From 2906724f09381c73b34831d4570640ae096e1dab Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Wed, 17 Jun 2026 10:04:16 +0200 Subject: [PATCH 1/8] fix(unic-pr-review): stop silently dropping linked Work Items on large PRs discoverWorkItems now takes a workItemRefs array directly (not prMetadata), throws on absent/non-array input, and the ADO Fetcher hoists workItemRefs to a top-level FETCHER_OUTPUT field so it survives inline on large PRs. review-pr.md Step 1.5 treats absent key as a data-loss Notice + continue (third ADR-0004 state), not as a silent empty-WI result. ADR-0010 and ADR-0004 amended; CONTEXT.md Work Item definition updated. Co-Authored-By: Claude Sonnet 4.6 --- apps/claude-code/unic-pr-review/CONTEXT.md | 2 +- .../unic-pr-review/agents/ado-fetcher.md | 8 +- .../unic-pr-review/commands/review-pr.md | 22 +++- ...04-hard-stop-on-missing-doc-credentials.md | 13 ++ .../docs/adr/0010-provider-folder-bundle.md | 8 +- .../providers/azure_devops/provider.mjs | 33 ++--- .../azure_devops/tests/provider.test.mjs | 49 ++++---- .../unic-pr-review/providers/index.mjs | 10 +- .../work-item-discovery-contract.test.mjs | 118 ++++++++++++++++++ 9 files changed, 212 insertions(+), 51 deletions(-) create mode 100644 apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs diff --git a/apps/claude-code/unic-pr-review/CONTEXT.md b/apps/claude-code/unic-pr-review/CONTEXT.md index 1d6a1444..4fd4158e 100644 --- a/apps/claude-code/unic-pr-review/CONTEXT.md +++ b/apps/claude-code/unic-pr-review/CONTEXT.md @@ -77,7 +77,7 @@ A folder bundle at `providers//` implementing the Source Platform contract _Avoid_: adapter, backend, driver **Work Item**: -A normalised task record `{ id, type, url, raw }` returned by `provider.discoverWorkItems(prMetadata)` — an Azure Boards Work Item, a Jira issue, or a manually pasted URL in Pre-PR Mode. +A normalised task record `{ id, type, url, raw }` returned by `provider.discoverWorkItems(workItemRefs)` — takes the refs array hoisted to `FETCHER_OUTPUT.workItemRefs` (top-level, not nested in `prMetadata`) directly. Covers an Azure Boards Work Item, a Jira issue, or a manually pasted URL in Pre-PR Mode. _Avoid_: ticket, story, task **Notice**: diff --git a/apps/claude-code/unic-pr-review/agents/ado-fetcher.md b/apps/claude-code/unic-pr-review/agents/ado-fetcher.md index 1c250d8e..a5d78383 100644 --- a/apps/claude-code/unic-pr-review/agents/ado-fetcher.md +++ b/apps/claude-code/unic-pr-review/agents/ado-fetcher.md @@ -266,14 +266,16 @@ Otherwise the command exited zero. `git diff` exits zero on success whether or n ### Step 6 — Emit result +**Authoritative-channel rule**: the single inline JSON object you emit here is the **only** authoritative output channel. Never offload required fields to a disk file — the orchestrator never reads a Fetcher-written file. On large PRs, keep the summary-tier fields (`workItemRefs`, `mode`, `changedFiles`, `diffUnavailable`, `warnings`) inline and small; the bulky `prMetadata` blob (full ADO response) may abbreviate as needed, because the orchestrator reads `workItemRefs` from the top-level field, not from `prMetadata`. + Emit exactly one JSON object — no prose, no markdown, no footer (replace each `<…>` placeholder with the real value it names; `prMetadata`, `revisions`, and `threads` are objects, not strings): ```json { "prMetadata": { - "pullRequestId": 42, - "workItemRefs": [{ "id": "101", "url": "https://dev.azure.com/org/project/_apis/wit/workitems/101" }] + "pullRequestId": 42 }, + "workItemRefs": [{ "id": "101", "url": "https://dev.azure.com/org/project/_apis/wit/workitems/101" }], "revisions": "", "threads": "", "changedFiles": ["path/to/file.ts"], @@ -292,6 +294,6 @@ Emit exactly one JSON object — no prose, no markdown, no footer (replace each } ``` -`prMetadata` is the raw ADO `pullrequests` response enriched with `workItemRefs` from Step 1.5. `workItemRefs` is always an array (empty `[]` when no Work Items are linked or the fetch failed). +`workItemRefs` is a **top-level** field — a small array of `{ id, url }` pairs from Step 1.5, always present (empty `[]` when no Work Items are linked or the fetch failed). It is **not** nested inside `prMetadata` so it survives inline even when the raw `prMetadata` blob is large. `prMetadata` is the raw ADO `pullrequests` response; the orchestrator reads `workItemRefs` from the top-level field, never from `prMetadata.workItemRefs`. `mode` is one of `"first-review"`, `"re-review"`, `"first-review-fallback"`. `priorRevisionId` and `priorIteration` are `null` except in `re-review` mode (where they carry `PRIOR_SIG.priorRevisionId` / `PRIOR_SIG.priorIteration`). `deltaRawDiff` is the delta diff string (empty in first-review modes). `priorFindings` is an array of `{ threadId, filePath, startLine, severity, title }` objects (empty except in `re-review` mode), where `threadId` is the number id of the ADO Thread carrying that prior finding's bot comment — it is what the Re-review Coordinator keys all thread mapping on. `humanThreads` is an array of `{ threadId, filePath, startLine, status, excerpt }` objects — Human Threads classified in Step 3b (ADR-0016). `filePath` and `startLine` are `null` for non-inline (general comment) threads. Always emitted; empty `[]` when no Human Threads exist. `diffUnavailable` is `false` when a real diff was computed (re-review always, first-review/first-review-fallback when inside a matching clone and the git diff succeeds) and `true` when a diff could not be obtained (no matching clone, missing commonRefCommit or sourceRefCommit, git diff failure, or an empty diff despite a non-empty changedFiles). `warnings` is an array of strings for any non-fatal issues. Never emit `hardStop` — the orchestrator handles all write decisions. diff --git a/apps/claude-code/unic-pr-review/commands/review-pr.md b/apps/claude-code/unic-pr-review/commands/review-pr.md index f0f729b3..50264eb7 100644 --- a/apps/claude-code/unic-pr-review/commands/review-pr.md +++ b/apps/claude-code/unic-pr-review/commands/review-pr.md @@ -152,13 +152,29 @@ Then proceed through the shared steps with these re-review deltas: #### Step 1.5 — Discover Work Items -Write `FETCHER_OUTPUT.prMetadata` to a temp file (avoids shell-quoting the JSON), then pipe it in: +First distinguish whether `FETCHER_OUTPUT.workItemRefs` is **present** (including an explicit `[]`) or **absent** (the key does not exist on `FETCHER_OUTPUT`). These are two different states — absent means data was lost in the Fetcher→orchestrator handoff, not that the PR has no Work Items linked. + +**Key absent — data-loss path:** + +Print the following notice to the terminal **before** the aspect fan-out so the Reviewer can Ctrl-C and re-run for intent coverage: + +``` +⚠ Work Item data was not delivered by the ADO Fetcher (workItemRefs key absent on FETCHER_OUTPUT). + This is a data gap — not a PR with no Work Items linked. The Intent Check will be skipped. + Press Ctrl-C now to abort and re-run for intent coverage, or wait to continue without it. +``` + +Add `lostInHandoff: true` to `NOTICES_CONTEXT` so the renderer emits a durable Summary Notice at the top of the Review Summary (also posted to the PR when `--post` is used). Set `WORK_ITEMS = []` and continue to Step 1.6. Do **not** stop the run. + +**Key present (including `[]`) — normal path:** + +Write `FETCHER_OUTPUT.workItemRefs` (the refs array, not the raw `prMetadata` blob) as JSON to a temp file, then pipe it in: ```sh -node "${CLAUDE_PLUGIN_ROOT}/providers/index.mjs" discover-work-items "" < "" +node "${CLAUDE_PLUGIN_ROOT}/providers/index.mjs" discover-work-items "" < "" ``` -- **Exit 0**: stdout is a JSON array. Store as `WORK_ITEMS`. +- **Exit 0**: stdout is a JSON array. Store as `WORK_ITEMS`. An explicit `[]` (no Work Items linked) stays silent — not a data gap. - **Exit non-zero**: relay stderr and stop. #### Step 1.6 — Spawn Intent Checker (only when `WORK_ITEMS` is non-empty) diff --git a/apps/claude-code/unic-pr-review/docs/adr/0004-hard-stop-on-missing-doc-credentials.md b/apps/claude-code/unic-pr-review/docs/adr/0004-hard-stop-on-missing-doc-credentials.md index b4906359..94bc0918 100644 --- a/apps/claude-code/unic-pr-review/docs/adr/0004-hard-stop-on-missing-doc-credentials.md +++ b/apps/claude-code/unic-pr-review/docs/adr/0004-hard-stop-on-missing-doc-credentials.md @@ -25,3 +25,16 @@ If a fetched Work Item links to a Confluence page and the Confluence Credential Work Items discovered natively by a Source Platform Provider (e.g. ADO `workItemRefs` linked to a PR) are **promised intent** and follow the same reachability doctrine as pasted Jira / Confluence URLs. If a linked Work Item is unreachable or returns an auth error, the Plugin halts with a hard-stop. `not-found` remains a soft note — matching the pasted-URL rule — because it signals that the Work Item was genuinely absent from the system (deleted or never created), not that a configuration or credentials problem prevents the Plugin from reaching ADO. Auth errors and unreachable URLs indicate a broken setup the reviewer must fix; a missing Work Item is recoverable missing context. Org-URL extraction failures (malformed or unrecognised Work Item URL shapes) are treated as unreachable: the Plugin halts and surfaces the offending URL rather than silently passing a wrong `--org` flag to `az boards work-item show`. + +## Amendment (2026-06) — Third intent-state: lost-in-handoff → loud Notice + continue + +Two existing intent states: (1) **legitimate empty** — no Work Items linked (`workItemRefs = []`) — silent, the Intent Check is simply omitted; (2) **unreachable** — a linked source cannot be fetched — hard-stop. A third state is now recognised: + +**(3) Lost-in-handoff** — `workItemRefs` key is **absent** from `FETCHER_OUTPUT` (the field existed in the ADO Fetcher's output contract but was not delivered to the orchestrator, e.g. because the Fetcher agent abbreviated its large inline return on a big PR and dropped the field). This is **not** a legitimate no-WI case and **not** a hard-stop: the PR may well have linked Work Items, but the data did not survive the Fetcher→orchestrator handoff. + +Behaviour for the lost-in-handoff state (`review-pr.md` Step 1.5): +1. **Early terminal notice** (before the aspect fan-out): loud print telling the Reviewer that Work Item data was not delivered, the Intent Check will be skipped, and they can Ctrl-C to abort and re-run for intent coverage. +2. **Summary Notice** (durable): a `lostInHandoff: true` flag is added to `NOTICES_CONTEXT` so the renderer emits a Notice at the top of the Review Summary — also posted to the PR when `--post` is used — creating a durable record that intent coverage was absent due to a data gap, not a deliberate design choice. +3. **Continue** — do not stop the run; proceed with `WORK_ITEMS = []` (no Intent Check, no hard-stop). + +This state is distinct from legitimate-empty (silent) and unreachable (hard-stop), and is detected by checking `'workItemRefs' in FETCHER_OUTPUT` before inspecting its value. diff --git a/apps/claude-code/unic-pr-review/docs/adr/0010-provider-folder-bundle.md b/apps/claude-code/unic-pr-review/docs/adr/0010-provider-folder-bundle.md index 055f4e6b..b110bc1f 100644 --- a/apps/claude-code/unic-pr-review/docs/adr/0010-provider-folder-bundle.md +++ b/apps/claude-code/unic-pr-review/docs/adr/0010-provider-folder-bundle.md @@ -21,7 +21,13 @@ Each Source Platform Provider ships as a folder bundle `providers//` conta - `fixtures/` — test fixtures co-located with the provider - `tests/` — the bundle's unit tests -The bundle exports `name`, `label`, `prUrlPattern`, `parsePrUrl(url) → { orgUrl, project, repo, prId }`, `agents.{ fetcher, writer }` (in the `unic-pr-review:*` namespace), and `discoverWorkItems(prMetadata) → [{ id, type, url, raw }]`. `providers/index.mjs` exposes `detectProvider(url) → ProviderModule | null` by testing each registered provider's `prUrlPattern` in first-match-wins order. +The bundle exports `name`, `label`, `prUrlPattern`, `parsePrUrl(url) → { orgUrl, project, repo, prId }`, `agents.{ fetcher, writer }` (in the `unic-pr-review:*` namespace), and `discoverWorkItems(workItemRefs) → [{ id, type, url, raw }]`. `providers/index.mjs` exposes `detectProvider(url) → ProviderModule | null` by testing each registered provider's `prUrlPattern` in first-match-wins order. + +## Amendment (2026-06) — discoverWorkItems signature: refs array, not prMetadata blob + +`discoverWorkItems` was originally specified as `discoverWorkItems(prMetadata)` — taking the raw PR-metadata object and extracting `prMetadata.workItemRefs` internally. This was changed to `discoverWorkItems(workItemRefs)` — taking the refs array directly — because `workItemRefs` buried inside the large `prMetadata` blob was silently dropped by the ADO Fetcher agent on large PRs (agent improvisation to keep inline output small; the `?? []` fallback then collapsed data-loss into the legitimate "no Work Items linked" state, bypassing the silent-false-negative guard in Fetcher Step 1.5). + +The fix decouples the two: the ADO Fetcher now emits `workItemRefs` as a **top-level** field on `FETCHER_OUTPUT` (small summary-tier data, always kept inline), and the orchestrator (`review-pr.md` Step 1.5) reads `FETCHER_OUTPUT.workItemRefs` directly and passes the array to `discoverWorkItems`. The raw `prMetadata` blob is no longer on the discovery data path. `discoverWorkItems` throws on any non-array input — including `undefined` from an absent key — so a handoff data-loss can never be silently swallowed. ## Consequences diff --git a/apps/claude-code/unic-pr-review/providers/azure_devops/provider.mjs b/apps/claude-code/unic-pr-review/providers/azure_devops/provider.mjs index 50739433..92cc1811 100644 --- a/apps/claude-code/unic-pr-review/providers/azure_devops/provider.mjs +++ b/apps/claude-code/unic-pr-review/providers/azure_devops/provider.mjs @@ -41,20 +41,25 @@ export const agents = { } /** - * Read the Work Item refs that the ADO Fetcher populated on `prMetadata` from the - * PR's linked work-item endpoint (`pullrequestworkitems`, Step 1.5). Never - * regex-scrapes the description — this is the ADR-0001 amendment contract. + * Normalise the Work Item refs array hoisted by the ADO Fetcher to `FETCHER_OUTPUT.workItemRefs` + * (top-level, never nested in `prMetadata`). Never regex-scrapes the PR description — + * this is the ADR-0001 amendment contract. * - * @param {{ workItemRefs?: Array<{ id: string | number, url: string }> }} prMetadata + * Throws on `undefined` or any non-array input so a data-loss handoff (absent key on + * `FETCHER_OUTPUT`) is never silently collapsed into the legitimate "no Work Items linked" + * case (an explicit `[]` from the Fetcher). The caller must distinguish the two states and + * handle absent-key via the loud Notice + continue path (review-pr.md Step 1.5, ADR-0004). + * + * @param {Array<{ id: string | number, url: string }>} workItemRefs * @returns {Array<{ id: string, type: string, url: string, raw: object }>} - * @throws {Error} when `prMetadata` is not a PR-metadata object (guards against a - * malformed stdin payload silently yielding zero Work Items) + * @throws {Error} when `workItemRefs` is not an array (guards against a malformed or + * absent payload silently yielding zero Work Items) */ -export function discoverWorkItems(prMetadata) { - if (prMetadata === null || typeof prMetadata !== 'object' || Array.isArray(prMetadata)) { - throw new Error(`Expected PR metadata object with optional workItemRefs[], got ${describeType(prMetadata)}`) +export function discoverWorkItems(workItemRefs) { + if (!Array.isArray(workItemRefs)) { + throw new Error(`Expected workItemRefs array, got ${describeType(workItemRefs)}`) } - return (prMetadata.workItemRefs ?? []).map((ref) => ({ + return workItemRefs.map((ref) => ({ id: String(ref.id), type: 'ado-work-item', url: ref.url, @@ -88,7 +93,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) } } else if (subcommand === 'discover-work-items') { if (process.stdin.isTTY) { - process.stderr.write('discover-work-items expects PR metadata JSON on stdin (pipe it in)\n') + process.stderr.write('discover-work-items expects a workItemRefs JSON array on stdin (pipe it in)\n') process.exit(1) } /** @type {Buffer[]} */ @@ -96,8 +101,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.stdin.on('data', (c) => chunks.push(c)) process.stdin.on('end', () => { try { - const meta = JSON.parse(Buffer.concat(chunks).toString('utf8')) - process.stdout.write(`${JSON.stringify(discoverWorkItems(meta))}\n`) + const refs = JSON.parse(Buffer.concat(chunks).toString('utf8')) + process.stdout.write(`${JSON.stringify(discoverWorkItems(refs))}\n`) } catch (err) { process.stderr.write(`${errMsg(err)}\n`) process.exit(1) @@ -109,7 +114,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) }) } else { process.stderr.write( - 'Usage:\n node provider.mjs parse-url \n node provider.mjs discover-work-items (reads PR metadata JSON from stdin)\n' + 'Usage:\n node provider.mjs parse-url \n node provider.mjs discover-work-items (reads workItemRefs JSON array from stdin)\n' ) process.exit(1) } diff --git a/apps/claude-code/unic-pr-review/providers/azure_devops/tests/provider.test.mjs b/apps/claude-code/unic-pr-review/providers/azure_devops/tests/provider.test.mjs index 41db6cb0..d3e2ac67 100644 --- a/apps/claude-code/unic-pr-review/providers/azure_devops/tests/provider.test.mjs +++ b/apps/claude-code/unic-pr-review/providers/azure_devops/tests/provider.test.mjs @@ -45,39 +45,40 @@ describe('parsePrUrl', () => { describe('discoverWorkItems', () => { it('returns normalised list from fixture with one WI', () => { - const meta = fixture('pr-with-work-items.json') - const items = discoverWorkItems(meta) + const items = discoverWorkItems(fixture('pr-with-work-items.json').workItemRefs) assert.equal(items.length, 1) assert.equal(items[0].id, '101') assert.equal(items[0].type, 'ado-work-item') assert.equal(items[0].url, 'https://dev.azure.com/myorg/myproject/_apis/wit/workitems/101') }) - it('returns empty array from fixture without WIs', () => { - const meta = fixture('pr-without-work-items.json') - assert.deepEqual(discoverWorkItems(meta), []) + it('returns empty array from explicit empty refs (legitimate no-WI case)', () => { + assert.deepEqual(discoverWorkItems([]), []) }) it('returns all WIs from fixture with multiple WIs', () => { - const meta = fixture('pr-with-multiple-work-items.json') - const items = discoverWorkItems(meta) + const items = discoverWorkItems(fixture('pr-with-multiple-work-items.json').workItemRefs) assert.equal(items.length, 2) assert.deepEqual( items.map((i) => i.id), ['101', '102'] ) }) - it('returns empty array when workItemRefs absent', () => { - assert.deepEqual(discoverWorkItems({}), []) + it('throws on undefined — data-loss path must not silently return empty', () => { + // absent key on FETCHER_OUTPUT collapses to undefined; this must throw so the + // orchestrator's absent-key loud-Notice path (review-pr.md Step 1.5, ADR-0004) + // is the only way to reach WORK_ITEMS=[] when data is missing + // @ts-expect-error — deliberately passing undefined to assert the guard fires + assert.throws(() => discoverWorkItems(undefined), /Expected workItemRefs array/) }) - it('throws on non-object input instead of silently returning empty', () => { + it('throws on non-array inputs instead of silently returning empty', () => { // @ts-expect-error — deliberately passing the wrong shape to assert the guard fires - assert.throws(() => discoverWorkItems(null), /Expected PR metadata object/) + assert.throws(() => discoverWorkItems(null), /Expected workItemRefs array/) // @ts-expect-error — deliberately passing the wrong shape to assert the guard fires - assert.throws(() => discoverWorkItems('not-json'), /got string/) + assert.throws(() => discoverWorkItems({}), /got object/) // @ts-expect-error — deliberately passing the wrong shape to assert the guard fires - assert.throws(() => discoverWorkItems([]), /got array/) + assert.throws(() => discoverWorkItems('not-json'), /got string/) }) it('emits string ids and ado-work-item type for every entry', () => { - const items = discoverWorkItems(fixture('pr-with-multiple-work-items.json')) + const items = discoverWorkItems(fixture('pr-with-multiple-work-items.json').workItemRefs) for (const item of items) { assert.equal(typeof item.id, 'string') assert.equal(item.type, 'ado-work-item') @@ -88,28 +89,28 @@ describe('discoverWorkItems', () => { // discoverWorkItems must coerce via String(ref.id) so downstream id handling is stable // regardless of wire shape — this exercises the integer branch the JSDoc `id: string | number` // widening opened, which the fixture-shape tests below cannot reach. - const items = discoverWorkItems({ - workItemRefs: [{ id: 42622, url: 'https://dev.azure.com/FZAG/_apis/wit/workitems/42622' }], - }) + const items = discoverWorkItems([{ id: 42622, url: 'https://dev.azure.com/FZAG/_apis/wit/workitems/42622' }]) assert.equal(items.length, 1) assert.equal(items[0].id, '42622') assert.equal(typeof items[0].id, 'string') }) }) -// Fetcher contract: the ADO Fetcher (Step 1.5) must populate workItemRefs on prMetadata -// from the pullrequestworkitems endpoint. These tests assert that the fixtures reflect -// real fetcher output shape — workItemRefs is always present (never absent) because the -// fetcher sets it explicitly (to [] when no WIs are linked or the fetch fails). -describe('fetcher contract — prMetadata.workItemRefs is always fetcher-populated', () => { +// Fetcher contract: the ADO Fetcher (Step 1.5) fetches workItemRefs from pullrequestworkitems +// and emits them as a top-level field on FETCHER_OUTPUT (not nested inside prMetadata). +// The orchestrator (review-pr.md Step 1.5) reads FETCHER_OUTPUT.workItemRefs directly and +// passes the array to discoverWorkItems — never the raw prMetadata blob. +// These tests assert that the fixtures reflect the expected ADO wire shape so the +// normalisation tests above remain grounded in real data. +describe('fetcher contract — fixture workItemRefs shapes match ADO wire format', () => { it('pr-with-work-items fixture has workItemRefs with at least one entry', () => { const meta = fixture('pr-with-work-items.json') - assert.ok(Array.isArray(meta.workItemRefs), 'workItemRefs must be an array (fetcher-populated)') + assert.ok(Array.isArray(meta.workItemRefs), 'workItemRefs must be an array') assert.ok(meta.workItemRefs.length > 0, 'fixture must have at least one work item ref') }) it('pr-with-multiple-work-items fixture has workItemRefs with multiple entries', () => { const meta = fixture('pr-with-multiple-work-items.json') - assert.ok(Array.isArray(meta.workItemRefs), 'workItemRefs must be an array (fetcher-populated)') + assert.ok(Array.isArray(meta.workItemRefs), 'workItemRefs must be an array') assert.ok(meta.workItemRefs.length > 1, 'fixture must have more than one work item ref') }) it('pr-without-work-items fixture has workItemRefs as empty array (not absent)', () => { diff --git a/apps/claude-code/unic-pr-review/providers/index.mjs b/apps/claude-code/unic-pr-review/providers/index.mjs index 9406ebbc..1e0835e0 100644 --- a/apps/claude-code/unic-pr-review/providers/index.mjs +++ b/apps/claude-code/unic-pr-review/providers/index.mjs @@ -17,7 +17,7 @@ import { pathToFileURL } from 'node:url' * prUrlPattern: RegExp, * parsePrUrl: (url: string) => { orgUrl: string, project: string, repo: string, prId: number }, * agents: { fetcher: string, writer: string }, - * discoverWorkItems: (prMetadata: object) => Array<{ id: string, type: string, url: string, raw: object }>, + * discoverWorkItems: (workItemRefs: Array<{ id: string | number, url: string }>) => Array<{ id: string, type: string, url: string, raw: object }>, * }} ProviderModule */ @@ -90,7 +90,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(1) } if (process.stdin.isTTY) { - process.stderr.write('discover-work-items expects PR metadata JSON on stdin (pipe it in)\n') + process.stderr.write('discover-work-items expects a workItemRefs JSON array on stdin (pipe it in)\n') process.exit(1) } /** @type {Buffer[]} */ @@ -98,8 +98,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.stdin.on('data', (c) => chunks.push(c)) process.stdin.on('end', () => { try { - const meta = JSON.parse(Buffer.concat(chunks).toString('utf8')) - process.stdout.write(`${JSON.stringify(provider.discoverWorkItems(meta))}\n`) + const refs = JSON.parse(Buffer.concat(chunks).toString('utf8')) + process.stdout.write(`${JSON.stringify(provider.discoverWorkItems(refs))}\n`) } catch (err) { process.stderr.write(`${errMsg(err)}\n`) process.exit(1) @@ -115,7 +115,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) } } else { process.stderr.write( - 'Usage:\n node providers/index.mjs detect \n node providers/index.mjs parse-url \n node providers/index.mjs discover-work-items (reads PR metadata JSON from stdin)\n' + 'Usage:\n node providers/index.mjs detect \n node providers/index.mjs parse-url \n node providers/index.mjs discover-work-items (reads workItemRefs JSON array from stdin)\n' ) process.exit(1) } diff --git a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs new file mode 100644 index 00000000..4b02f198 --- /dev/null +++ b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs @@ -0,0 +1,118 @@ +// @ts-check +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright © 2026 Unic + +/** + * Structural regression guard for issue #252 — Work Item discovery contract. + * + * Two invariants: + * + * 1. `commands/review-pr.md` Step 1.5 must pipe `FETCHER_OUTPUT.workItemRefs` (the + * top-level refs array) to `discover-work-items` — never the raw `prMetadata` blob. + * Piping prMetadata silently loses workItemRefs on large PRs (the original bug). + * + * 2. `agents/ado-fetcher.md` Step 6 must emit `workItemRefs` as a top-level field + * (2-space indent in the JSON example), not nested inside `prMetadata` (4-space + * indent). Top-level placement keeps the small refs array in the "summary tier" + * the agent keeps inline even when the bulky prMetadata blob is abbreviated. + */ + +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const reviewPr = readFileSync(join(ROOT, 'commands/review-pr.md'), 'utf8') +const adoFetcher = readFileSync(join(ROOT, 'agents/ado-fetcher.md'), 'utf8') + +/** + * Extract all fenced `sh` code blocks from a markdown string. + * @param {string} content + * @returns {string[]} + */ +function extractShBlocks(content) { + return [...content.matchAll(/```sh\n([\s\S]*?)```/g)].map((m) => m[1]) +} + +/** + * Extract all fenced `json` code blocks from a markdown string. + * @param {string} content + * @returns {string[]} + */ +function extractJsonBlocks(content) { + return [...content.matchAll(/```json\n([\s\S]*?)```/g)].map((m) => m[1]) +} + +describe('Work Item discovery contract', () => { + describe('review-pr.md Step 1.5: pipes workItemRefs (not prMetadata) to discover-work-items', () => { + it('no sh block containing discover-work-items also contains prMetadata', () => { + const violations = /** @type {string[]} */ ([]) + for (const block of extractShBlocks(reviewPr)) { + if (block.includes('discover-work-items') && block.includes('prMetadata')) { + violations.push(block.trim()) + } + } + assert.deepEqual( + violations, + [], + `sh blocks with discover-work-items must not reference prMetadata — pipe workItemRefs array instead:\n${violations.join('\n---\n')}` + ) + }) + + it('Step 1.5 section references FETCHER_OUTPUT.workItemRefs', () => { + assert.ok( + reviewPr.includes('FETCHER_OUTPUT.workItemRefs'), + 'review-pr.md Step 1.5 must reference FETCHER_OUTPUT.workItemRefs (top-level field)' + ) + }) + }) + + describe('ado-fetcher.md Step 6: workItemRefs is a top-level field in the output schema', () => { + it('Step 6 JSON example has workItemRefs at top-level indent (2 spaces)', () => { + const jsonBlocks = extractJsonBlocks(adoFetcher) + // The Step 6 output schema is the only json block in the file + const step6Block = jsonBlocks.find((b) => b.includes('"prMetadata"') && b.includes('"mode"')) + assert.ok(step6Block, 'ado-fetcher.md must contain a Step 6 JSON output schema block') + + const lines = step6Block.split('\n') + const topLevelWorkItemRefs = lines.some((l) => /^ {2}"workItemRefs"/.test(l)) + assert.ok( + topLevelWorkItemRefs, + 'workItemRefs must appear at top-level (2-space indent) in the Step 6 JSON schema, not nested inside prMetadata' + ) + }) + + it('Step 6 JSON example does not have workItemRefs nested inside prMetadata (4+ spaces)', () => { + const jsonBlocks = extractJsonBlocks(adoFetcher) + const step6Block = jsonBlocks.find((b) => b.includes('"prMetadata"') && b.includes('"mode"')) + assert.ok(step6Block, 'ado-fetcher.md must contain a Step 6 JSON output schema block') + + const lines = step6Block.split('\n') + // Find the prMetadata block range and check workItemRefs does not appear inside it + let insideMetadata = false + let depth = 0 + const nestedViolations = /** @type {string[]} */ ([]) + for (const line of lines) { + if (/^ {2}"prMetadata"/.test(line)) { + insideMetadata = true + depth = 0 + } + if (insideMetadata) { + depth += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length + if (depth <= 0 && line.includes('}')) { + insideMetadata = false + } else if (/^ {4,}"workItemRefs"/.test(line)) { + nestedViolations.push(line.trim()) + } + } + } + assert.deepEqual( + nestedViolations, + [], + `workItemRefs must not be nested inside prMetadata in Step 6 schema:\n${nestedViolations.join('\n')}` + ) + }) + }) +}) From 9e39e8e80098c4e9e71d632cc41927ab01f4da50 Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Wed, 17 Jun 2026 10:04:36 +0200 Subject: [PATCH 2/8] chore(unic-pr-review): add CHANGELOG entry for #252 fix Co-Authored-By: Claude Sonnet 4.6 --- apps/claude-code/unic-pr-review/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/claude-code/unic-pr-review/CHANGELOG.md b/apps/claude-code/unic-pr-review/CHANGELOG.md index 73fa6ddf..c07d1f7c 100644 --- a/apps/claude-code/unic-pr-review/CHANGELOG.md +++ b/apps/claude-code/unic-pr-review/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - (none) ### Fixed -- (none) +- Linked Work Items silently dropped on large PRs: `discoverWorkItems` now takes the refs array directly (hoisted to top-level `FETCHER_OUTPUT.workItemRefs`), throws on absent/non-array input, and `review-pr.md` Step 1.5 surfaces a loud Notice when the key is absent (data-loss path) instead of silently treating it as a no-WI PR (#252) ## [2.1.11] — 2026-06-16 From b605923d58a6df7e532ef0f0951ade757cdf93f1 Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Wed, 17 Jun 2026 10:06:00 +0200 Subject: [PATCH 3/8] chore(unic-pr-review): bump to 2.1.12 Co-Authored-By: Claude Sonnet 4.6 --- .../unic-pr-review/.claude-plugin/marketplace.json | 2 +- .../unic-pr-review/.claude-plugin/plugin.json | 2 +- apps/claude-code/unic-pr-review/CHANGELOG.md | 11 +++++++++++ apps/claude-code/unic-pr-review/package.json | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/claude-code/unic-pr-review/.claude-plugin/marketplace.json b/apps/claude-code/unic-pr-review/.claude-plugin/marketplace.json index d419f66f..600d4a80 100644 --- a/apps/claude-code/unic-pr-review/.claude-plugin/marketplace.json +++ b/apps/claude-code/unic-pr-review/.claude-plugin/marketplace.json @@ -21,7 +21,7 @@ "name": "unic-pr-review", "source": "./", "tags": ["productivity", "code-review", "azure-devops"], - "version": "2.1.11" + "version": "2.1.12" } ] } diff --git a/apps/claude-code/unic-pr-review/.claude-plugin/plugin.json b/apps/claude-code/unic-pr-review/.claude-plugin/plugin.json index 91ad6a48..703a39c8 100644 --- a/apps/claude-code/unic-pr-review/.claude-plugin/plugin.json +++ b/apps/claude-code/unic-pr-review/.claude-plugin/plugin.json @@ -15,5 +15,5 @@ "keywords": ["pr-review", "azure-devops", "jira", "confluence", "code-review", "unic"], "license": "LGPL-3.0-or-later", "name": "unic-pr-review", - "version": "2.1.11" + "version": "2.1.12" } diff --git a/apps/claude-code/unic-pr-review/CHANGELOG.md b/apps/claude-code/unic-pr-review/CHANGELOG.md index c07d1f7c..51cb57a0 100644 --- a/apps/claude-code/unic-pr-review/CHANGELOG.md +++ b/apps/claude-code/unic-pr-review/CHANGELOG.md @@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - (none) +### Fixed +- (none) + +## [2.1.12] — 2026-06-17 + +### Breaking +- (none) + +### Added +- (none) + ### Fixed - Linked Work Items silently dropped on large PRs: `discoverWorkItems` now takes the refs array directly (hoisted to top-level `FETCHER_OUTPUT.workItemRefs`), throws on absent/non-array input, and `review-pr.md` Step 1.5 surfaces a loud Notice when the key is absent (data-loss path) instead of silently treating it as a no-WI PR (#252) diff --git a/apps/claude-code/unic-pr-review/package.json b/apps/claude-code/unic-pr-review/package.json index ebd0b598..99d7d921 100644 --- a/apps/claude-code/unic-pr-review/package.json +++ b/apps/claude-code/unic-pr-review/package.json @@ -22,5 +22,5 @@ "verify:changelog": "unic-verify-changelog" }, "type": "module", - "version": "2.1.11" + "version": "2.1.12" } From 132547c9a539b3ca117b866881e68f6633cdafcd Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Wed, 17 Jun 2026 10:09:14 +0200 Subject: [PATCH 4/8] fix(unic-pr-review): address CI review findings from PR #255 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Format ADR-0004 markdown with Prettier (CI Biome+Prettier check) - Normalise CRLF→LF in structural test to fix Windows Node test failures Co-Authored-By: Claude Sonnet 4.6 --- .../docs/adr/0004-hard-stop-on-missing-doc-credentials.md | 1 + .../tests/work-item-discovery-contract.test.mjs | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/claude-code/unic-pr-review/docs/adr/0004-hard-stop-on-missing-doc-credentials.md b/apps/claude-code/unic-pr-review/docs/adr/0004-hard-stop-on-missing-doc-credentials.md index 94bc0918..c4875d1c 100644 --- a/apps/claude-code/unic-pr-review/docs/adr/0004-hard-stop-on-missing-doc-credentials.md +++ b/apps/claude-code/unic-pr-review/docs/adr/0004-hard-stop-on-missing-doc-credentials.md @@ -33,6 +33,7 @@ Two existing intent states: (1) **legitimate empty** — no Work Items linked (` **(3) Lost-in-handoff** — `workItemRefs` key is **absent** from `FETCHER_OUTPUT` (the field existed in the ADO Fetcher's output contract but was not delivered to the orchestrator, e.g. because the Fetcher agent abbreviated its large inline return on a big PR and dropped the field). This is **not** a legitimate no-WI case and **not** a hard-stop: the PR may well have linked Work Items, but the data did not survive the Fetcher→orchestrator handoff. Behaviour for the lost-in-handoff state (`review-pr.md` Step 1.5): + 1. **Early terminal notice** (before the aspect fan-out): loud print telling the Reviewer that Work Item data was not delivered, the Intent Check will be skipped, and they can Ctrl-C to abort and re-run for intent coverage. 2. **Summary Notice** (durable): a `lostInHandoff: true` flag is added to `NOTICES_CONTEXT` so the renderer emits a Notice at the top of the Review Summary — also posted to the PR when `--post` is used — creating a durable record that intent coverage was absent due to a data gap, not a deliberate design choice. 3. **Continue** — do not stop the run; proceed with `WORK_ITEMS = []` (no Intent Check, no hard-stop). diff --git a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs index 4b02f198..68774d7a 100644 --- a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs +++ b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs @@ -24,8 +24,9 @@ import { describe, it } from 'node:test' import { fileURLToPath } from 'node:url' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const reviewPr = readFileSync(join(ROOT, 'commands/review-pr.md'), 'utf8') -const adoFetcher = readFileSync(join(ROOT, 'agents/ado-fetcher.md'), 'utf8') +// Normalise CRLF → LF so regex patterns work on Windows and Unix identically. +const reviewPr = readFileSync(join(ROOT, 'commands/review-pr.md'), 'utf8').replace(/\r\n/g, '\n') +const adoFetcher = readFileSync(join(ROOT, 'agents/ado-fetcher.md'), 'utf8').replace(/\r\n/g, '\n') /** * Extract all fenced `sh` code blocks from a markdown string. From 215509919568e13b013728ae11dbf88b1adb487b Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Wed, 17 Jun 2026 10:23:47 +0200 Subject: [PATCH 5/8] fix(unic-pr-review): address review findings from PR #255 Fixes: - Update README.md discoverWorkItems section to new signature (HIGH) - Update ADR-0001 amendment to new discoverWorkItems(workItemRefs) signature (HIGH) - Add per-ref validation (id/url guards) in discoverWorkItems (LOW) - Add CLI integration tests for discover-work-items subcommand (MEDIUM) - Fix inaccurate comment about JSON blocks in contract test (LOW) - Add depth-tracker multi-line assumption comment in contract test (LOW) - Update AGENTS.md ADR-0004 one-liner with lost-in-handoff state (LOW) Co-Authored-By: Claude Sonnet 4.6 --- apps/claude-code/unic-pr-review/AGENTS.md | 2 +- ...ntent-with-shared-atlassian-credentials.md | 10 +++--- .../providers/azure_devops/README.md | 31 +++++++++-------- .../providers/azure_devops/provider.mjs | 17 ++++++---- .../azure_devops/tests/provider.test.mjs | 18 ++++++++++ .../providers/tests/index-cli.test.mjs | 33 +++++++++++++++++++ .../work-item-discovery-contract.test.mjs | 8 +++-- 7 files changed, 93 insertions(+), 26 deletions(-) diff --git a/apps/claude-code/unic-pr-review/AGENTS.md b/apps/claude-code/unic-pr-review/AGENTS.md index 10e57a04..6a734172 100644 --- a/apps/claude-code/unic-pr-review/AGENTS.md +++ b/apps/claude-code/unic-pr-review/AGENTS.md @@ -67,7 +67,7 @@ Load-bearing invariants captured as ADRs. All sixteen must be understood before - **ADR-0001** — Multi-source intent gathering with shared Atlassian credentials (`.unic-confluence.json` covers both Confluence and Jira) - **ADR-0002** — Confidence-scored Findings with explicit Severity thresholds (Critical 90-100, Important 80-89, Minor 60-79; drop below 60) - **ADR-0003** — Interactive Approval Loop as the default write path -- **ADR-0004** — Hard-stop when intent sources are unreachable; empty intent is legitimate +- **ADR-0004** — Hard-stop when intent sources are unreachable; legitimate empty (`[]`) is silent; absent `workItemRefs` key (lost-in-handoff) → loud Notice + continue - **ADR-0005** — `az` CLI for Azure DevOps reads/writes; `node:https` (or global `fetch`) for Atlassian - **ADR-0006** — Iteration state lives in the PR's Bot Signature (hidden `` Iteration Marker), not on disk; detection keys on the Iteration Marker, never on ADO author identity; `doctor` does not probe `az devops user show` - **ADR-0007** — Re-review uses a delta diff against the prior reviewed Revision diff --git a/apps/claude-code/unic-pr-review/docs/adr/0001-multi-source-intent-with-shared-atlassian-credentials.md b/apps/claude-code/unic-pr-review/docs/adr/0001-multi-source-intent-with-shared-atlassian-credentials.md index c14bbc71..7aa6a1b3 100644 --- a/apps/claude-code/unic-pr-review/docs/adr/0001-multi-source-intent-with-shared-atlassian-credentials.md +++ b/apps/claude-code/unic-pr-review/docs/adr/0001-multi-source-intent-with-shared-atlassian-credentials.md @@ -24,10 +24,12 @@ Reuse `~/.unic-confluence.json` as the shared Atlassian Credential File for both ## Amendment (2026-06) Work-item discovery is a **Provider contract**. Each Source Platform Provider exposes -`discoverWorkItems(prMetadata) → [{ id, type, url, raw }]`. For ADO, the Fetcher -populates `prMetadata.workItemRefs` from the dedicated `pullrequestworkitems` endpoint -(Step 1.5 of the ADO Fetcher — the `pullrequests` response does not include Work Item -links); `discoverWorkItems` then reads that field (never regex-scraping the description). +`discoverWorkItems(workItemRefs) → [{ id, type, url, raw }]` — takes the refs array +directly (hoisted to `FETCHER_OUTPUT.workItemRefs` top-level, not nested in `prMetadata`). +For ADO, the Fetcher fetches `workItemRefs` from the `pullrequestworkitems` endpoint +(Step 1.5) and emits them as a top-level field; `discoverWorkItems` receives that array +and never regex-scrapes the description. See ADR-0010 amendment (2026-06) for the full +rationale behind the signature change. For future GitHub/GitLab Providers it will use their respective native linkage endpoints. The Intent Checker stays Source-Platform-agnostic: it consumes the normalised list regardless of origin. This separates "where did the Work Items come from?" (Provider diff --git a/apps/claude-code/unic-pr-review/providers/azure_devops/README.md b/apps/claude-code/unic-pr-review/providers/azure_devops/README.md index 34f0b022..62f38729 100644 --- a/apps/claude-code/unic-pr-review/providers/azure_devops/README.md +++ b/apps/claude-code/unic-pr-review/providers/azure_devops/README.md @@ -23,14 +23,19 @@ Returns the addressable parts of a PR: Throws `Not an ADO PR URL: ` when the URL does not match `prUrlPattern`. -## `discoverWorkItems(prMetadata)` +## `discoverWorkItems(workItemRefs)` -Reads the `workItemRefs` that the ADO Fetcher populates on `prMetadata` from the -dedicated `pullrequestworkitems` endpoint (Step 1.5 — the `pullrequests` response -does not carry Work Item links), and normalises each entry to +Takes the `workItemRefs` array hoisted by the ADO Fetcher to `FETCHER_OUTPUT.workItemRefs` +(top-level, not nested in `prMetadata`) and normalises each entry to `{ id, type: "ado-work-item", url, raw }`. It never regex-scrapes the PR -description — work-item discovery is a Provider contract (ADR-0001 amendment). -Returns `[]` when `workItemRefs` is empty or absent. +description — work-item discovery is a Provider contract (ADR-0001 amendment, ADR-0010 amendment). + +Throws when `workItemRefs` is not an array (including `undefined` from an absent key). +The orchestrator's loud-Notice path (`review-pr.md` Step 1.5) is the only way to reach +`WORK_ITEMS = []` when `FETCHER_OUTPUT.workItemRefs` is absent — this function must not +silently swallow data-loss. + +Returns the normalised array (empty `[]` when the input array is empty — legitimate no-WI case). ## Registered agents @@ -41,10 +46,10 @@ Returns `[]` when `workItemRefs` is empty or absent. ## Adding fixtures -PR-metadata fixtures live in `fixtures/`. They mirror the enriched `prMetadata` -shape — the `az devops invoke --area git --resource pullrequests` response with -`workItemRefs` grafted on by the Fetcher (Step 1.5). Add a new fixture file and -reference it from `tests/provider.test.mjs` via the `fixture(name)` helper. The -`fixtures/ado-cli-inventory.json` file catalogues every `az devops invoke` call -the ADO Fetcher agent emits; the root `tests/ado-cli-smoke.test.mjs` asserts the -agent and the inventory stay in sync. +PR-metadata fixtures live in `fixtures/`. They mirror the `az devops invoke --area git --resource pullrequests` +ADO wire-format response. `workItemRefs` is a top-level field on the fixture object (not nested inside +`prMetadata`) — it is fetched separately from the `pullrequestworkitems` endpoint and emitted by the Fetcher +as a top-level `FETCHER_OUTPUT` field. Pass `fixture('...').workItemRefs` directly to `discoverWorkItems`. +Add a new fixture file and reference it from `tests/provider.test.mjs` via the `fixture(name)` helper. The +`fixtures/ado-cli-inventory.json` file catalogues every `az devops invoke` call the ADO Fetcher agent emits; +the root `tests/ado-cli-smoke.test.mjs` asserts the agent and the inventory stay in sync. diff --git a/apps/claude-code/unic-pr-review/providers/azure_devops/provider.mjs b/apps/claude-code/unic-pr-review/providers/azure_devops/provider.mjs index 92cc1811..0ed6f501 100644 --- a/apps/claude-code/unic-pr-review/providers/azure_devops/provider.mjs +++ b/apps/claude-code/unic-pr-review/providers/azure_devops/provider.mjs @@ -59,12 +59,17 @@ export function discoverWorkItems(workItemRefs) { if (!Array.isArray(workItemRefs)) { throw new Error(`Expected workItemRefs array, got ${describeType(workItemRefs)}`) } - return workItemRefs.map((ref) => ({ - id: String(ref.id), - type: 'ado-work-item', - url: ref.url, - raw: ref, - })) + return workItemRefs.map((ref, i) => { + if (ref.id == null) throw new Error(`workItemRefs[${i}].id is missing or null`) + if (typeof ref.url !== 'string') + throw new Error(`workItemRefs[${i}].url must be a string, got ${describeType(ref.url)}`) + return { + id: String(ref.id), + type: 'ado-work-item', + url: ref.url, + raw: ref, + } + }) } /** Default export — the full Provider bundle for `providers/index.mjs`. */ diff --git a/apps/claude-code/unic-pr-review/providers/azure_devops/tests/provider.test.mjs b/apps/claude-code/unic-pr-review/providers/azure_devops/tests/provider.test.mjs index d3e2ac67..98efa81c 100644 --- a/apps/claude-code/unic-pr-review/providers/azure_devops/tests/provider.test.mjs +++ b/apps/claude-code/unic-pr-review/providers/azure_devops/tests/provider.test.mjs @@ -94,6 +94,24 @@ describe('discoverWorkItems', () => { assert.equal(items[0].id, '42622') assert.equal(typeof items[0].id, 'string') }) + it('throws on ref with missing id', () => { + // @ts-expect-error — deliberately omitting id to assert per-ref guard fires + assert.throws( + () => discoverWorkItems([{ url: 'https://dev.azure.com/o/p/_apis/wit/workitems/1' }]), + /workItemRefs\[0\]\.id is missing or null/ + ) + }) + it('throws on ref with null id', () => { + // @ts-expect-error — deliberately passing null id to assert per-ref guard fires + assert.throws( + () => discoverWorkItems([{ id: null, url: 'https://dev.azure.com/o/p/_apis/wit/workitems/1' }]), + /workItemRefs\[0\]\.id is missing or null/ + ) + }) + it('throws on ref with non-string url', () => { + // @ts-expect-error — deliberately passing undefined url to assert per-ref guard fires + assert.throws(() => discoverWorkItems([{ id: '1' }]), /workItemRefs\[0\]\.url must be a string/) + }) }) // Fetcher contract: the ADO Fetcher (Step 1.5) fetches workItemRefs from pullrequestworkitems diff --git a/apps/claude-code/unic-pr-review/providers/tests/index-cli.test.mjs b/apps/claude-code/unic-pr-review/providers/tests/index-cli.test.mjs index e2a56e86..8dd7e776 100644 --- a/apps/claude-code/unic-pr-review/providers/tests/index-cli.test.mjs +++ b/apps/claude-code/unic-pr-review/providers/tests/index-cli.test.mjs @@ -47,6 +47,39 @@ describe('providers/index.mjs detect CLI', () => { }) }) +describe('providers/index.mjs discover-work-items CLI', () => { + it('exits 0 and emits normalised WI JSON for a valid refs array', () => { + const refs = JSON.stringify([{ id: '101', url: 'https://dev.azure.com/org/proj/_apis/wit/workitems/101' }]) + const out = execFileSync( + 'node', + [indexMjs, 'discover-work-items', 'https://dev.azure.com/o/p/_git/r/pullrequest/1'], + { input: refs, encoding: 'utf8' } + ) + const parsed = JSON.parse(out) + assert.equal(parsed.length, 1) + assert.equal(parsed[0].id, '101') + assert.equal(parsed[0].type, 'ado-work-item') + }) + + it('exits 1 when stdin contains a non-array (data-loss guard)', () => { + assert.throws( + () => + execFileSync('node', [indexMjs, 'discover-work-items', 'https://dev.azure.com/o/p/_git/r/pullrequest/1'], { + input: '{"workItemRefs":[]}', + encoding: 'utf8', + }), + (/** @type {any} */ err) => err.status === 1 + ) + }) + + it('exits 1 when URL argument is missing', () => { + assert.throws( + () => execFileSync('node', [indexMjs, 'discover-work-items'], { encoding: 'utf8' }), + (/** @type {any} */ err) => err.status === 1 + ) + }) +}) + describe('providers/index.mjs parse-url CLI', () => { it('exits 0 and emits parsed PR ref for a dev.azure.com URL', () => { const out = execFileSync( diff --git a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs index 68774d7a..9f5a761a 100644 --- a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs +++ b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs @@ -73,7 +73,7 @@ describe('Work Item discovery contract', () => { describe('ado-fetcher.md Step 6: workItemRefs is a top-level field in the output schema', () => { it('Step 6 JSON example has workItemRefs at top-level indent (2 spaces)', () => { const jsonBlocks = extractJsonBlocks(adoFetcher) - // The Step 6 output schema is the only json block in the file + // Identified by its two distinguishing fields; other json blocks in the file lack both const step6Block = jsonBlocks.find((b) => b.includes('"prMetadata"') && b.includes('"mode"')) assert.ok(step6Block, 'ado-fetcher.md must contain a Step 6 JSON output schema block') @@ -91,7 +91,11 @@ describe('Work Item discovery contract', () => { assert.ok(step6Block, 'ado-fetcher.md must contain a Step 6 JSON output schema block') const lines = step6Block.split('\n') - // Find the prMetadata block range and check workItemRefs does not appear inside it + // Find the prMetadata block range and check workItemRefs does not appear inside it. + // Assumption: the Step 6 JSON is multi-line formatted. A single-line prMetadata + // block (e.g. `"prMetadata": { "pullRequestId": 42 }`) would exit insideMetadata + // before the violation check runs — acceptable because the ado-fetcher.md schema + // is always formatted with one key per line. let insideMetadata = false let depth = 0 const nestedViolations = /** @type {string[]} */ ([]) From 7bde177ae6f15655c0687aef0bbb5dd2d2ecd8eb Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Wed, 17 Jun 2026 10:26:27 +0200 Subject: [PATCH 6/8] simplify: hoist shared step6Block lookup in contract test Both ado-fetcher describe tests computed extractJsonBlocks + .find identically; extract to a shared const at the describe scope. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/work-item-discovery-contract.test.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs index 9f5a761a..e419083b 100644 --- a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs +++ b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs @@ -71,10 +71,12 @@ describe('Work Item discovery contract', () => { }) describe('ado-fetcher.md Step 6: workItemRefs is a top-level field in the output schema', () => { + // Identified by its two distinguishing fields; other json blocks in the file lack both + const step6Block = extractJsonBlocks(adoFetcher).find( + (b) => b.includes('"prMetadata"') && b.includes('"mode"') + ) + it('Step 6 JSON example has workItemRefs at top-level indent (2 spaces)', () => { - const jsonBlocks = extractJsonBlocks(adoFetcher) - // Identified by its two distinguishing fields; other json blocks in the file lack both - const step6Block = jsonBlocks.find((b) => b.includes('"prMetadata"') && b.includes('"mode"')) assert.ok(step6Block, 'ado-fetcher.md must contain a Step 6 JSON output schema block') const lines = step6Block.split('\n') @@ -86,8 +88,6 @@ describe('Work Item discovery contract', () => { }) it('Step 6 JSON example does not have workItemRefs nested inside prMetadata (4+ spaces)', () => { - const jsonBlocks = extractJsonBlocks(adoFetcher) - const step6Block = jsonBlocks.find((b) => b.includes('"prMetadata"') && b.includes('"mode"')) assert.ok(step6Block, 'ado-fetcher.md must contain a Step 6 JSON output schema block') const lines = step6Block.split('\n') From 7199aefdbd42e62bc81ea22ff121f84719effbed Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Wed, 17 Jun 2026 10:30:22 +0200 Subject: [PATCH 7/8] fix(unic-pr-review): fix Biome format violation in contract test The simplify commit wrapped the find() call across multiple lines; Biome's formatter requires it on one line at this width. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/work-item-discovery-contract.test.mjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs index e419083b..6ea3b84f 100644 --- a/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs +++ b/apps/claude-code/unic-pr-review/tests/work-item-discovery-contract.test.mjs @@ -72,9 +72,7 @@ describe('Work Item discovery contract', () => { describe('ado-fetcher.md Step 6: workItemRefs is a top-level field in the output schema', () => { // Identified by its two distinguishing fields; other json blocks in the file lack both - const step6Block = extractJsonBlocks(adoFetcher).find( - (b) => b.includes('"prMetadata"') && b.includes('"mode"') - ) + const step6Block = extractJsonBlocks(adoFetcher).find((b) => b.includes('"prMetadata"') && b.includes('"mode"')) it('Step 6 JSON example has workItemRefs at top-level indent (2 spaces)', () => { assert.ok(step6Block, 'ado-fetcher.md must contain a Step 6 JSON output schema block') From 7ba0c1e18bbf624b58cd5fea51269208d213d595 Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Wed, 17 Jun 2026 11:05:59 +0200 Subject: [PATCH 8/8] fix(unic-pr-review): render lostInHandoff data-loss notice in Review Summary review-pr.md Step 1.5 sets NOTICES_CONTEXT.lostInHandoff on the work-item data-loss path, but renderNotices() had no branch for it, so the durable Summary Notice was silently dropped (the early terminal print still fired). Add the render branch + typedef + unit tests so the Notice actually renders at the top of the Review Summary and posts to the PR. Completes AC#5 of #252. Co-Authored-By: Claude Opus 4.8 --- apps/claude-code/unic-pr-review/CHANGELOG.md | 1 + .../unic-pr-review/scripts/lib/notices.mjs | 10 ++++++++++ .../unic-pr-review/tests/notices.test.mjs | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/apps/claude-code/unic-pr-review/CHANGELOG.md b/apps/claude-code/unic-pr-review/CHANGELOG.md index 51cb57a0..2066eb1b 100644 --- a/apps/claude-code/unic-pr-review/CHANGELOG.md +++ b/apps/claude-code/unic-pr-review/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Linked Work Items silently dropped on large PRs: `discoverWorkItems` now takes the refs array directly (hoisted to top-level `FETCHER_OUTPUT.workItemRefs`), throws on absent/non-array input, and `review-pr.md` Step 1.5 surfaces a loud Notice when the key is absent (data-loss path) instead of silently treating it as a no-WI PR (#252) +- `renderNotices` now emits the data-loss Summary Notice for `lostInHandoff`, so the durable notice set by `review-pr.md` Step 1.5 actually renders at the top of the Review Summary (and posts to the PR) rather than being silently dropped (#252) ## [2.1.11] — 2026-06-16 diff --git a/apps/claude-code/unic-pr-review/scripts/lib/notices.mjs b/apps/claude-code/unic-pr-review/scripts/lib/notices.mjs index 2690105a..ab2cc4e8 100644 --- a/apps/claude-code/unic-pr-review/scripts/lib/notices.mjs +++ b/apps/claude-code/unic-pr-review/scripts/lib/notices.mjs @@ -35,6 +35,9 @@ * unaddressed across two or more Iterations, ordered by sinceIteration ascending (US 27) * @property {boolean} [unassessedIntentCheck] - true when the Assessor was spawned but * applied zero verdicts (assessed missing, non-array, or all-zero applied count) + * @property {boolean} [lostInHandoff] - true when the ADO Fetcher did not deliver + * workItemRefs (key absent on FETCHER_OUTPUT), so Work Item discovery was skipped — a data + * gap, not a PR with no Work Items linked (ADR-0004 lost-in-handoff state) * @property {boolean} [diffUnavailable] - true when line-level diff could not be fetched; * diff-driven aspect agents were not run and an empty Findings list does not mean clean * @property {HumanThreadEntry[]} [humanThreadsNotice] - unresolved Human Threads that no @@ -74,6 +77,13 @@ export function renderNotices(ctx) { ) } + if (ctx.lostInHandoff) { + lines.push( + '> **Notice:** Work Item data was not delivered by the Fetcher (a data gap, not a PR with no ' + + 'Work Items linked), so the Intent Check was skipped. Re-run the review to retry intent gathering.' + ) + } + if (ctx.diffUnavailable) { lines.push( '> **Notice:** Line-level diff was unavailable in this preview, so diff-driven Review Aspect agents did not run. ' + diff --git a/apps/claude-code/unic-pr-review/tests/notices.test.mjs b/apps/claude-code/unic-pr-review/tests/notices.test.mjs index e912f730..e68d723b 100644 --- a/apps/claude-code/unic-pr-review/tests/notices.test.mjs +++ b/apps/claude-code/unic-pr-review/tests/notices.test.mjs @@ -106,6 +106,26 @@ describe('renderNotices', () => { assert.equal(renderNotices({ unassessedIntentCheck: false }), '') }) + it('renders the lost-in-handoff notice when lostInHandoff is true', () => { + const out = renderNotices({ lostInHandoff: true }) + assert.ok(out.includes('> **Notice:**')) + assert.ok(out.includes('data gap')) + assert.ok(out.includes('Intent Check was skipped')) + }) + + it('returns empty string when lostInHandoff is false', () => { + assert.equal(renderNotices({ lostInHandoff: false }), '') + }) + + it('renders lost-in-handoff notice after unassessedIntentCheck when both are set', () => { + const out = renderNotices({ unassessedIntentCheck: true, lostInHandoff: true }) + const unassessedIdx = out.indexOf('Intent Check block') + const handoffIdx = out.indexOf('data gap') + assert.ok(unassessedIdx >= 0, 'Missing unassessed notice') + assert.ok(handoffIdx >= 0, 'Missing lost-in-handoff notice') + assert.ok(unassessedIdx < handoffIdx, 'unassessed notice must precede lost-in-handoff notice') + }) + it('renders unassessed-intent-check notice after fallbackToFirstReview when both are set', () => { const out = renderNotices({ fallbackToFirstReview: true, unassessedIntentCheck: true }) const fallbackIdx = out.indexOf('force-push detected')