canonical / canonical/desktop-engineering
Spec: Immediate cross-repo PR→card status sync (native links, dispatch trigger, map cascade)
- Dominant language
- Python
- Stars
- 10
- Forks
- 14
- Avg merge
- 8h 19m
- Merged PRs (30d)
- 3
Description
## Spec — Immediate cross-repo PR→card status sync (native links, dispatch trigger, map cascade)
**Effort:** [Map: Immediate cross-repo PR→card status sync (no cron, no hosting)](https://github.com/canonical/desktop-apps-ai-planning/issues/19)
**Home:** the `ai-planning` tooling in `canonical/desktop-engineering` — the package at `ai-planning/`, the composite action at `gh-actions/ai-planning/`, the deployed caller `.github/workflows/board-sync.yml`, plus `setup.sh` and the adapter/deploy docs.
**Status target:** `/implement`-ready. Every design decision below is settled (links in Provenance); what remains is building it. Slice with `/to-tickets` along the numbered workstreams.
---
## 1. Destination
Reflect a code-repo PR's lifecycle onto its planning card **immediately (≤1 min) and reliably**, for **both** linkage cases:
- the **same-repo spec** (issue and PR both in the code repo), and
- the **cross-repo implementation ticket** (issue in the planning repo, PR in a code repo, targeting the spec branch — so native close never fires).
Mapping: **draft PR → In progress**, **ready (open, non-draft) PR → In review**, **merged → Done** (including the non-default/spec-branch case where the planning issue must be closed by the job itself).
The PR↔issue link is a **durable native cross-repo link** authored automatically and uniformly (`addCloseIssueReferences`), read back with `closedByPullRequestsReferences(userLinkedOnly:true, includeClosedPrs:true)`. **No reliance on GitHub `schedule` cron for immediacy, no org-admin, no external hosting.**
Scope widened by [#23 (Q4=b)](https://github.com/canonical/desktop-apps-ai-planning/issues/23) and [#28](https://github.com/canonical/desktop-apps-ai-planning/issues/28): the spec also delivers **map auto-close cascade** — a map issue auto-closes (cross-repo, everything Done) when its sub-issue subtree is fully closed, and auto-reopens if a descendant reopens. This **reverses** the standing "map closure is manual" policy.
---
## 2. Current state (what this spec changes)
The tooling today reconciles the board with **one idempotent full-board sweep** (`python -m ai_planning`), reached by planning-repo `on: issues` events, a manual `workflow_dispatch` at merge time, and a `schedule: "*/10"` floor. It detects a cross-repo merge through a **`CROSS_REFERENCED_EVENT` timeline fallback** gated by an `AI_PLANNING_DESTINATION_REPOS` allow-list and an `isCrossRepository` guard. It authors **no** PR↔issue links. Maps are **never** auto-closed.
This spec replaces the detection read path, adds a reliable code-repo PR trigger, adds automatic link-authoring, folds in map auto-close cascade, and updates the docs/policy. `sync_status` stays a pure function — **only the extractor changes** — and the precedence-ladder **order is unchanged**: `Done > Blocked > In review > In progress > Ready`.
---
## 3. Workstreams (each a landable implementation slice)
### WS-1 — Detection rewrite: score off deliberate native links only
Ref: [#24](https://github.com/canonical/desktop-apps-ai-planning/issues/24) ([gist](https://gist.github.com/didrocks/6c5722763f9447055e2806eaa011cf9b)).
**Read path — one signal, deliberate links only.** Score every card off `closedByPullRequestsReferences(userLinkedOnly:true, includeClosedPrs:true)` as the **sole** PR read, for both linkage cases. Reject every mention/keyword-inclusive read (`userLinkedOnly:false`, timeline cross-references).
**Delete** from `queries.py` / `facts_mapping.py` / `job.py`:
- the `timelineItems(itemTypes: [CROSS_REFERENCED_EVENT])` embed and the whole `ISSUE_TIMELINE_QUERY` pager, plus `_TIMELINE_XREF_FIELDS`;
- `_has_merged_linked_pr` timeline logic, `_timeline_xref_nodes`, `item_timeline_page_info`, `extend_item_timeline`, `iter_issue_timeline_events`, `page_item_timeline`;
- the `destination_repos` allow-list threading and the `isCrossRepository` guard (the `AI_PLANNING_DESTINATION_REPOS` input/var is retired from **detection** — see WS-4 for any link-authoring reuse).
**`closedByPullRequestsReferences` selection** must read `state`, `isDraft`, `merged` per node (drop `first:20` capped read only if unbounded paging is needed; keep a generous page and page if `hasNextPage`).
**New fact + facts_mapping.** Add `has_open_draft_pr` to `Facts` (a draft linked PR is not a review). Re-source the two existing PR facts from the `userLinkedOnly:true` refs:
- `has_merged_linked_pr` ← any linked PR with `state == "MERGED"` (was: merged timeline xref in an allow-listed repo).
- `has_open_non_draft_pr` ← any linked PR `state == "OPEN"` and not `isDraft` (unchanged semantics, new source).
- `has_open_draft_pr` ← any linked PR `state == "OPEN"` and `isDraft`.
**`sync_status` ladder (order unchanged; only the In-progress rung gains a source):**
1. `closed OR has_merged_linked_pr` → **Done**
2. `open_blocker_count >= 1` → **Blocked**
3. `has_open_non_draft_pr` → **In review**
4. `assigned OR child_in_progress_count >= 1 OR has_open_draft_pr` → **In progress**
5. else → **Ready**
`sync_status` stays pure; `is_map` short-circuit unchanged.
**Self-close on merge (impure job step, unchanged in spirit).** On a linked PR `state == "MERGED"` with the issue still `OPEN`, the job closes the issue (`CLOSE_ISSUE_MUTATION`, `stateReason: COMPLETED`) so native `blocked_by` edges clear and dependents unblock. Harmless no-op when native close already fired (same-repo/default-branch), the fix when it did not (spec branch). Never in `sync_status`.
**Tests:** update `tests/test_sync_status.py` truth table for the new rung ordering and `has_open_draft_pr`; update `tests/test_facts_mapping.py` / `test_job.py` to the new extractor and self-close path; drop timeline-fallback fixtures.
### WS-2 — Sweep granularity, run-count and concurrency
Ref: [#25](https://github.com/canonical/desktop-apps-ai-planning/issues/25), grounded by [#22](https://github.com/canonical/desktop-apps-ai-planning/issues/22) ([gist](https://gist.github.com/didrocks/7a8833f38ef043386701019ce74712ad)).
- **One full-board sweep is the sole mechanism.** No targeted single-card path: the ~30–65 s fixed CI setup/teardown is paid per run regardless, so per-card writes save nothing. The **same** sweep runs from **both** immediacy triggers (WS-3).
- **Concurrency:** a **single global concurrency group** with `cancel-in-progress: true` on the sweep so a burst of events collapses to one run.
- **Sweep scope:** open cards board-wide are the **write-candidates**; closed cards are read **only** as cascade inputs under open maps (WS-5). Finished efforts drop out, so `N` tracks active work. In `run_sync`, skip status-writing for closed cards except where a closed child/descendant is needed for a parent/map roll-up.
- **Scaling ceiling ≈ N≈500 accepted.** The **3×-board-pagination de-dup** (`_board_content_ids`, the seed closure loop, and the main `items` read each fully paginate) is the pre-agreed escape hatch — **documented, not built now**.
### WS-3 — Trigger: code-repo `on: pull_request` → `repository_dispatch` → planning-repo sweep
Ref: [#23](https://github.com/canonical/desktop-apps-ai-planning/issues/23), grounded by [#21](https://github.com/canonical/desktop-apps-ai-planning/issues/21) ([gist](https://gist.github.com/didrocks/84d0ea30ba8f278c2d6c5a6b52f3fe10)).
**Option A.** A code-repo `on: pull_request` thin caller fires `repository_dispatch` at the planning repo, which runs the full-board sweep (WS-2) plus the immediate link-authoring arm (WS-4) and the non-default-branch issue-close (WS-1).
- **New reusable workflow** shipped from `canonical/desktop-engineering/gh-actions/ai-planning/` (alongside the existing composite action), e.g. `pr-dispatch.yaml` (`on: workflow_call`). Its single job `POST`s `repository_dispatch` to `canonical/desktop-apps-ai-planning`, carrying a `client_payload` with the PR facts the immediate arm needs: `repository` (`owner/name`), `number`, `body`, `baseRefName`, `state`, `isDraft`, `merged`.
- **Per-repo adapter caller** (one small file, ~6 lines) installed into each destination code repo: `.github/workflows/ai-planning-pr.yml`, `on: pull_request` with types **`[opened, edited, reopened, converted_to_draft, ready_for_review, closed]`**, one `uses:` line to the reusable workflow, passing the dispatch token as a secret. The caller is a **non-required, cosmetic** check — the real work runs in the planning repo, so the PR shows no test-like gate.
- **Planning-repo caller** (`.github/workflows/board-sync.yml`) gains an **`on: repository_dispatch`** trigger (event type e.g. `ai-planning-pr`) beside the existing `issues`, `workflow_dispatch`, and `schedule` triggers.
- **Installer:** a single idempotent `onboard-code-repo.sh` that installs/updates the per-repo caller and registers the dispatch-token secret in a destination repo. Extending `canonical-repo-automation` was **rejected**. Re-running is a no-op.
**`schedule` floor (reconciliation of #25 ↔ #28 — see §6):** immediacy comes from the two **event** triggers (`repository_dispatch` + planning-repo `on: issues`); native "→ Done" Project workflows still handle closes. The `schedule: "*/10"` cron is **retained as a backstop-only quiescence floor** (not the mechanism, never relied on for the ≤1-min path), matching #28's "10-min floor backstops" and the destination's "no *reliance* on cron."
### WS-4 — Automatic, uniform link-authoring (immediate + reconcile arms, one rule)
Ref: [#29](https://github.com/canonical/desktop-apps-ai-planning/issues/29). Detection (WS-1) reads **deliberate** links only, so it is inert until the links exist. Author them automatically and uniformly — never relying on a human or agent, never differently per type. This is a **standing** condition, not a one-time backfill.
**Signal = parse the PR body.** Closing keywords + `#N` / `owner/repo#N`, the only signal that reaches cross-repo impl tickets, non-default spec branches, and pre-adoption stragglers. A single `Ticket: ` field is the happy-path convention layered on top.
**Shared authoring rule (identical in both arms) — pick exactly one target ticket:**
- **Tie-break by kind:** a same-repo `wayfinder:spec` issue outranks any planning-repo implementation ticket (a spec-branch→`main` PR links the **spec**, not its impl tickets).
- The base branch **corroborates** the choice (spec-branch PR ⇒ spec; `main`-targeting small-ticket PR ⇒ the impl ticket).
- **`>1` candidate impl ticket = a defect, surfaced not guessed** — do not author; log/annotate for a human.
- **Idempotent read-before-write** off `closedByPullRequestsReferences(userLinkedOnly:true)`: author only when the target is unambiguous **and** not already linked. There is **no** `removeClosingIssueReferences` mutation, so a wrong link is unlinked manually in the UI — hence author conservatively.
**Two arms, one rule, both running in the planning repo:**
- **Immediate arm** — the `repository_dispatch`-triggered sweep (WS-3). Applies the rule to the dispatched PR (`client_payload`), authors its link, then the sweep scores the card.
- **Reconcile arm** — the periodic/issues-triggered full-board sweep. Authors any **missing** link so correctness never depends on the immediate arm having fired: covers today's unlinked PRs (e.g. `#459 ← warty-whisperer#460`, `#11 ← warty-whisperer#461`) and every future straggler. It discovers candidate PRs from the **PR side** (an unlinked cross-repo PR is invisible from the issue side); the destination-repo scope for that scan is an implementation detail for the ticket — reuse of the retired `AI_PLANNING_DESTINATION_REPOS` variable as the scan scope is acceptable but must be named distinctly from the deleted detection guard.
**Write identity:** runs in the planning repo with the **existing `AI_PLANNING_TOKEN`** (no new token). Org `Projects:write` and `Issues:write` stay solely on the planning repo. See WS-6 for the scope bump.
### WS-5 — Map auto-close cascade
Ref: [#28](https://github.com/canonical/desktop-apps-ai-planning/issues/28).
- **Predicate:** a map auto-closes when its sub-issue subtree has **≥1 descendant AND zero *open* descendants** (reason-agnostic — `NOT_PLANNED`/out-of-scope closes count). A map is a **pure reducer over its subtree**: **auto-reopen** if any descendant reopens. **Childless-map guard:** never close a map with zero descendants.
- **Mechanism:** folded into the **existing board-sync sweep — no new trigger.** Planning-repo `issues:[closed]` events plus WS-3's `repository_dispatch` cover the cross-repo spec-merge case; the WS-3 `schedule` floor backstops. Close/reopen via `CLOSE_ISSUE_MUTATION` / a reopen mutation, cross-repo, with `AI_PLANNING_TOKEN` (already `Issues:write` over all repos).
- **Policy reversal:** this reverses the documented "Map closure is manual" rule. The `docs/agents/issue-tracker.md` flip is a **deliverable of this spec** (WS-7), applied when the mechanism ships.
### WS-6 — Token / secret model
Ref: [#23](https://github.com/canonical/desktop-apps-ai-planning/issues/23), [#29](https://github.com/canonical/desktop-apps-ai-planning/issues/29).
- **Planning repo** keeps the single privileged token `AI_PLANNING_TOKEN` (fine-grained PAT): org **`Projects: write`** + repo **`Issues: read and write`** over the planning repo **and destination code repos**, PRs read. All org-Projects and cross-repo issue writes (status, self-close, map cascade, link-authoring) run here.
- **Destination code repos** hold **only** a dispatch token — fine-grained PAT scoped to the planning repo, permission **`Contents: write`** (least privilege for `POST /repos/{owner}/{repo}/dispatches`). Minted once; stored in **Bitwarden** under a location named for the planning repo (`desktop-apps-ai-planning`), alongside `setup.sh`'s central token. Secrets are write-only.
- **Doc fix:** `deploy/DEPLOY.md` currently states `Issues: read` — it must state **`Issues: read and write`** on the planning repo **and destination repos** (the README already does; DEPLOY.md is stale). No `actions` permission anywhere.
### WS-7 — Docs & adapter
- **`README.md`** (ai-planning): replace the timeline-fallback section with the native-link detection + link-authoring model; describe the `repository_dispatch` immediacy path and the demoted `schedule` floor; document the 3× de-dup escape hatch as a future lever.
- **`deploy/DEPLOY.md`:** the token-scope fix (WS-6); the `onboard-code-repo.sh` step; the dispatch-token / Bitwarden note.
- **`adapter/issue-tracker.md`** (template) **and** this planning repo's **`docs/agents/issue-tracker.md`:** update the derived-Status description to the new detection (draft PR → In progress; native-link based In review/Done); **flip the "Map closure is manual" policy to auto-close cascade** (WS-5); note that agents still write no Status and add no cards.
- **`setup.sh` / `onboard-code-repo.sh`:** the code-repo onboarding path and dispatch-token registration.
---
## 4. Acceptance criteria
1. A **draft** PR linked (via authored native link) to its ticket moves the card to **In progress**; marking it **ready for review** moves it to **In review**; **merging** moves it to **Done** — for **both** the same-repo spec and the cross-repo impl ticket, within ≤1 min of the PR event, with **no** `schedule` cron in the path.
2. Merging a **spec-branch** PR (native close inert) **closes** the planning issue itself and unblocks its dependents.
3. Detection scores **only** off `closedByPullRequestsReferences(userLinkedOnly:true, includeClosedPrs:true)`; a mere mention/keyword reference (no deliberate link) scores **nothing**. Timeline fallback, `destination_repos` detection guard, and `isCrossRepository` guard are gone.
4. A PR with no pre-existing link gets **exactly one** correct link authored automatically (spec vs impl tie-break honoured); an ambiguous multi-impl PR authors **no** link and is surfaced. Re-running authors nothing new (idempotent).
5. The reconcile arm authors the missing links for the existing unlinked PRs (`#459 ← warty-whisperer#460`, `#11 ← warty-whisperer#461`).
6. A map whose entire subtree is closed **auto-closes** (cross-repo, all Done); reopening any descendant **auto-reopens** the map; a childless map never auto-closes.
7. Org `Projects:write` and cross-repo `Issues:write` live **only** on the planning repo; destination repos carry only a `Contents:write` dispatch token. `deploy/DEPLOY.md` states the correct scopes.
8. `sync_status` remains pure and unit-tested; the ladder order is unchanged; `has_open_draft_pr` is covered by the truth table.
9. The two adapter docs and `docs/agents/issue-tracker.md` reflect the new detection and the auto-close-cascade policy.
---
## 5. Out of scope
- Org-owner mechanisms: a GitHub App with org `Projects:write`, org-level webhooks, any `projects_v2_item` Actions trigger (does not exist in 2026).
- Any self-hosted webhook receiver or external scheduler/timer.
- Migrating existing cards into the new mechanism (beyond the link-authoring backfill of AC-5).
- Building the 3×-pagination de-dup now (documented escape hatch only).
---
## 6. Reconciliation note (flag for the maintainer)
Two closed decisions phrase the `schedule` floor differently: **#25** says the sweep runs from both event triggers with **"no cron/timer"**; **#28** (later) says a **"10-min floor backstops"** the cascade, and the live `board-sync.yml` still carries `schedule: "*/10"`. This spec synthesises them as: **immediacy is event-driven only; the `schedule: "*/10"` cron is retained strictly as a backstop-only quiescence floor, never relied on for the ≤1-min path** — consistent with the map Destination's "no *reliance* on GitHub `schedule` cron." If the maintainer intends #25 literally (delete the cron entirely), drop the `schedule` trigger in WS-3/WS-5 and rely solely on `repository_dispatch` + `on: issues` events.
---
## 7. Provenance
- Map: [#19](https://github.com/canonical/desktop-apps-ai-planning/issues/19)
- [#20 — addCloseIssueReferences cross-repo behaviour](https://github.com/canonical/desktop-apps-ai-planning/issues/20) ([gist](https://gist.github.com/didrocks/6f49a72ed8152480d2cf328c3f7544b1))
- [#21 — no-cron/no-hosting triggers](https://github.com/canonical/desktop-apps-ai-planning/issues/21) ([gist](https://gist.github.com/didrocks/84d0ea30ba8f278c2d6c5a6b52f3fe10))
- [#22 — sweep cost & scaling](https://github.com/canonical/desktop-apps-ai-planning/issues/22) ([gist](https://gist.github.com/didrocks/7a8833f38ef043386701019ce74712ad))
- [#23 — trigger mechanism](https://github.com/canonical/desktop-apps-ai-planning/issues/23)
- [#24 — detection semantics](https://github.com/canonical/desktop-apps-ai-planning/issues/24) ([gist](https://gist.github.com/didrocks/6c5722763f9447055e2806eaa011cf9b))
- [#25 — sync granularity & run-count](https://github.com/canonical/desktop-apps-ai-planning/issues/25)
- [#28 — map auto-close cascade](https://github.com/canonical/desktop-apps-ai-planning/issues/28)
- [#29 — automatic, uniform link-authoring](https://github.com/canonical/desktop-apps-ai-planning/issues/29)
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in ai-planning/, especially queries.py, facts_mapping.py, job.py, sync_status, and the named tests under tests/. Then read gh-actions/ai-planning/, .github/workflows/board-sync.yml, setup.sh, and the adapter/deploy docs. Done means the numbered workstreams are implemented, covered by updated tests, and the documented PR triggers, link authoring, sweep behavior, and map cascade work together.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- github-actions, python, shell
- Domain
- ci-cd, devops, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100