Conversation
…n-mercato#816) Steps 1.1 and 1.2 of the spec's implementation plan. - `linkedPrSchema` + `githubIssuePrsDataSchema` in the contract, with the `GithubIssuePrs200` mutual-assignability assertion in contract-parity. - `fetchGithubIssuePrs` / `fetchIssuePrLinks` in the forge: one aliased GraphQL query per window over `timelineItems(last: 30)`, deduped by PR number, disconnections subtracted, ordered open -> merged -> closed; a bounded 60s per-issue cache with a `refresh` bypass; a CEZ_DRY_RUN mock so the offline demo and the e2e suite paint chips.
…ato#816) Step 1.3. A chained `.get` link in the githubRoutes family (a loose `app.get` would vanish from AppType), same CSV validator shape and 400 wording as /github/checks, plus the optional refresh=1 flag. Inventoried in BACKWARD_COMPATIBILITY.md §2 and covered by a route test mirroring github-checks-api.test.ts.
…rs (open-mercato#816) Steps 1.4 and 1.5. `getGithubIssuePrs` beside `getGithubChecks` (a `refresh: false` sends nothing, as everywhere else on this client), plus `queryKeys.githubIssuePrs` and `useGithubIssuePrs` mirroring `useGithubChecks`. packages/api-client is untouched — its `export * from '@open-mercato/cezar-contract'` already re-exports the new types.
|
|
…rcato#816) Step 1.6. Derives the on-screen issue window (pinning the URL-selected issue), wires useGithubIssuePrs for the Issues view, and paints a state-tinted 'PR #n' chip per link with a muted draft variant, an in-cockpit target when the PR is in the loaded open set, and a +N overflow past three. The row is restructured to a stretched link to make that possible: an <a> inside the row <Link> is invalid HTML and a WCAG 4.1.2 failure that this SSR-less SPA would ship silently, so the <li> becomes the positioning context, the row link an absolute inset-0 overlay carrying the navigation, drag payload and accessible name, and the chips are its siblings — no nested anchors, no stopPropagation. The header refresh re-fetches the window with refresh=1 so a just-opened PR is not hidden by the 60s cache.
… complete (open-mercato#816) Steps 1.7 and 1.8. The e2e case drives the real cockpit under CEZ_DRY_RUN=1 — which the step-2 mock catalog now serves links for — and screenshots the chipped row. Full gate green: typecheck, npm test (6091), test:unit (36), build + check:pack, test:package (15).
sheeerth
left a comment
There was a problem hiding this comment.
🔍 Code Review
🎯 Summary
PR #885 implements .ai/specs/2026-08-09-issue-linked-pr-chip.md — the linked-PR chips on the GitHub Issues list — as one additive vertical slice modelled on the lazy checks-glyph slice (#664): a contract schema, a forge fetcher, a chained route, a typed client, a query hook and a row chip. The diff against the PR's base is 15 files, +1354/−10, and better than half of it is tests. Nothing existing is reshaped: no §2-protected shape changes, the one-shot GET /api/v1/github list payload is untouched, and rollback is deleting the slice.
The change carries exactly one structural edit — GithubRow becomes a stretched link so the chips can be anchors without nesting inside the row's anchor — and that is where the regression risk lives. I reviewed it specifically for drag, hover, focus, truncation and keyboard order, and found no functional regression; the details are under 🧩 Findings.
Verdict: approve. No blockers and no majors. One minor and three nits are listed below for the author to take or leave; none of them blocks the merge.
One process note that is not a code finding: this run could not apply labels. The authoring account has pull-only rights on open-mercato/cezar, so every label mutation answers HTTP 403. The label set this PR should carry — review, feature, needs-qa, priority-medium, risk-medium — needs a maintainer with write access, and until needs-qa is on the PR the qaGate cannot do its job.
🧪 Validation Gate
Every configured command was re-run in order, on the reviewed head, after the last edit to it:
| Command | Result | Evidence |
|---|---|---|
npm run typecheck |
✅ pass | Clean across api-client, server and web. This is where the contract guard lives — the new GithubIssuePrs200 mutual-assignability assertion fails compilation if schema and handler drift in either direction. |
npm test |
✅ pass | 6091 passed / 324 files. |
npm run test:unit |
✅ pass | 36 passed, 0 failed. |
npm run build |
✅ pass | tsc → dist/, vite → web/dist/, then check:pack ok — 475 files, 85 under web/dist. |
npm run test:package |
✅ pass | 15 passed, 0 failed — the tarball installs and the built CLI runs. |
Beyond the gate: the GraphQL document was verified against real GitHub
The gate cannot catch the one failure mode this slice is most exposed to. fetchIssuePrLinks injects runGraphql, so every unit test drives a mocked forge — and the fetcher deliberately swallows a failed chunk (catch {}, degrading to absent chips). A malformed GraphQL document would therefore ship green and simply render no chips forever, which is precisely the "degradation path hiding a real failure" case CODE_REVIEW.md ranks second.
So I ran the generated document against the live API (gh api graphql, open-mercato/cezar, issues #765 and #730) and then fed the real response through fetchIssuePrLinks unmodified:
- The document is accepted: the per-issue aliasing (
i0:,i1:), theitemTypes: [CONNECTED_EVENT, DISCONNECTED_EVENT, CROSS_REFERENCED_EVENT]enum, and the field-merging ofsubject/sourceacross the three inline fragments are all valid. - The real payload contains exactly the shapes the parser is written for, including
source: { "__typename": "Issue" }entries with no other fields — the issue↔issue references that must be dropped. - The parser's output on that payload is correct and correctly ordered: issue #765 →
[#873 open, #766 closed], issue #730 →[#732 open].
That is the strongest evidence available short of a live integration test, and it is what moves the GraphQL builder from "plausible" to "verified" in this review.
🧩 Findings
Minor
1. The refresh handler writes fresh chips under the pre-refresh window key. packages/web/src/routes/github/github.tsx:227-241
The refresh mutation's onSuccess closes over issuePrNumbers as computed from the list before the refresh, and writes the re-fetched links to queryKeys.githubIssuePrs(issuePrNumbers). If the refresh changed the open-issue set, the component re-renders with a different window key; that key is cold, so React Query refetches it — without refresh=1 — and the server may answer from the ≤60 s cache the explicit refresh was meant to bust.
Why it does not block: in the scenario the flag exists for — an agent just opened a PR against an existing open issue — the issue list does not change, so the key is stable and the refresh lands. And when a PR closes its issue, the issue leaves the list entirely and has no row to chip. The sibling checks invalidation two lines above (invalidateQueries({ queryKey: queryKeys.githubChecks(checkPrNumbers) })) has the identical shape, so this is consistent with the established pattern rather than a new hazard.
Suggested fix if you want it airtight: derive the window from the data the mutation just received rather than from the closed-over value, so the key written matches the key the next render will read.
Nit
2. Row text is no longer mouse-selectable. packages/web/src/routes/github/github.tsx:624
The content wrapper is pointer-events-none so that hovering and clicking anywhere on the row still reaches the overlay link. A side effect is that the title and meta text can no longer be selected by dragging or double-clicking. In practice the content previously lived inside a draggable anchor, where selection was already awkward, so this is close to a no-op — and re-enabling selection would reintroduce exactly the hit-testing problem the structure solves. I checked that nothing interactive is caught by it: LabelChip and CommentCount are plain <span>s, and the chips opt back in with pointer-events-auto. Worth accepting knowingly rather than fixing.
3. The overflow chip's non-HTTP fallback drops its tooltip. packages/web/src/routes/github/github.tsx:744-748
The anchor branch carries both title and aria-label; the <span> fallback carries only aria-label, so a sighted mouse user loses the "N more linked pull requests" hover text in the (rare) case the issue URL is malformed. LinkedPrChip's own fallback keeps both. One attribute for symmetry.
4. Keep the two last: 30 tests together — they are only meaningful as a pair. packages/cezar/src/server/forge/github.test.ts
"still finds the PR behind 29 issue cross-references" feeds a reply that is already in last: order, so on its own it proves the parser scans the whole window rather than proving the window is the newest one. What actually pins the ordering is its neighbour, which asserts the emitted document contains timelineItems(last: 30 and not timelineItems(first:. Together they lock the behaviour; separately, either one is deletable as "redundant". A one-line comment on the first pointing at the second would protect them from a future cleanup.
💥 Breaking Changes
- No exported API, route, event name, CLI flag, DB schema or config format was removed, renamed or retyped. Every addition is new:
linkedPrSchema,githubIssuePrsDataSchema,LinkedPr,GithubIssuePrsData,GH_ISSUE_PRS_MAX,fetchGithubIssuePrs,fetchIssuePrLinks,__clearIssuePrsCacheForTests,getGithubIssuePrs,useGithubIssuePrs,queryKeys.githubIssuePrs. - The §2-protected surfaces are intact.
githubItemSchemaand theGET /api/v1/githublist payload are byte-identical;bc-route-inventory.test.ts,route-parity.test.ts,versioned-surface.test.tsandtyped-bodies.test.tsall pass. - The new route is inventoried in
BACKWARD_COMPATIBILITY.md§2 with its full contract, including the rule that an issue with no links is absent from the map rather than present-and-empty, and which future changes would be additive versus breaking. - The route is registered by chaining into the
githubRoutesfamily, not as a looseapp.get(…)— so it reachesAppTypeand the typed client, which is the failure modeAGENTS.md § The HTTP APIcalls out and #694 shipped eleven times. - Input is validated as route middleware (
queryZodValidator), not parsed inside the handler, so the validated shape is recorded in the route type. - Degradation paths are preserved, not narrowed.
ghmissing, no remote, offline and an unknown handle all answer200 { available: false, reason }; a failed GraphQL chunk costs only its own issues;CEZ_DRY_RUN=1returns a mock catalog so the offline demo and the e2e suite still paint chips. Nothing in this diff turns an expected absence into a throw or a 5xx. - Bounds are explicit. The window is capped at
GH_ISSUE_PRS_MAX = 100at the route and again in the fetcher; the per-issue cache is bounded at 500 entries with LRU eviction; the timeline window is capped at 30 items per issue. - No new dependencies — server-runtime budget untouched, no browser package added.
🧪 Test Coverage
Coverage is proportionate and, importantly, not vacuous — I checked the new assertions against the code they claim to pin.
- Forge (20 cases,
forge/github.test.ts). Alias→issue mapping; all three state words plus the unknown-state fallback toclosed;isDraftomitted rather than emitted asundefined(the exactJSON.stringify-drops-undefined trapAGENTS.mdnames, and the reason the contract's optional field survives parity); dedupe of a PR seen as both connected and cross-referenced;DisconnectedEventsubtraction; issue↔issue references dropped; thelast:-window pair discussed in finding 4; chunking with one failing chunk; the empty-list and total-failure degrades; the cache, itsrefreshbypass, the "no links is cached too" case, and theCEZ_DRY_RUNmock. - Route (9 cases,
github-issue-prs-api.test.ts). The happy payload, absent-vs-empty,refresh=1, both 400 wordings kept distinct (missing issues queryfor?issues=versusinvalid issues queryfor a malformed list), the >100 cap, zero/negative/zero-padded numbers, and the project-scoped alias. - Contract.
GithubIssuePrs200mutual assignability — a compile-time guard, so it isnpm run typecheckthat enforces it, exactly as the siblingGithubChecks200does. - Client (3 cases) and cockpit (9 cases). Chip text, the three tints, the draft variant, both link targets, the
+Noverflow, the unavailable payload, the PRs view never paying for the window, selected-issue pinning, andrefresh=1on the header refresh. - The structural invariant has its own test, which is the right call for a change like this:
github.test.tsxwalks every anchor under[data-slot="gh-rows"]and asserts none has an anchor ancestor. That is what stops a future edit from quietly putting the chips back inside the row link — the failure this restructure exists to prevent, and one no type or lint rule in this repo would catch. - E2E.
packages/web/e2e/github.e2e.tsdrives the real cockpit underCEZ_DRY_RUN=1, waits for the hydrated chips on issue #142 and screenshots the row. Enabled by the dry-run mock added in the same change, so the e2e layer is genuinely exercised rather than skipped. - Regression risk on the restructure is covered behaviourally, not just structurally: the existing drag, hover-prefetch,
aria-current, deep-link and row-click tests all still pass against the new DOM, and two selectors were rescoped from the row link to the row<li>because the meta glyphs are now its siblings — a rescope, not a weakened assertion.
Gaps I looked for and did not find a reason to file: the detail pane is explicitly out of scope per the spec's Non-goals, and the Phase 2 runs-store instant-paint is deferred by the same section.
✅ Verdict
Approve. No blockers, no majors; the full validation gate is green on the reviewed head, the GraphQL path is verified against the live forge, and the one structural edit is covered by a test that pins the invariant it exists to protect. The minor and the three nits are the author's call and do not block.
Two things that need a human with write access rather than a code change: applying the label set (403 for this account), and signing the CLA so license/cla can settle — it is the only check on this PR and is currently pending, unsigned.
…-mercato#816) Review follow-up. The old comment justified first-writer-wins by where `last:` puts the newest events, which reads backwards. The real reason order does not matter is that GraphQL resolves state/isDraft off the pull request now, not as of the event, so every occurrence carries identical fields.
|
🤖 Status: complete. The spec at Engine: om-auto-create-pr (steps: 8, --loop: no) The spec's Implementation Plan drafted to 8 Steps, comfortably under the configured 20-Step loop threshold and with no 📋 What was builtSix of the eight steps are the vertical slice the spec designed, mirroring the lazy checks-glyph slice (#664) layer for layer:
The one structural edit: 🧪 Verification phases completedValidation gate — every configured command, in order, on the reviewed head:
Code review — verdict approve, no blockers, no majors. The full report is in the review on this PR. Two verifications in it are worth repeating here because the gate could not have produced them:
📸 UI verification — real Chrome against the built app. What was confirmed in the live DOM, not in jsdom:
Not covered: a mobile-width pass. The browser tool's viewport command failed here, so the 390 px layout was not exercised — the row structure is unchanged apart from one inline chip, but it is honestly unverified.
|
1d9cb42 to
baa589a
Compare
open-mercato#816) The authorship rewrite (wrong author email — the commits were being attributed to another GitHub account, which is what held license/cla at not-signed) changed every sha on this branch, so the plan's Progress references pointed at commits that no longer exist. Also adds the `PR: open-mercato#885` back-reference om-auto-continue-pr resumes from.
|
🤖 Two follow-ups since the run summary above. Both close gaps that summary was honest about leaving open. 📸 The 390 px layout is now verifiedThe earlier summary flagged mobile as unverified — the browser tool's viewport command was failing. The command was simply wrong (
So the stretched-link restructure behaves identically at phone width, and the chips cost the row no extra height. One observation, offered as a note rather than a finding: the chips render 16 px tall, below the 44 px touch-target guideline. That is not a regression this PR introduces — the whole existing meta line (comment count, checks glyph, the queued-run flag) is built at the same scale, and the chip is a secondary affordance beside a row that is itself one large tap target. Worth a separate pass over that line if the project wants to raise it; out of scope here. ✅
|
…mercato#816) Two correctness defects found by a second review pass, both confirmed against live GitHub rather than reasoned about. 1. `issue(number:)` does not answer null for a number that is not an issue — GitHub returns a NOT_FOUND entry in an `errors` array, and `gh api graphql` exits non-zero the moment that array is present, even when `data` holds a good answer for every other alias. So one bad number in the window discarded the whole batch and every row lost its chips. Reachable in one click: the window pinned the URL-selected number, so `/github/issues/<a PR number>` poisoned the list. This is the trap `refStatusQuery` already documents; take the same cure — `issueOrPullRequest(number:)` with the timeline selected `... on Issue`, so a PR number resolves to a bare PullRequest and is skipped. The window also no longer pins a `:n` the list does not hold, which removes the nonexistent-number vector at source and hydrates nothing that has no row anyway. 2. A failed query was reported as success. `fetchIssuePrLinks` swallowed chunk failures, so `fetchGithubIssuePrs` could not tell "we could not ask" from "there is nothing here" and answered `{available: true, links: {}}` — the shape that asserts the forge was asked and had nothing — then cached that emptiness for 60s. It now returns the failed numbers and a reason (mirroring `fetchRefStatuses`), caches nothing for them, and answers `{available: false, reason}` when the window could not be answered at all. Also drops an unreachable `/ENOENT/` branch whose helpful "install gh" hint could never fire, because `resolveRepoHandle` swallows the cause first; the handle failure now names the possibilities instead. Tests: the failure semantics of the real transport were never exercised — every case injected `runGraphql`, whose failure modes are not `gh`'s. Adds the PR-number skip, the union assertion, failed-chunk reporting, available:false on total failure, no-cache-on-failure, and the pin guard. All verified red without the fix (git stash push -- <source>), green with.
sheeerth
left a comment
There was a problem hiding this comment.
🔍 Code Review — re-review (supersedes the earlier approve)
🎯 Summary
A second, adversarial pass over this branch found two major correctness defects that the first review missed, and my earlier approve on this PR was therefore wrong. Both are now fixed in 35464f2c; this review records what was wrong, how it was proven, and what changed.
The first review's headline verification — running the GraphQL document against live GitHub — is what should have caught this. It ran the query over two valid issue numbers, got a clean result, and declared the path verified. It never tried an invalid one. That is the lesson worth keeping: a happy-path probe against a real service proves the document parses, not that the code survives what the service does when it is unhappy.
Verdict on the branch as it now stands: approve. Full gate green (6097 tests, up from 6091), both defects fixed with regression tests proven red without the fix.
🧩 What was wrong
Major 1 — one unresolvable number blanked every chip in the window
issue(number: N) does not answer null for a number that is not an issue. GitHub returns a NOT_FOUND entry in an errors array, and gh api graphql exits non-zero the moment that array is present — even when data carries a perfectly good answer for every other alias. runGraphql rejected, the per-chunk catch swallowed it, and the entire chunk was discarded.
Proven live against this repository — one batch, a real issue and a PR number:
gh exit code: 1
gh: Could not resolve to an Issue with the number of 885.
{"data":{"repository":{"i0":{"timelineItems":{"nodes":[…4 nodes…]}},"i1":null}},
"errors":[{"type":"NOT_FOUND","path":["repository","i1"], …}]}
i0 came back complete and was thrown away.
Reachable in one click: the on-screen window pinned the URL-selected number, so /github/issues/885 — a PR number pasted or bookmarked into the issues route — cost every row its chips.
This is a trap the repository had already hit and documented. refStatusQuery carries the explanation verbatim: "asking issue(number:) about a pull request is a question with no answer, and GitHub says so with a NOT_FOUND error rather than a null… one mis-guessed kind used to fail the whole batch." This slice reintroduced the class its own sibling had cured.
Major 2 — a failed query was reported as a definitive "no linked PRs"
fetchIssuePrLinks swallowed every chunk failure internally, so it never threw and fetchGithubIssuePrs could not distinguish "we could not ask" from "there is nothing here". A rate limit, a network blip, or major 1 produced:
return { available: true, links: {} } // …then cached for 60 s{available: true, links: {}} is the shape BACKWARD_COMPATIBILITY.md §2 defines as "we asked, and none of these issues has a linked pull request" — the opposite of what happened — and the 60 s cache kept repeating it. The documented failure shape was unreachable for the case it exists for. CODE_REVIEW.md ranks graceful degradation second among review priorities; this was the inverse failure, an error dressed as a successful answer.
🔧 What changed in 35464f2c
issueOrPullRequest(number:)with the timeline selected... on Issue, matchingrefStatusQuery. A number that is a PR now resolves to a bare{__typename: 'PullRequest'}with no timeline and is skipped like any other absent answer. Verified live: the same mixed batch that used to exit 1 now returnsgh exit=0, noerrorsarray, every real issue answered.- The window no longer pins a
:nthe list does not hold. That removes the remaining vector — a number GitHub never issued still errors the batch — at source, and costs nothing: with no row on screen there was never a chip to hydrate. - Chunk failures propagate.
fetchIssuePrLinksreturns{links, failed, reason}(mirroringfetchRefStatuses, which keeps afailedlist for exactly this reason). Failed numbers are not returned, not cached, and when the whole window fails the route answers{available: false, reason}. Recovery does not depend on the user pressing Refresh. - Removed an unreachable
/ENOENT/branch whose actionable "installgh" hint could never fire, becauseresolveRepoHandleswallows the cause first. The handle failure now names the possibilities, and the ENOENT mapping moved to the one path where the message genuinely arrives. - §2 updated to state that a failed query answers
available: falseand must never be reported as an empty map.
End-to-end live check of the fixed path, with a PR number sitting in a window of real issues:
gh exit=0
{ "links": { "730": [#732 open], "765": [#885 open, #873 open, #766 closed] }, "failed": [] }
Before the fix that same window returned nothing at all.
🧪 Test coverage — the gap that let this through
Every forge test injected runGraphql, so the failure semantics of the real transport were never exercised. The mock's failure modes are "the promise rejects" and "the alias is null"; gh's is "exit non-zero for the whole batch because one alias errored". Nothing in the suite could tell them apart. Worse, the unknown-issue test encoded i0: null — a reply GitHub never sends — so it passed against a fiction.
Added, each verified red without the fix via git stash push -- <source files> and green with it:
- a PR number in the window is skipped without costing its neighbours their chips;
- the emitted document uses the union and never
issue(number:; - a failed chunk is reported in
failedwith itsreason, while surviving chunks keep their answers; - total failure answers
{available: false, reason}, not an empty map; - a failed number is not cached — the next call re-asks, with no
refreshneeded; - the window never pins a
:nabsent from the list.
🧪 Validation Gate
| Command | Result |
|---|---|
npm run typecheck |
✅ pass |
npm test |
✅ 6097 passed / 324 files |
npm run test:unit |
✅ 36 passed |
npm run build |
✅ + check:pack ok — 475 files |
npm run test:package |
✅ 15 passed |
💥 Breaking Changes
None. Still purely additive; no §2-protected surface changed. The §2 entry for this route gained precision about the failure shape — the route now behaves as documented, where before the documentation was aspirational.
✅ Verdict
Approve as of 35464f2c. The remaining open items are unchanged and unrelated to correctness: the earlier minor about the refresh handler's window key, two cosmetic nits, and the label set this account cannot apply (review, feature, needs-qa, priority-medium, risk-medium — HTTP 403 on pull-only rights).
# Conflicts: # BACKWARD_COMPATIBILITY.md
|
🤖 Verdict: functionally PASS. Verified end-to-end against a live cockpit booted under 🔌 Live endpoint (running server,
|



Refs #816
Tracking plan: .ai/runs/2026-08-14-issue-linked-pr-chip.md
Source doc: .ai/specs/2026-08-09-issue-linked-pr-chip.md
Status: complete
🎯 Goal
↗ PR #123chip for every pull request linked to that issue, tinted by the PR's state — green for open, violet for merged, danger for closed-unmerged, and a muted variant for a draft. A triager can see that an issue is already being worked before handing it to an agent, instead of spending a run to find out.ConnectedEvent/CrossReferencedEvent), hydrated lazily for the on-screen row window through a newGET /api/v1/github/issue-prsendpoint — an additive sibling of the lazy checks-glyph endpoint (GitHub tab: incremental/paginated issue & PR loading instead of the 30→1000 two-shot fetch #664), so the fast one-shot list fetch is untouched..ai/specs/2026-08-09-issue-linked-pr-chip.md(Phase 1, steps 1–8) landed by the design-only spec PR docs(specs): Linked-PR chips on the GitHub Issues list #816.What Changed
packages/contract/src/github.ts— newlinkedPrSchema({number, url, state: 'open'|'merged'|'closed', isDraft?}) andgithubIssuePrsDataSchema, a discriminated union onavailablemirroringgithubChecksDataSchema. The §2-protectedgithubItemSchemaand the list payload are unchanged.packages/cezar/src/server/forge/github.ts— newfetchGithubIssuePrs(repoRoot, numbers, refresh?)plus the exported, injection-testablefetchIssuePrLinks. One aliased GraphQL query (i0: issue(number:), …) covers a whole window, so 100 issues cost one subprocess. Per issue it keeps onlyPullRequestsubjects/sources, dedupes by PR number (a PR seen as both connected and cross-referenced collapses to one chip), subtracts any PR carried by aDisconnectedEventso a deliberately-unlinked PR loses its chip, and orders open → merged → closed then by descending number. Backed by a bounded 60 s per-issueissuePrsCachewith arefreshbypass, a__clearIssuePrsCacheForTests()seam, and aCEZ_DRY_RUN=1mock so the offline demo and the e2e suite paint real chips. It resolves the handle through the memoizedresolveRepoHandlerather thanfetchGithubChecks's inlinegh repo view— one fewer subprocess per window.packages/cezar/src/server/server.ts— the new route registered as a chained.get('/github/issue-prs', …)link in thegithubRoutesfamily (a looseapp.getwould vanish fromAppType), with the same CSV validator shape and 400 wording as/github/checksplus the optionalrefresh=1flag. Re-exported through theserver/github.tsbarrel.BACKWARD_COMPATIBILITY.md— the route added to the §2 inventory with its full contract, including why an issue with no links is absent from the map rather than present-and-empty.packages/web/src/api/client.ts/queries.ts—getGithubIssuePrs,queryKeys.githubIssuePrsanduseGithubIssuePrs, mirroring the checks sibling.packages/api-clientis deliberately untouched — itsexport * from '@open-mercato/cezar-contract'already re-exports the new types.packages/web/src/routes/github/github.tsx— derives the on-screen issue window (pinning the URL-selected issue so a deep link still hydrates), gates the hook toview === 'issues', and rendersLinkedPrChip/LinkedPrOverflowChipon the row's meta line: in-cockpit/github/prs/:nwhen the PR is in the loaded open set, the PR's GitHub URL otherwise (isHttpUrl-guarded), and a+Ncollapse past three chips. The header's Refresh re-fetches the window withrefresh=1, because a bare invalidate would be handed the same ≤60 s server cache and the flagship case is "an agent just opened a PR for this issue".GithubRowused to be one<Link>wrapping everything, which a chip cannot live inside: an<a>within an<a>is invalid HTML and a WCAG 4.1.2 (nested interactive) failure that this SSR-less SPA would emit silently. The<li>is now the positioning context, the row link anabsolute inset-0overlay carrying the navigation, the drag payload and an explicitaria-label, and the chips are its siblings — one interactive element per region, and nostopPropagationneeded.🧪 Tests
npm run typecheck— clean. This is where the contract guard lives: the newGithubIssuePrs200mutual-assignability assertion incontract-parity.github.test.tsfails compilation if the schema and the handler ever drift in either direction.npm test— 6091 passed / 324 files. New: 20 forge cases (forge/github.test.ts) covering alias mapping, the three state words plus the unknown-state fallback,isDraftomitted rather than sent asundefined, dedupe, disconnected-subtraction, thelast:-window ordering, chunking with a failing chunk, the empty-list and total-failure degrades, the cache, itsrefreshbypass and the dry-run mock; 9 route cases (github-issue-prs-api.test.ts) covering the happy payload, absent-vs-empty,refresh=1, both 400 wordings, the cap, padded/negative numbers and the project-scoped alias; 3 client cases; and 9 cockpit cases (github.test.tsx) covering chip text, tint, the draft variant, both link targets, that clicking a chip opens the PR and not the row's issue, the no-nested-anchor structural invariant, the+Noverflow, the unavailable and PRs-view cases, selected-issue pinning, and therefresh=1on the header refresh.npm run test:unit— 36 passed.npm run build+check:pack— ok (475 files).npm run test:package— 15 passed.packages/web/e2e/github.e2e.tsgains a case that drives the real cockpit underCEZ_DRY_RUN=1and screenshots the chipped row.💥 Breaking Changes
data-slot, thehref,aria-current, the drag payload and the hover/active styling; two existing test selectors were rescoped from the row link to the row<li>(gh-row-item) because the meta glyphs are now its siblings.📋 Progress
See the Progress section in the tracking plan — all 8 steps checked.