jira-sync

v0.4.0

An idempotent, drift-aware, fail-closed reconcile engine that mirrors spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase).

Community extension — Independently maintained. Use at your own discretion. Learn more

spec-kit-jira-sync

A real sync engine that mirrors your spec-kit specs into Jira — idempotent, drift-aware, and fail-closed.

status: active development tests: 182 bats engine: vendor-neutral license: MIT privacy: no real identifiers

Point it at a spec-kit repository, run one command, and every feature under specs/NNN-feature/ shows up in Jira — grouped under a single per-repo Epic, one Story per spec, a Subtask per task phase, with the phase's tasks rendered as a checklist. Re-run it as often as you like: an unchanged corpus produces zero writes.


Not a prompt that POSTs to your board

The honest competitor in this space is a markdown command file that tells an agent "read the checkboxes and POST them to Jira." That works exactly once, on a clean board, with a patient operator watching. It has no idempotency (re-running duplicates issues), no drift detection (it clobbers whatever a teammate advanced), and no failure contract (a 401 mid-run leaves a half-mirrored board).

spec-kit-jira-sync is the other thing — a reconcile engine, built the way Kubernetes builds controllers: it reads the full desired state off disk, reads the actual state from Jira, computes the difference, and writes only that. The three properties that fall out of that design are the whole pitch:

PropertyWhat it meansWhy a prompt can't promise it
IdempotentRe-running against an unchanged corpus performs zero writes — no duplicate Epic, no rewritten fields, no re-posted comments.A prompt re-creates whatever it's told to create; it has no model of "already there."
Drift-awareWhen Jira is ahead of disk (someone advanced the Story), the engine surfaces a named WARNING and asks before overwriting — it never silently regresses your board.A prompt has no notion of "Jira is further along than my file."
Fail-closedIf Jira can't be read reliably (auth, network, deleted issue, sustained 429), the engine writes nothing for that spec, reports it, and exits non-zero.A prompt that can't read just guesses, or writes partial garbage.

The filesystem is the single source of truth. Jira is a unidirectional, read-only mirror. Edit a Story in Jira and the next reconcile restores the disk-derived state — after telling you it found drift.


The automatic mirror

You don't run a sync command. After you install the bridge, every spec-kit lifecycle command auto-mirrors to Jira/speckit-specify, .clarify, .plan, .tasks, .implement, .analyze. The board stays current as a side effect of normal spec-kit work, with nothing to remember (Principle VII — Memory-Just-Works). A bridge whose primary UI is "remember to run sync" drifts and gets abandoned; auto-firing on every transition is what earns the bridge its toolchain slot.

How it works: install registers the six after_* hooks into your .specify/extensions.yml, each firing speckit.jira.push with optional: false. An after_* hook fires after the lifecycle command has already completed, so the mirror can never retroactively fail your command:

  • Non-blocking. If the sync can't run (no .env creds, no jira-config.yml, Jira unreachable) the lifecycle command still succeeds and the bridge surfaces a single clean WARNING with the fix — never a hard error or a blocker (Principle VIII).
  • Idempotent. Each fire is an independent full reconcile; an unchanged corpus produces zero writes, so firing on every command is safe.
  • Operator-controlled. Set a hook enabled: false in your .specify/extensions.yml and the bridge honours it — reinstall never silently re-enables a hook you turned off, never duplicates one, and a re-run is byte-identical.

The on-demand commands (push / status / install / seed) remain as the recovery / escape-hatch path — for forcing a sync, previewing drift, binding the repo, or validating the workflow by hand.


How it works

The pipeline is deliberately split into a vendor-neutral engine and a Jira-specific sink, joined by one interface:

flowchart TB
    subgraph DISK["Your repo"]
        direction LR
        SPECS["specs/NNN-feature/<br/>spec.md - plan.md - tasks.md"]
    end
    subgraph NEUTRAL["Vendor-neutral engine"]
        direction LR
        PARSER["parser.sh<br/>infer lifecycle phase"]
        WS["workstate JSON<br/>neutral, schema-valid"]
        ENGINE["reconcile.sh<br/>drift - recency - idempotency"]
        PARSER --> WS --> ENGINE
    end
    subgraph SINK["Jira-specific sink"]
        direction LR
        JSINK["jira_sink.sh<br/>workstate to REST"]
        REST["Jira Cloud REST v3<br/>Epic - Story - Subtask"]
        JSINK --> REST
    end
    SPECS --> PARSER
    ENGINE -->|mutate_ / query_ / sync_| JSINK
  1. Parse. parser.sh reads each specs/NNN-feature/ directory and infers the lifecycle phase from which artifacts exist and what they contain — no operator annotation required.
  2. Neutralize. The parser emits workstate — a neutral, schema-valid JSON document. The engine and sink never model Jira directly; they pass workstate items around. Every emitted document is validated against the published workstate.schema.json (Draft 2020-12) before any write.
  3. Reconcile. reconcile.sh (the engine, copied vendor-neutral from the shipped spec-kit-linear) computes per-spec drift and recency, then drives writes through a fixed set of mutate_* / query_* / sync_* functions.
  4. Sink. jira_sink.sh implements that interface against Jira Cloud REST — idempotency lookups by label, ADF body rendering, transition POSTs, and the bounded 429-backoff fail-closed read path.

The workstate contract — and why this repo exists

workstate is a tracker-agnostic interchange format owned by a separate schema repo. The deal is: a producer emits workstate; any sink consumes it. The Jira sink reads only documented workstate fields and ignores unknown extensions keys (so the format can grow without breaking sinks).

This repo is the independent second consumer that proves workstate is genuinely neutral. A from-scratch Jira sink that eats the same workstate a Linear-targeted tool produces is the validation that the format wasn't secretly shaped to one vendor. The clean engine/sink seam is also what lets the two tools later collapse into one shared engine plus per-tracker sinks.

The engine vs sink seam

flowchart TB
    ENGINE["reconcile.sh - the engine<br/>(zero Jira knowledge)"]
    IFACE{{"the interface<br/>mutate_issue_create - mutate_issue_update<br/>query_by_label - sync_subtasks - sync_comments<br/>config::get_status_transition"}}
    SINK["jira_sink.sh - the sink<br/>(all Jira knowledge)"]
    ENGINE --> IFACE --> SINK

config::get_status_transition (lifecycle phase → Jira status + transition id) is the single vendor lever. Swap the sink and the same engine drives a different tracker — that is the whole point of keeping Jira specifics out of the engine.


What lands in Jira

spec-kit artifacts map to Jira primitives:

flowchart LR
    REPO["consumer repo"] --> EPIC["Epic<br/>(1 per repo)"]
    SPEC["spec/ NNN-feature"] --> STORY["Story<br/>(under the Epic)"]
    PHASE["Phase N<br/>in tasks.md"] --> SUB["Subtask<br/>(of the Story)"]
    TASKS["tasks<br/>in that phase"] --> ADF["ADF checklist<br/>(in the Subtask body)"]
    EPIC -.-> STORY -.-> SUB -.-> ADF
On diskJira primitiveIdempotency key
Consumer repositoryEpic (1 per repo, reused across runs)label speckit-repo:<slug>
Spec (specs/NNN-feature/)Story under the repo Epiclabel speckit-spec:NNN
Lifecycle phaseStory status + phase:* label; a terminal spec (ready_to_merge/merged) also cascades every phase Subtask to the done statusconfig phase→status map
Task phase (## Phase <idx>)Subtask of the Story (header index may be numeric or a single letter, separated by :/-/en-/em-dash)parent + task-phase:<idx> label
Tasks within a phaseADF checklist in the Subtask bodycontent diff
Clarification / decision sessionscommentsmarker prefix → at-most-once
ADRs / decision records (research.md)comments (one per decision)marker speckit-adr:<spec>-<id> → at-most-once, updated in place on a content change
Cross-spec dependenciesissue links(rel, target) → at-most-once

The ADF checklist is today's default. Configurable artifact mapping and a 2-level mode (collapse tasks into a Story-body checklist instead of separate Subtask issues) are on the roadmap.

Where the Story title comes from. The spec issue's title is derived from spec.md by a deterministic source ladder (no AI, no network — the bridge never summarizes at reconcile time): an explicit Title: line → a concise # Feature Specification: H1 → the first sentence of the ## Summary section → the directory slug, first match wins. Every rung is capped at 120 characters on a word boundary (no ellipsis). A clean, within-cap H1 is used verbatim — byte-identical to before — so well-formed specs never see a title change; only specs whose H1 is the unfilled [FEATURE NAME] placeholder, is a verbose pasted wall, or is missing get a readable title instead of a raw slug.

Upgrading a multi-spec board? A bug in earlier builds matched a phase Subtask by its task-phase:N label alone — a phase number that is unique only within a spec — so across a repo with several specs every spec's "Phase N" collided on the same Subtask. The phase find is now scoped to the parent Story, so each spec mirrors to its own Subtask. A plain reconcile.sh --all self-heals a mis-scoped board: the collided Subtasks belong to the first-processed spec, which re-matches and corrects them, and every other spec creates its own — no orphans. For a clean slate, prune the old task-phase:* Subtasks (or run --remode) before re-pushing. See the CHANGELOG for details.

Merging a spec marks its phases done. When a spec reaches a terminal lifecycle (ready_to_merge or merged), the next reconcile transitions every phase Subtask under it to the done status — in the default config, no status-rollup needed. Previously a merged Story sat over a column of "To Do" Subtasks; now the whole spec reads complete. The cascade is idempotent (it fires only on a real status change), fail-closed on an unreadable read, and forward-only (it does not un-set children if the lifecycle later regresses). Phase headers are also more forgiving: ## Phase A — Foundations, ## Phase 1 — Setup, and ## Phase 10: Polish all parse — the index may be a number or a single letter, separated by :, -, or an en-/em-dash.

Author attribution (opt-in)

By default the bridge creates issues with no assignee, so they fall to the consumer project's default-assignee policy — on a PROJECT_LEAD project every spec shows the project lead, never the actual author. Opt into author-based attribution to make the board reflect who authored each spec, as two tracks:

  • An author:<handle> label, always — account-independent, so it works even for authors who have no Jira account. The <handle> is an operator-chosen, non-PII token from the gitignored authors map (never an email).
  • A Jira assignee, on create only — set when the author maps to a real accountId. It is never re-sent on update, so a manual reassignment in Jira survives (the Linear bridge's FR-034 semantics). A null/absent accountId is label-only (the non-member case).

Author resolution (per spec): an explicit Owner:/Author: line in spec.md wins; otherwise the first git author to add the spec dir; otherwise unknown (no label, no assignee — not an error). Email → accountId cannot be resolved at runtime (Jira's email user-search is GDPR-restricted), so a static, operator-maintained map is required.

Enable it in the gitignored jira-config.yml:

attribution:
  enabled: true          # default OFF — absent/false = byte-identical to today
  assignee: true         # set the create-time assignee when the author maps
  label: true            # stamp the always-on author:<handle> label
  authors_file: ".specify/extensions/jira/jira-authors.local.yml"

Then copy jira-authors.local.yml.sample to the gitignored jira-authors.local.yml and fill in real ids (it holds real emails + account ids = PII, so it is never committed):

schema_version: 1
authors:
  "dev@example.com": { accountId: "<jira-account-id>", handle: "dev" }
  "nonuser@example.com": { accountId: null, handle: "nonuser" }  # label-only
default_assignee: null   # null = leave unassigned (NOT the project lead)

PROJECT_LEAD caveat (FR-007): the bridge does not change your project's assignee policy. If assignee: true and the project default is PROJECT_LEAD, an unmapped author's spec still lands on the lead. For a neutral mirror, set the project's default assignee to Unassigned.

A stale/deactivated accountId is fail-soft: the assignee write is surfaced (warned) and the spec still completes with its label — never a full-run abort.


Changing the mapping: guarded re-mode (--remode)

The ordinary reconcile is add/update-only — it never deletes. So if you change the mapping shape (e.g. 3-level Subtasks → a 2-level in-body checklist, or a different issue type for specs), a plain reconcile adds the new-shape artifacts but leaves the old ones behind as orphans. A plain reconcile warns when it detects them, and points you at re-mode.

reconcile.sh --remode cleans this up in one guarded pass: it prunes the bridge-owned artifacts the current mapping no longer projects, then regenerates the new shape. It is the only destructive operation in the bridge, and it is fenced:

  • Opt-in only — reachable solely via --remode; never fired by a hook. The ordinary reconcile (and every after_* hook) stays strictly non-destructive.
  • Bridge-owned only — it prunes only issues carrying the speckit-* identity labels. An issue you created (no identity label) is structurally excluded and is never deleted, relabeled, or edited — even under the same Epic or with a lookalike summary.
  • Preview firstreconcile.sh --remode --dry-run prints the exact prune + regenerate set and writes nothing; the real run acts on exactly that set.
  • Fail-closed — an unreadable Jira read aborts the whole re-mode before any delete. A partial prune failure is surfaced and finished by a re-run.
  • Destruction modelremode.destruction: hard-delete (default; bridge content is regenerable from the specs) or archive (transition off-board + detach identity, preserving comments/links).
reconcile.sh --remode --dry-run --all   # preview what would be pruned + regenerated
reconcile.sh --remode --all             # prune orphans + regenerate the new shape

This rests on the regenerable-projection insight: Jira is a one-way mirror of the source-of-truth specs, so bridge-owned content needs no backup. See the constitution Principle I controlled-destruction carve-out (v1.1.0).


The reconcile decision flow

For every spec, the engine decides — per spec, independently — whether and how to write. It never silently clobbers, and it fails closed when it can't read:

flowchart TD
    START([reconcile spec NNN]) --> READ{read Jira<br/>state for NNN}
    READ -->|unreadable: 401/403/404/net/429-exhausted| FC["fail closed:<br/>NO write - record error"]
    FC --> EXIT3([escalate exit 3])
    READ -->|readable| DRIFT{compute drift:<br/>phase ahead? OR<br/>Jira newer than commit?}
    DRIFT -->|no drift| WRITE["reconcile:<br/>create/update only the diff"]
    DRIFT -->|backward-drift| WARN[surface named WARNING]
    WARN --> DISP{disposition}
    DISP -->|proceed: default / TTY y| WRITE
    DISP -->|abort: --on-drift=abort / TTY n| SKIP["skip: Jira unchanged"]
    WRITE --> SUM([structured summary])
    SKIP --> SUM
    EXIT3 --> SUM

A single reconcile run, end to end:

sequenceDiagram
    participant Op as Operator
    participant Eng as reconcile.sh
    participant Sink as jira_sink.sh
    participant Jira as Jira REST
    Op->>Eng: reconcile.sh --all
    Eng->>Sink: query_by_label(speckit-repo:slug)
    Sink->>Jira: GET /search/jql
    Jira-->>Sink: Epic (or absent)
    Sink-->>Eng: epic key (reuse) / create
    loop each spec NNN
        Eng->>Sink: query_by_label(speckit-spec:NNN)
        Sink->>Jira: GET /search/jql + GET /issue
        Jira-->>Sink: Story status + updated (or absent)
        Sink-->>Eng: actual state
        Note over Eng: compute drift vs disk
        alt drift and abort
            Eng-->>Op: WARNING - skipped (Jira ahead)
        else proceed
            Eng->>Sink: mutate_issue_* / transition / sync_subtasks
            Sink->>Jira: POST/PUT (only the diff)
        end
    end
    Eng-->>Op: summary: created/updated/skipped + warnings/errors

The run summary drives the process exit code by monotonic escalation: the highest code that fires wins.


Install

Install the extension into the spec-kit project you want mirrored — run this from that consumer repo, not from inside the bridge's own checkout (the installer detects source-equals-target and halts rather than copy the bridge into itself).

Until this extension lands in the spec-kit community catalog, install from the GitHub archive URL (the CLI's --from flag expects a direct .zip, not a repo URL):

specify extension add jira-sync --from https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/heads/main.zip

Or from a local checkout (--dev symlinks rather than copies, so your local edits flow straight through):

specify extension add /path/to/spec-kit-jira-sync --dev

Once the extension is listed in the catalog, the short form works:

specify extension add jira-sync

Naming — the catalog id is jira-sync (the bare jira slot is taken by an unrelated extension), but the extension installs under .specify/extensions/jira/ and its commands are namespaced speckit.jira.*.

Install copies the bridge into your repo's .specify/extensions/jira/, registers its reconcile commands (speckit.jira.push, speckit.jira.status, speckit.jira.install, speckit.jira.seed), and — if your project was initialised with --ai-skills — generates a SKILL.md per command. Like the Linear sibling, it also auto-registers the six after_* lifecycle hooks into your .specify/extensions.yml, so every /speckit-* command mirrors the spec state to Jira automatically — the automatic mirror is the primary path (Principle VII). The on-demand /speckit-jira-push is the escape-hatch for when you want to force a sync by hand.

Install does not scaffold your credentials. After installing, add your gitignored .env (below), then run /speckit-jira-install to resolve the binding — it reads the project over the Jira REST API and writes .specify/extensions/jira/jira-config.yml (project key, issue-type ids, the lifecycle phase→status map, and the story-points field id) automatically, so you never read an id off Jira by hand. Then /speckit-jira-seed confirms the lifecycle mapping is reachable on the project's workflow. Both are idempotent (a re-run is a byte-identical no-op) and fail-closed (exit 2 = missing input, exit 3 = Jira unreadable).

Quick start

Prerequisites

  • Runtime: bash 4.4+, curl, jq, git, gh
  • Dev/CI: bats, shellcheck, yamllint, markdownlint-cli2, and uv (preferred — PEP 668-safe) for workstate schema validation

Configure the two gitignored files

Credentials and the per-project binding live only in gitignored files — never in the tracked tree (Principles VI + IX).

.env — the Jira Cloud Basic-auth credential:

JIRA_BASE_URL=https://<your-site>.atlassian.net
JIRA_EMAIL=<you@example.com>
JIRA_API_TOKEN=<atlassian-api-token>   # never commit; .env is gitignored

.specify/extensions/jira/jira-config.yml — the resolved per-project binding (project key, issue-type / status / transition ids). You do not write this by hand: run /speckit-jira-install to resolve every id over the Jira REST API and write this gitignored file, then /speckit-jira-seed to confirm the lifecycle mapping is reachable. The committed placeholder config-template.yml shows the shape the install step fills. (The manual route — copy the template and read the ids off the project via the Atlassian MCP — still works; see dogfood.md.)

Dry-run first — preview every write, touch nothing

src/reconcile.sh --all --dry-run

This reports every intended create / update / transition / comment without calling Jira. The preview's reported actions match what a subsequent live run performs (SC-007). Inspect the summary, then drop --dry-run:

src/reconcile.sh --all                     # reconcile every spec
src/reconcile.sh --spec 001                # reconcile only feature 001
src/reconcile.sh --all --on-drift=abort    # never overwrite a drifted issue

Command surface

reconcile.sh [--spec NNN | --all] [--dry-run] [--on-drift=proceed|abort]
             [--quiet] [--config PATH]
FlagMeaning
--allReconcile every spec under specs/ (default if no --spec).
--spec NNNReconcile only feature NNN.
--dry-runCompute and report every intended write; perform none.
--on-drift=proceed|abortNon-interactive disposition on backward-drift; default proceed-and-warn.
--quietSuppress per-mutation logging; the summary still prints.
--config PATHOverride the gitignored jira-config.yml location.

Recovery / on-demand commands

The automatic mirror is the primary path — these commands are the escape-hatch: force a sync, preview drift, (re)bind the repo, or validate the workflow by hand. If you're driving from inside the agent harness, the slash commands run the engine without dropping to a terminal:

CommandMaps toWhat it does
/speckit-jira-pushreconcile.sh [--all|--spec NNN] [--dry-run] [--on-drift=abort|proceed]Write path — force a reconcile (the same path the after_* hooks fire).
/speckit-jira-statusreconcile.sh --dry-runRead-only preview — plans every write, issues none.
/speckit-jira-installthe install ceremonyResolve + write the gitignored binding; also (re)registers the after_* hooks.
/speckit-jira-seedthe seed gateValidate the labels + confirm every lifecycle status is reachable.

/speckit-jira-push is the write path the hooks also fire; /speckit-jira-status is the read-only drift/sync preview (it computes the same diff a live push would perform, then stops). There is intentionally no pull: the filesystem is the single source of truth and Jira is a unidirectional, read-only mirror, so there is nothing to pull back.

Exit codes (monotonic escalation)

CodeMeaning
0Success; all processable specs reconciled (or nothing to do).
1Completed with per-spec warnings (drift surfaced, missing tasks.md).
2Project-level configuration error (no/invalid binding) — run halted.
3One or more specs failed closed (unreadable Jira, exhausted 429 retries).
4Consumer-tree privacy leak — fail-closed, zero Jira writes (see below). Terminal: never demoted by 1/3.

The highest code that fires wins. See quickstart.md for setup and running the tests, and dogfood.md for self-provisioning this repo against a real Jira project.


Configuration

The per-project binding is jira-config.yml, resolved once and stored gitignored (real ids never enter the tree). Its shape is documented in the committed placeholder config-template.yml:

jira:
  project_key: "PROJ"           # placeholder — the prefix in every issue key
  issue_types:                  # numeric ids, NOT names
    epic: "10001"
    story: "10002"
    subtask: "10003"
  phase_status:                 # lifecycle phase -> target status id (via a transition)
    specifying: "20001"
    planning: "20002"
    tasking: "20003"
    implementing: "20004"
    ready_to_merge: "20005"
    merged: "20006"
  transitions: {}               # optional explicit transition ids; else resolved by target status
  labels:                       # operator-chosen prefixes carrying idempotency identity
    spec_prefix: "speckit-spec:"
    repo_prefix: "speckit-repo:"
    phase_prefix: "task-phase:"
    lifecycle_prefix: "phase:"

Every identifier is a stable id, never an operator-editable display name (Principle V) — renaming a status in Jira's UI never breaks the bridge. A missing or unreadable binding is a project-level configuration error that halts the run (exit 2), distinct from a per-spec failure.


Status & roadmap

In active development — not yet released. This section is honest about what is built versus planned. The suite is 182 bats tests, cross-model reviewed at phase boundaries, run against a mocked Jira REST harness (a curl-shim that returns fixture JSON keyed by method + URL) — and validated end-to-end against a real Jira instance (see Live-dogfood-proven below).

Done — mock-validated

  • US1 · Fresh mirror — a first run creates the per-repo Epic, one Story per spec under it, and a Subtask per task phase with each phase's tasks rendered as a done/not-done ADF checklist.
  • US2 · Idempotent zero-churn re-run — a second run against an unchanged corpus performs zero writes, reuses the existing Epic, and reconciles a disk-authoritative Story status back to the derived value.
  • US3 · Drift read — a Story ahead of disk (by lifecycle order or commit recency) surfaces a named backward-drift warning; it never silently clobbers.
  • US4 · Lifecycle & content propagation — status transitions, new Subtasks, clarification comments, and cross-spec issue links, each created at most once.
  • US5 · Fail-closed & observable — unreadable Jira, missing artifacts, and sustained 429 each fail closed for the affected spec and surface in the structured summary.
  • Vendor-neutral reconcile engine — drift detection, commit-recency gating, layered idempotency, kept free of any Jira specifics.
  • workstate schema gate — every emitted record validated against the published schema (under uv, PEP 668-safe) before any write.
  • Install + seed ceremony/speckit-jira-install resolves the binding (project key, issue-type ids, lifecycle phase→status map, story-points field) over the Jira REST API and writes the gitignored jira-config.yml; no more hand-editing ids. /speckit-jira-seed validates the labels + confirms every lifecycle status is reachable on the project's workflow. Idempotent (byte-identical re-run), fail-closed (exit 2/3), Privacy IX.

Live-dogfood-proven

Mirrored this repo into a real Jira project and confirmed the guarantees against a live instance — which surfaced three real bugs the mock structurally could not catch, each fixed and regression-guarded:

  • /search/jql returned key-less / field-less issue stubs without an explicit fields request → every run duplicated the board.
  • Empty ADF descriptions churned every run (Jira drops empty paragraphs on store).
  • Fresh creates churned once (Jira returns a null description until a later write settles it).

True zero-churn idempotency is now verified on real Jira (fresh mirror → immediate re-run = 0 created / 0 updated). In-session slash commands /speckit-jira-push (write) and /speckit-jira-status (read-only --dry-run preview) drive it.

Roadmap

  • Configurable artifact/relationship mapping + a 2-level mode (collapse tasks into a Story-body checklist instead of separate Subtask issues) — now in active spec-kit planning (specs/002-configurable-mapping/: spec + plan done).
  • Exposing workstate as a direct input (run the sink from a workstate file/stdin, skipping the parser) so any producer can feed it.
  • Carving the parser out into a standalone workstate producer, leaving this repo a pure workstate → Jira consumer.
  • Engine extraction — collapsing the engine shared with spec-kit-linear into one package with per-tracker sinks.
  • Layer E (the real-time GitHub Action status flips).

Privacy

This is a public repository. No real Jira coordinate ever enters a tracked file — no workspace, site, project key, account id, cloudId/UUID, email, or API token. Documentation and fixtures use placeholders only (e.g. https://<your-site>.atlassian.net, PROJ).

Real values live exclusively in gitignored files — .env, jira-config.yml, and tests/.private-deny — and the privacy guard (tests/unit/no-real-identifiers.bats) scans the tracked tree and gates CI on any shape-based or operator-literal leak (Principle IX).

Consumer-side privacy guard (auto, every reconcile)

The CI guard above protects this repo. A separate consumer-side guard protects the repos you install the bridge into. It runs automatically as a fail-closed pre-write gate on every reconcile (and at install) — after the config is loaded, before any Jira write, in --dry-run too. It scans the consumer repo's whole tracked tree (git ls-files, binaries skipped) and asserts the resolved jira-config.yml, .env, and jira-authors.local.yml are gitignored-and-untracked.

It reports findings in two tiers (precision-blocks, recall-warns — the industry norm):

  • BLOCK (fail closed, exit 4, zero Jira writes):
    • your exact known coordinates — the JIRA_EMAIL, the JIRA_BASE_URL host, the JIRA_API_TOKEN, and any accountId in your authors map — matched as fixed strings (zero false positives; only the bridge knows them);
    • the Atlassian API-token prefix (ATATT…);
    • an Atlassian site host <name>.atlassian.net (the reserved example.atlassian.net documentation host is intentionally exempt).
  • WARN (surfaced, run proceeds): a generic email, a cloudId/UUID, or a 24-hex / NNNNNN:UUID accountId — broad shapes that also match ordinary content (a contributor email, a lockfile UUID), so they never halt you (a blocking control with false positives just gets disabled).

A non-git target also fails closed (the tree can't be proven clean). The failure names the file and the shape class with copy-paste remediation, and never echoes the matched secret (it reports the shape, not the bytes). On a BLOCK: move the real value into the gitignored .env / jira-config.yml, replace the tracked occurrence with a placeholder, scrub history if already committed, and rotate the token if it was a credential.

Off-the-shelf scanners are recommended, never bundled. gitleaks / trufflehog are a useful complementary net for generic secrets the bridge doesn't know about; run them in your own CI. They are never a dependency of the guard (its core is dep-free git + grep), are only ever invoked best-effort if already on PATH, and trufflehog live-verification stays off (it would call Jira's API). The bridge's known-value pass is the precise guarantee no generic scanner can offer, since only the bridge knows its own exact resolved coordinates.


Development

Run the exact CI gate locally before pushing:

scripts/check.sh                                # mirrors all CI jobs
# or individually:
shellcheck --shell=bash --severity=style src/*.sh
yamllint -d relaxed .github/workflows/ci.yml
npx --yes markdownlint-cli2 "specs/**/*.md" "*.md"
bats --recursive tests/unit                     # offline, curl-shim mocked
RUN_INTEGRATION_TESTS=1 bats tests/integration  # needs a real binding + .env

See CONTRIBUTING.md for the full contribution workflow. Project governance — the non-negotiable principles every spec and PR is checked against — lives in .specify/memory/constitution.md (v1.0.0).


Related work

  • Spec Kit corehttps://github.com/github/spec-kit
  • Sibling bridgespec-kit-linear, the shipped twin targeting Linear; the reconcile engine here is copied from it and kept vendor-neutral.
  • Sibling extensionspec-kit-red-team, adversarial spec review before /speckit.plan locks in architecture.
  • workstate — the neutral interchange schema this repo proves as an independent second consumer.

License

MIT — see LICENSE.

Stats

0 stars

Version

0.4.0release
Updated 6 days ago

Install

Using the Specify CLI

specify extension add jira-sync --from https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.4.0.zip

Owners

License

MIT