This repository now defines a minimal Context Bridge between:
- GitLab: merge requests, branches, commits
- Jira: tasks, subtasks, bugs, Confluence documentation links
The goal is to provide one normalized payload that an LLM can consume without querying both systems independently.
From a single entry-point Jira key, latoile recursively walks the Jira relationship graph (parent, subtasks, siblings, issue links, and keys mentioned in descriptions/comments), enriches every issue with its GitLab context (merge requests, branches, commits), builds a graph, and serves it through a live backend plus an interactive frontend visualizer.
Authentication piggybacks on the locally logged-in acli (Atlassian) and
glab (GitLab) CLI sessions by default. For much faster startup, both
clients can bypass their CLIs entirely:
- Jira: set
LATOILE_JIRA_URL+LATOILE_JIRA_EMAIL+LATOILE_JIRA_TOKEN(Atlassian API token from https://id.atlassian.com/manage-profile/security/api-tokens). Each issue fetch drops from ~5 s (acli spawn) to ~0.3 s. - GitLab: the HTTP client reads the PAT from your local glab config automatically
(
%LOCALAPPDATA%\glab-cli\config.ymlon Windows). Can be overridden withLATOILE_GITLAB_TOKEN. No credentials are stored in this repository.
This repo uses yarn.
yarn install
yarn build # compile TypeScript (backend + frontend) to dist/ and public/app.js
# 1) Live backend + frontend (runs acli/glab on demand)
yarn start # → http://localhost:3000
# open the UI, enter a Jira key, or deep-link: http://localhost:3000/?key=JIRA-123
# 2) Direct data fetch, no UI (writes JSON to stdout or a file)
yarn graph JIRA-123 --view full --out JIRA-123.graph.json
# or, after building: node dist/src/cli.js JIRA-123 --view fullNote: the Jira client calls acli jira workitem view <KEY> --fields '*all' --json
(the key is positional; older acli versions used --key, which current versions
reject). If issues come back empty, check acli jira auth status first.
Jira's "GitLab for Jira Cloud" plugin writes a dev-status summary into each
issue (customfield_10000). The collector reads it as a hint: when Jira says
an issue has zero branches, commits, and MRs, the GitLab lookup is skipped
entirely. Otherwise MRs are searched by Jira key in the title across your
configured projects or groups (the team convention: branch names and MR titles
contain the key, e.g. fix/PV2-17818-...).
GitLab calls go through src/collector/gitlab-http.ts, which uses fetch()
with the token of your logged-in glab session (read from glab's config file
at runtime, or from LATOILE_GITLAB_TOKEN). This is roughly 15× faster than
spawning a glab process per request. The glab-spawning client
(src/collector/glab.ts) is still there and shares the same normalizers.
Every request runs the collector against acli and the GitLab API on demand.
| Endpoint | Description |
|---|---|
GET /api/graph/:key |
Renderable graph { nodes, edges } (default) |
GET /api/graph/:key?view=context |
Normalized LLM context payload |
GET /api/graph/:key?view=full |
Both graph and context |
GET /api/search?q=text |
Jira text search (top 8 matches, for the UI autocomplete) |
GET /api/resolve-mr?url=… |
Resolves a GitLab MR link to its Jira key (paste-a-link flow) |
GET /api/health |
Liveness probe |
Query params: maxDepth (traversal depth), maxNodes (node cap), and
refresh=1 (bypass the fetch cache) override the defaults per request. Requesting /api/graph/:key with Accept: text/event-stream
streams progress logs as server-sent events, then the final payload — the UI
uses this for its loading indicator.
Example:
curl "http://localhost:3000/api/graph/JIRA-123?view=context&maxDepth=2"| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
Backend HTTP port |
LATOILE_MAX_DEPTH |
2 |
Traversal depth from the entry point |
LATOILE_MAX_NODES |
100 |
Hard cap on fetched Jira nodes |
LATOILE_GITLAB_PROJECTS |
(empty) | Comma-separated group/project paths to search (takes precedence over groups) |
LATOILE_GITLAB_GROUPS |
(empty) | Comma-separated group paths or IDs; projects are enumerated once per run |
LATOILE_GITLAB_ACTIVE_DAYS |
90 |
Skip group projects with no activity in this many days |
LATOILE_GITLAB_CONCURRENCY |
8 |
Max parallel GitLab API requests — lower it if you hit 429s |
LATOILE_GITLAB_MAX_RETRIES |
4 |
Retries on an HTTP 429, honoring Retry-After/RateLimit-Reset with capped backoff |
LATOILE_ORBIT |
(off) | Set to 1 to attach a per-issue code neighborhood from a local GitLab Orbit graph (see PLAN.md, section 4); needs LATOILE_GITLAB_FETCH_FILES=1 too |
LATOILE_ORBIT_BIN |
orbit |
Orbit CLI binary |
LATOILE_ORBIT_DB |
(default) | Override the Orbit DuckDB path (default: ~/.orbit/graph.duckdb) |
LATOILE_ORBIT_MAX_DEFS |
40 |
Max definitions returned per touched repo |
LATOILE_GITLAB_TOKEN |
(empty) | Override the token normally read from glab's config |
LATOILE_GITLAB_FETCH_FILES |
(off) | Set to 1 to fetch each MR's changed file paths (persisted as :File/TOUCHES in the knowledge graph); one extra API call per MR |
LATOILE_JIRA_URL |
(empty) | e.g. https://your-org.atlassian.net; enables the direct Jira HTTP client |
LATOILE_JIRA_EMAIL |
(empty) | Atlassian account email (used with LATOILE_JIRA_TOKEN) |
LATOILE_JIRA_TOKEN |
(empty) | Atlassian API token — when set with URL+email, replaces acli (~15× faster) |
LATOILE_CLI_DELAY_MS |
0 |
Delay between CLI calls (rate limiting) |
LATOILE_CLI_RETRIES |
2 |
Retries on transient CLI failures |
LATOILE_CLI_TIMEOUT_MS |
30000 |
Per CLI-call timeout |
LATOILE_ACLI_BIN / LATOILE_GLAB_BIN |
acli / glab |
Binary overrides |
LATOILE_CACHE |
on |
Set to off to disable the SQLite fetch cache |
LATOILE_CACHE_PATH |
~/.latoile/cache.db |
Cache file location |
LATOILE_CACHE_TTL_MIN |
15 |
Cache freshness window in minutes |
LATOILE_NEO4J_URI |
(empty) | e.g. bolt://localhost:7687; enables the knowledge-graph sink |
LATOILE_NEO4J_USER / LATOILE_NEO4J_PASSWORD |
neo4j / (empty) |
Neo4j credentials |
LATOILE_NEO4J |
on |
Set to off to disable the knowledge-graph sink |
LATOILE_WATCHER_STALE_MIN |
1440 |
Minutes since last_seen before yarn watcher re-checks an issue |
LATOILE_WATCHER_BATCH |
20 |
Max issues yarn watcher re-traverses per run |
LATOILE_EXPORT_DIR |
./export |
Output root for yarn export / yarn derive |
LATOILE_EXPORT_CONCURRENCY |
6 |
Parallel acli calls during a bulk export |
LATOILE_EXPORT_DELAY_MS |
150 |
Pause per worker between issues during a bulk export |
LATOILE_EXPORT_SHARD_PAUSE_MS |
5000 |
Pause after each sealed shard |
LATOILE_EXPORT_ENUMERATE_TIMEOUT_MS |
900000 |
Timeout for the one long enumeration call (a whole project takes minutes, unlike LATOILE_CLI_TIMEOUT_MS) |
A .env file in the project root (gitignored) is loaded at startup; shell
exports win over .env values. Set at least LATOILE_GITLAB_GROUPS or
LATOILE_GITLAB_PROJECTS, otherwise GitLab enrichment returns nothing and
logs a warning.
Jira issues and GitLab lookups are cached in a single-file SQLite database
(Node's built-in node:sqlite, so Node ≥ 22.13 is required — no native
dependency). Entries expire after LATOILE_CACHE_TTL_MIN minutes; repeat
lookups within the window are near-instant. ?refresh=1 (API) or
refresh: true (pipeline/MCP) forces live fetches while still updating the
cache. Failed Jira lookups are never cached.
Beyond the per-request cache, latoile can remember everything it has seen:
every traversal upserts its issues, MRs, commits, people, and doc links into a
local Neo4j database with first_seen/last_seen timestamps, so coverage
accumulates across runs and becomes queryable across tickets (Cypher via the
Neo4j browser at http://localhost:7474). Start the database with
docker compose up -d neo4j and set LATOILE_NEO4J_URI +
LATOILE_NEO4J_PASSWORD in .env. Without configuration the feature is
simply off; if the database is down, runs proceed and log a warning. Design
and roadmap (query tools, incremental refresh): PLAN.md, section 1.
latoile exposes its pipeline as an MCP tool so coding agents can pull ticket context mid-conversation:
yarn build:server
claude mcp add latoile -- node /path/to/latoile/dist/src/mcp/server.jsThree tools are exposed, all returning structured content:
| Tool | Purpose |
|---|---|
get_context(jiraKey, maxDepth?, maxNodes?, refresh?, maxAgeSeconds?) |
Full traversal → normalized LLM context payload. With maxAgeSeconds: a fully fresh neighborhood is answered instantly from the knowledge graph, and otherwise only the stale frontier is fetched live (source: live / knowledge_graph / partial) |
get_context_from_mr(mrUrl, …) |
Same, from a GitLab MR link — the Jira key is extracted from the MR's source branch, title, or description (resolved_from block says which) |
search_issues(query, limit?) |
JQL full-text search, newest-updated first — find the key when only a topic is known |
get_issue(jiraKey) |
Single issue (status, parent, subtasks, links…), no traversal — fast and cache-backed |
find_connection(keyA, keyB) |
Shortest path between two issues in the knowledge graph (offline) |
known_context(jiraKey) |
What the knowledge graph already knows: stored fields, neighbors, ageSeconds freshness |
person_activity(name, sinceDays?) |
Issues assigned / MRs+commits authored by a person (offline) |
project_activity(projectPath, sinceDays?) |
Issues and MRs that touched a GitLab project — check every repo an earlier fix involved (offline) |
graph_stats() |
Knowledge-graph size and freshness by node/relationship type |
The last four query the Neo4j knowledge graph and need LATOILE_NEO4J_URI
configured; they answer instantly from accumulated data, without live
Jira/GitLab calls.
get_context streams pipeline progress as MCP progress notifications when the
client provides a progressToken, and always as logging notifications.
Configuration comes from the same environment / .env as the server, resolved
from the working directory the MCP server is started in. Run it manually with
yarn mcp.
yarn export downloads every work item of a Jira project to gzipped NDJSON
— one raw issue payload per line, all 640-odd fields preserved, including full
comment threads and ADF descriptions. It is the corpus builder: everything
downstream (chunking, embeddings, the relationship graph) reads the dump
instead of hitting Jira again.
yarn export --project PV2 --verbose # full project, resumable
yarn export --project PV2 --limit 50 --verbose # smoke runMeasured on PV2 (16,302 items): ~80 minutes at the default concurrency 6,
~790 MB raw / ~80 MB gzipped, ~1.2 s per acli call.
export/pv2/
keys.json enumeration (reused across runs; --refresh to redo)
raw/pv2-0001.ndjson.gz one raw issue per line, 1000 per shard
done.txt checkpoint — rerunning skips these keys
errors.ndjson per-issue failures; rerun to retry them
manifest.json counts, timings, shard list
Resumable by design. Shards are written to .tmp and renamed on
completion, and a key enters done.txt only once its shard is on disk — so a
killed run loses at most one shard's worth of fetches and never leaves a
truncated file that looks complete. Rerun the same command to continue.
Useful flags: --concurrency, --delay-ms and --shard-pause-ms (pacing for
unattended overnight runs), --max-minutes (stop cleanly at a checkpoint),
--from/--to YYYY-MM (enumerate month by month instead of one paginated
call — needed for projects large enough that a single enumeration would
approach the 20 MB process-output limit), --no-repair-comments.
Why per-issue fetches rather than a bulk search: acli jira workitem search
whitelists display fields and rejects comment, issuelinks, parent,
subtasks, created, updated, components and resolution. Only
workitem view --fields '*all' returns a complete record. Two related acli
traps the exporter works around: --fields 'key' alone silently returns an
array of nulls (key is not a field — it must be paired with a real one),
and --limit has no offset, so paging deeper needs --paginate or JQL
windows.
Known gap: changelog is null even under *all — status-transition history
needs expand=changelog, which acli does not expose.
yarn derive reads the dump offline and emits an explicit graph — the raw
payloads contain every relationship, but buried where a retriever cannot use
them.
yarn derive --project PV2 --verbose # writes export/pv2/graph/
yarn derive --project PV2 --load --verbose # ...and upserts it into Neo4j| Edge | Answers |
|---|---|
ASSIGNED_TO |
who has to do this |
REPORTED_BY / CREATED_BY |
who filed it |
COMMENTED_ON |
who said something, when |
MENTIONS_PERSON |
who mentioned whom — carries by (the author) and source (description or comment:<id>) |
MENTIONS_ISSUE |
which item references which, and who wrote the reference |
PARENT_OF / HAS_SUBTASK |
hierarchy |
LINKED |
typed Jira links (blocks, Cloners, …), normalized to one direction |
Output is graph/issues.ndjson, graph/people.ndjson, graph/edges.ndjson
and graph/stats.json. --load merges into the same :Issue / :Person
nodes the traversal sink uses (people keyed by personKey, tagged
:JiraUser), so the corpus enriches the existing knowledge graph rather than
building a parallel one; corpus-written facts carry corpus: true.
Two caveats worth knowing: Jira redacts mentions of deactivated accounts as
@unknown / id: "unknown" (dropped rather than merged into one fictitious
person), and edges pointing outside the exported project are skipped instead of
creating contentless stub nodes.
yarn watcher is a one-shot re-traversal of the stalest issues in the
knowledge graph — it lists issues whose last_seen is older than
LATOILE_WATCHER_STALE_MIN (default 24h), re-fetches up to
LATOILE_WATCHER_BATCH of them (default 20, oldest first), and logs any
status/title/assignee change it observes. It's a plain script, not a daemon —
schedule it externally (cron, Windows Task Scheduler) at whatever cadence
suits your Jira/GitLab rate limits. Requires LATOILE_NEO4J_URI; a no-op
otherwise.
The project is written in TypeScript. The backend compiles to dist/ and the
frontend compiles to public/app.js (see tsconfig.json / tsconfig.web.json).
src/
collector/ acli & glab clients, CLI runner, recursive BFS traversal
model/ graph builder + normalized LLM context builder
api/ live Express backend
web/ frontend visualizer source (compiled to public/app.js)
types.ts shared domain types
pipeline.ts wires collector → model
cli.ts command-line entry point
public/ frontend static assets (Cytoscape UI; app.js is generated)
test/ unit + integration tests (node --test)
dist/ compiled backend + tests (generated, git-ignored)
Build with yarn build and run the tests with yarn test (the pretest
hook compiles the backend first). Type-check without emitting via yarn typecheck.
The frontend loads Cytoscape 3.30.2 and the Quicksand font from CDNs (pinned in
public/index.html). Cytoscape is not an npm dependency; update both the CDN tag and this note together when bumping the version.
The UI ships light and dark palettes built from the company design tokens
(public/styles.css), defaulting to dark with a toggle in the header. The
search box autocompletes against /api/search when you type text instead of
a key. The canvas supports mouse-wheel and pinch zoom, +/−/fit buttons, PNG
and JSON export, and double-clicking a node opens it in Jira or GitLab (set
LATOILE_JIRA_BASE_URL for Jira links).
{
"work_item": {
"id": "JIRA-123",
"type": "task|subtask|bug",
"title": "Short summary",
"status": "In Progress",
"assignee": "user",
"parent_id": "JIRA-100"
},
"gitlab": {
"merge_request": {
"id": 42,
"title": "feat: improve checkout validation",
"state": "opened",
"source_branch": "feature/JIRA-123-checkout-validation",
"target_branch": "main",
"url": "https://gitlab.example.com/group/project/-/merge_requests/42"
},
"branch": {
"name": "feature/JIRA-123-checkout-validation",
"last_commit_sha": "abc123..."
},
"commits": [
{
"sha": "abc123...",
"title": "feat: add checkout guard",
"author": "user",
"timestamp": "2026-07-08T10:00:00Z"
}
]
},
"documentation": [
{
"source": "confluence",
"title": "Checkout validation design",
"url": "https://confluence.example.com/display/TEAM/Checkout+Validation"
}
],
"comments": [
{
"author": "user",
"created": "2026-07-09T14:30:00Z",
"body": "Validation must also fire on quantity change, see JIRA-456."
}
],
"traceability": {
"links": [
{
"jira_key": "JIRA-123",
"merge_request_id": 42,
"commit_sha": "abc123..."
}
]
},
"traversal": {
"nodes_fetched": 5,
"total_nodes": 8,
"depth_reached": 2,
"max_depth": 2,
"max_nodes": 100,
"node_cap_hit": false,
"depth_limit_hit": true
}
}traversal reports how complete the walk was, so a consumer can tell a genuine
empty neighborhood from a budget-truncated one. node_cap_hit (raise maxNodes)
and depth_limit_hit (raise maxDepth) are kept separate because they imply
different remedies — at the default depth of 2, depth_limit_hit is often true
on non-trivial graphs, which is expected, not an error.
Beyond the per-issue context object, latoile emits a graph payload
({ nodes, edges }) for visualization:
- Node types:
jira,merge_request,doc. Branches and commits are not separate nodes: the MR node carriessourceBranch,commitCount, and thecommitslist (shown in the UI details panel). - Edge types:
parent,subtask,sibling,link(typed),mention(Jira ↔ Jira);has_mr(Jira → MR);documented_by(Jira → doc). The valid source/target types per edge are declared inEDGE_SCHEMA(src/model/graph.ts). - Every edge carries a
strength:strongfor structural Jira links,weakfor text mentions. Consumers can filter on it. - The context payload lists
repositoriesper work item and for the whole context — one work item routinely lands MRs in several repos (microservices + microfrontends), and a fix attempt should consider every repo the original fix touched. - The entry-point node is flagged (
isEntry) and highlighted in the frontend; keys discovered beyondmaxDepth/maxNodesappear as unresolved placeholders.
- Resolve the entry Jira issue and traverse parent, subtasks, siblings, issue links, and description/comment mentions (breadth-first, visited-set, depth/node limits).
- Resolve GitLab merge requests, branches, and commits related to each Jira key.
- Attach relevant Confluence / remote documentation links.
- Output one normalized JSON context object (example above) for LLM prompts, and a graph payload for the frontend.
- Branch names should include the Jira key (
feature/JIRA-123-*). - Merge request title/description should include the Jira key.
- Commit messages should include the Jira key when possible.
- Confluence links should be attached through Jira issue links or labels.