Skip to content

Repository files navigation

latoile

Intermediary layer: GitLab + Jira context for LLMs

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.yml on Windows). Can be overridden with LATOILE_GITLAB_TOKEN. No credentials are stored in this repository.

Quick start

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 full

Note: 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.

How GitLab data is found

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.

Backend API (live)

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"

Configuration (environment variables)

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.

Fetch cache

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.

Knowledge graph (Neo4j)

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.

MCP server

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.js

Three 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.

Bulk project export (corpus for RAG)

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 run

Measured 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.

Relationship graph from the export

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.

Background watcher

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.

Project layout

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).

Unified context model

{
  "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.

Graph model

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 carries sourceBranch, commitCount, and the commits list (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 in EDGE_SCHEMA (src/model/graph.ts).
  • Every edge carries a strength: strong for structural Jira links, weak for text mentions. Consumers can filter on it.
  • The context payload lists repositories per 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 beyond maxDepth/maxNodes appear as unresolved placeholders.

Bridge behavior

  1. Resolve the entry Jira issue and traverse parent, subtasks, siblings, issue links, and description/comment mentions (breadth-first, visited-set, depth/node limits).
  2. Resolve GitLab merge requests, branches, and commits related to each Jira key.
  3. Attach relevant Confluence / remote documentation links.
  4. Output one normalized JSON context object (example above) for LLM prompts, and a graph payload for the frontend.

Suggested linking rules

  • 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.

About

Context bridge from a Jira key to a GitLab-enriched knowledge graph, with an MCP server for LLM-driven Jira/GitLab investigation.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages