Runbook — the issues namespace (Jira ticket corpus)¶
Feature: FEAT-454 — Jira Ticket Extractor → LLM Wiki Audience: operators running the daily sweep; Claude Code sessions querying the corpus. Status: current.
What this is¶
Every Jira ticket matching a JQL scope is extracted into one deterministic
markdown document, plus satellite person/project/component/label notes,
under an off-repo corpus directory. wikitoolkit build --vault turns that
folder into a queryable, navigable SQLite plane — the same retrieval plane
wikitoolkit query/page/related already serve for the repository
itself — reachable as its own federated namespace (--ns issues,
FEAT-450).
Zero-LLM by default (G2): every frontmatter field is a Jira field or a pure function of one, so an unchanged ticket produces byte-identical output and a daily cron writes nothing when nothing changed.
Independent of this repo's own env/plane choice (FEAT-461). The
issues namespace is registered --global (PARROT_HOME/wikis.json),
not in this repo's .parrot/wiki.json — it is unaffected by WIKI_ENV/
ENV or which per-env overlay this repo's own primary plane resolves to.
Whether your local checkout runs the repo's wiki against local (sqlite,
no VPN) or dev (shared ArangoDB), wikitoolkit query --ns issues "..."
reaches the same corpus the same way. See the "Environments" section of
docs/guides/llm-wiki-guide.md for the repo's own env model — it does
not change anything documented in this runbook.
Install¶
source .venv/bin/activate
pip install 'ai-parrot[jira]'
# or, inside this monorepo's workspace:
uv pip install -e 'packages/ai-parrot[jira]'
python -c "import jira, html2text; print(jira.__version__, html2text.__file__)"
jira (pycontribs) and html2text are optional — every FEAT-454 module
imports them lazily, so wikitoolkit --help and every other command work
with neither installed. Only wikitoolkit ingest-jira needs them, and
fails with a single actionable line (naming this same install command) if
they are absent — never a raw traceback.
Credentials¶
Reused verbatim from JiraToolkit — no new credential surface:
| Key | Purpose |
|---|---|
JIRA_INSTANCE |
Jira base URL (e.g. https://yourcompany.atlassian.net) |
JIRA_AUTH_TYPE |
One of basic_auth, token_auth, oauth, oauth2_3lo |
JIRA_USERNAME |
Basic-auth username |
JIRA_API_TOKEN / JIRA_PASSWORD |
Basic-auth secret |
JIRA_SECRET_TOKEN |
Personal Access Token (token_auth) |
JIRA_OAUTH_CONSUMER_KEY, JIRA_OAUTH_KEY_CERT, JIRA_OAUTH_ACCESS_TOKEN, JIRA_OAUTH_ACCESS_TOKEN_SECRET |
OAuth 1.0a (oauth) |
JIRA_REQUEST_TIMEOUT |
HTTP timeout in seconds (default 30) |
JIRA_AUTH_TYPE has no default. Unlike some config keys, there is no
heuristic fallback — leaving it unset means every read call raises rather
than silently guessing a mode or falling back to a shared service account.
Set it explicitly.
For OAuth 2.0 (3LO), the corpus sweep authenticates the same way
JiraToolkit does via JiraOAuthManager — see that toolkit's own docs for
the per-user authorization flow. A cron sweep normally uses basic_auth or
token_auth (a service account), since 3LO is inherently per-human-user.
One-time setup¶
The sweep only emits markdown and builds the local plane — it never registers a namespace. Run this once, by hand, after the first successful sweep:
wikitoolkit ns add issues \
--store "${PARROT_HOME:-$HOME/.parrot}/wikis/issues/.parrot/wiki" \
--global \
--description "Jira ticket corpus"
--store(not--vault): the issues directory has no.obsidian/marker, andns add --vaultrequires one.--storepoints directly at a pre-built plane directory.--globalwritesPARROT_HOME/wikis.json(per-user), not this repo's.parrot/wiki.json— the ticket corpus is not part of any one repository.- This step is required.
ingest-jiranever self-registers a namespace by design —ns addis documented as "the only writer of namespace entries" — so a successful sweep followed byquery --ns issuesfinding nothing almost always means this step was skipped.
Verify:
The daily sweep¶
# Daily Jira -> issues-namespace sweep (FEAT-454). Host: <FILL IN — operator choice>.
17 6 * * * cd /path/to/checkout && \
/path/to/.venv/bin/wikitoolkit ingest-jira --quiet \
>> /var/log/parrot/jira-ingest.log 2>&1
Daily cadence is the resolved default (spec §8) — the code is indifferent
to cadence or host; both are operator choices. ingest-jira builds the
plane by default (G10), so the cron line above is the entire pipeline: no
separate build step is needed. A "partial" run (see below) exits
non-zero, so cron's own mail-on-failure already surfaces it — no extra
alerting wiring required.
The first load: --backfill¶
The initial load of a scope is a different job from the daily sweep — it
fetches everything instead of a day's changes, and it is the one run where
"did the whole scope actually land?" matters. That is --backfill:
It is a preset, not a separate code path:
| It sets | Why |
|---|---|
--force |
ignore any stored watermark; fetch the whole scope |
--concurrency 16 |
each ticket costs two round trips, and the second one dominates the wall clock |
| progress every 100 issues | a multi-thousand-ticket load should not look hung |
| scope-completeness enforcement | a fetch that came up materially short of Jira's own count for the scope fails the run, so no watermark is ever recorded over a corpus that was never fully fetched |
Any explicit --concurrency / --progress-every still wins over the
preset, so --backfill --concurrency 4 is a gentle backfill.
Measured on this repo's own corpus (109 tickets, Jira Cloud): 39s at
--concurrency 1, 9s at the --backfill default — and the two corpora
came out byte-identical apart from the sync.fetched_at stamp. Render
order cannot affect the result: each document is rendered from its own
ticket, and the aggregates are a max plus sets keyed by entity.
Concurrency is bounded by a semaphore, and the sweep resizes the
underlying requests connection pool to match (its default is 10, so an
unresized pool would discard and re-handshake connections above that).
--concurrency 1 reproduces the strictly sequential sweep exactly, and
never touches the pool.
Querying it¶
wikitoolkit query --ns issues "tenant in the URL"
wikitoolkit page issues::file:NAV-9372.md
wikitoolkit related issues::file:NAV-9372.md
related follows the [[KEY]] wikilinks the renderer emits (epic,
parent, subtasks, blocks/duplicates/relates) plus the person/project pages
— all derived by the existing, unmodified scan_vault, never written as
edges by this feature directly.
Scope: the default JQL and how to widen it¶
The shipped default (spec §8, resolved) is:
A single project, no status filter, no date bound — closed and
resolved tickets are in scope deliberately, since "what do we already know
about X" is answered mostly by finished work. The first run over this
scope is a full backfill; the stored watermark (updated >=) makes every
run after that fetch only what changed.
To widen scope, set JIRA_WIKI_JQL to any valid JQL, or pass --jql/
--project per invocation:
wikitoolkit ingest-jira --jql 'project in (NAV, FORMS) AND updated >= -30d'
wikitoolkit ingest-jira --project NAV
Changing the JQL starts a new watermark scope (keyed by a fingerprint of the normalized JQL) — the old scope's watermark is untouched, so widening never silently loses history for the narrower scope.
Re-rendering everything¶
Two independent ways to force a full re-render, ignoring the stored watermark:
--force— re-fetches every issue in the JQL scope this run, regardless of the watermark. A byte-identical render still counts asunchangedand is not rewritten (mtime untouched). For the first load of a scope prefer--backfill, which is--forceplus the guarantees a one-shot load wants (see above).- An
EXTRACTOR_VERSIONbump (code change,parrot.knowledge.wiki .jira_render.EXTRACTOR_VERSION) — the sweep detects a scope's storedextractor_versionis older than the running code's and automatically forces a full re-render for that scope, even whenupdatedis unchanged. This is how a renderer bugfix reaches already-synced tickets without an operator needing to remember--force.
Reading a SweepReport¶
ingest-jira prints a summary (or --json/-q for machine/short output):
| Field | Meaning |
|---|---|
fetched |
Issues returned by this run's JQL |
written |
Documents actually rewritten (bytes changed) |
unchanged |
Byte-identical renders, left untouched |
orphaned |
Documents on disk whose key fell out of scope (full sweeps only — never deleted) |
entity_notes |
Person/project/component/label notes emitted this run |
unresolved_link_keys |
Relation targets that fell outside the JQL scope, so scan_vault dropped their edge (the key still survives in frontmatter) |
approx_scope_count |
Jira's own approximate count for the effective scope, when available (Cloud-only) — the yardstick for the shortfall check |
warnings |
Non-fatal findings, e.g. a scope-completeness shortfall (an error instead, under --backfill) |
watermark_advanced |
true only after a fully successful pass |
errors |
Non-empty means the run is "partial" — the watermark was not advanced |
A non-empty unresolved_link_keys is not a bug — it is the signal to
widen JIRA_WIKI_JQL if that relation matters for the corpus. It is also
the first place a truncated fetch shows up: if the list names tickets
that are plainly inside the declared scope, the run did not fetch the
whole scope — check fetched against
client.approximate_issue_count(jql) before widening anything. A
"partial" run means exactly what it says: something failed mid-sweep,
the watermark was deliberately left untouched so the next run does not
trust an incomplete pass, and ingest-jira exits non-zero.
Your own notes survive every re-sync¶
Every generated document and every satellite note ends with:
Anything you (or a teammate) write below that line survives every future re-sync byte-for-byte, forever — the extractor only ever owns the region above it. A hand-created file with no marker gains one on first sync; nothing in it is discarded.
What is not synced in v1¶
- Comments. Explicitly out of scope — the largest token contributor and highest-churn field on re-sync.
- Attachment and remote-link payloads. Both are recorded as
references only (attachments: filename, size, URL; remote links:
title, URL, under a
## Remote Linkssection) — nothing is downloaded. - Cross-namespace edges.
wikitoolkit linkrefuses them outright: "Both pages must live in the same plane — there are no cross-namespace edges". A ticket ↔ repo-spec relationship is a text-level join only — the frontmatterrepo_pageslist plus the**Jira**:line the repo plane already FTS-indexes, findable byquery, openable bypage, not traversable byrelated. Real federated edge traversal across namespaces is deferred to a follow-up spec extending FEAT-450. - PII beyond display name. Person pages carry
displayNameandaccountIdonly — no email address is ever captured (G9). The parse boundary dropsemailAddressbefore it can reach any model, document, or export. As defense in depth, any email-shaped string that shows up in free text (description, acceptance criteria) is also redacted at render time — G9's own acceptance criterion is a blanket check over the whole generated corpus, not just structured Jira user objects.
G8 — keep the corpus out of git¶
The default corpus root (${PARROT_HOME}/wikis/issues) resolves outside
the repository working tree even when PARROT_HOME is unset. Do not
point JIRA_WIKI_ISSUES_DIR at a path inside a git checkout — internal
ticket prose and customer names must never enter git history.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| Every call raises an auth error | JIRA_AUTH_TYPE unset — there is no heuristic default |
Set it explicitly |
Sweep reports 0 fetched, run marked "partial" |
Jira Cloud silent auth failure (X-Seraph-Loginreason: AUTHENTICATED_FAILED) — the watermark deliberately did not advance |
Re-check/rotate credentials |
query --ns issues finds nothing after a successful sweep |
The namespace was never registered — ns add is a required one-time step |
Run the ns add command above |
unresolved_link_keys is non-empty |
A ticket links outside the JQL scope; the edge is dropped but the key is still in the frontmatter | Widen JIRA_WIKI_JQL if the relation matters |
A large orphaned count |
Tickets moved project, were renamed, or the JQL narrowed | Review; documents are never auto-deleted |
fetched stops at exactly 100 (or whatever the page size is), with many unresolved_link_keys |
Fixed: Jira Cloud retired the offset-based /search endpoint, so paging by startAt returned one page and no total, and JiraInterface.search_issues read the missing total as "exhausted". It now follows nextPageToken on Cloud (/search/jql) and keeps the startAt loop for Server/DC |
Upgrade, then backfill with --force: the truncated run stored an "ok" watermark, so a plain incremental run will not go back for the tickets it never saw |
| Acceptance-criteria section missing from a ticket | The AC custom field did not resolve (no JIRA_WIKI_AC_FIELD and no by-name match) |
Set JIRA_WIKI_AC_FIELD to the exact custom field id |
related does not reach a repo spec |
Cross-namespace edges do not exist by design | Use query across namespaces instead of related |
Configuration reference¶
| Key | Default | Purpose |
|---|---|---|
JIRA_WIKI_ISSUES_DIR |
${PARROT_HOME}/wikis/issues |
Off-repo corpus root (G8) |
JIRA_WIKI_JQL |
project = ${JIRA_DEFAULT_PROJECT} |
Default sweep scope |
JIRA_WIKI_NAMESPACE |
issues |
Namespace name this runbook registers |
JIRA_WIKI_CONCURRENCY |
8 (16 under --backfill) |
Issues fetched concurrently; also sizes the HTTP connection pool |
JIRA_WIKI_AC_FIELD |
(unset) | Acceptance-criteria custom field id; falls back to a by-name match, else the section is omitted |
JIRA_INSTANCE, JIRA_AUTH_TYPE, JIRA_USERNAME, JIRA_API_TOKEN, JIRA_SECRET_TOKEN, JIRA_OAUTH_*, JIRA_REQUEST_TIMEOUT |
(existing) | Reused verbatim from JiraToolkit |
CLI reference¶
--jql TEXT JQL scope (default: JIRA_WIKI_JQL, or `project =
<JIRA_DEFAULT_PROJECT>`).
--project TEXT Shorthand for `project = <KEY>`.
--since TEXT Override the stored watermark (ISO-8601).
--issues-dir DIRECTORY Output directory (default: JIRA_WIKI_ISSUES_DIR or
${PARROT_HOME}/wikis/issues).
--build / --no-build Build the plane after emitting (FEAT-454, G10).
[default: build]
--enrich Opt-in LLM summary for thin descriptions — not
implemented in v1.
--force Re-render every issue in scope, ignoring the stored
watermark.
--backfill One-shot full load of the scope. A preset: implies
--force, raises the default concurrency, prints
progress, and FAILS the run (leaving the watermark
untouched) if the fetch came up short of Jira's own
count for the scope.
--concurrency INTEGER RANGE
Issues fetched concurrently (default:
JIRA_WIKI_CONCURRENCY, else 8; 16 under --backfill).
1 sweeps strictly sequentially. [1<=x<=64]
--progress-every INTEGER RANGE
Print a progress line every N issues (0 disables;
default 100 under --backfill). [0<=x<=100000]
--dry-run Report what would change; write nothing at all.
--json Emit the SweepReport as JSON.
-q, --quiet Only print the final summary line.
See also¶
- Spec:
sdd/specs/jira-extractor-llmwiki.spec.md - Shared read interface:
packages/ai-parrot/src/parrot/interfaces/jira/ - Renderer:
packages/ai-parrot/src/parrot/knowledge/wiki/jira_render.py - Sweep:
packages/ai-parrot/src/parrot/knowledge/wiki/jira_sync.py - CLI command:
packages/ai-parrot/src/parrot/knowledge/wiki/cli.py(ingest-jira)