@nanobpm/nano-workforce 0.187.5 → 0.187.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.187.6](https://github.com/nanobpm/nano-workforce/compare/v0.187.5...v0.187.6) (2026-09-16)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **convergence:** re-solicit on a stale Copilot review instead of re-escalating ([#799](https://github.com/nanobpm/nano-workforce/issues/799)) ([#803](https://github.com/nanobpm/nano-workforce/issues/803)) ([58a99b6](https://github.com/nanobpm/nano-workforce/commit/58a99b6024af6cef37f724f9d50cf6151a6f061c)), closes [#789](https://github.com/nanobpm/nano-workforce/issues/789)
6
+
1
7
  ## [0.187.5](https://github.com/nanobpm/nano-workforce/compare/v0.187.4...v0.187.5) (2026-09-15)
2
8
 
3
9
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -135,9 +135,33 @@ MAX_ROUNDS forces an escalation ("not converged after N rounds") so a human
135
135
  decides rather than looping forever. The guard sits *after* `check-progress`
136
136
  (not before), so a husk auto-retry — which does not consume a round — bypasses
137
137
  the cap and is re-tried onto a healthy worker even on the final configured round.
138
+ The **stale-review re-solicitation** path (below) likewise bypasses the cap: its
139
+ `f_guardMax` arm is gated on `round ≥ maxRounds and reviewStale != true`, so a
140
+ stale review received on the final configured round re-solicits a fresh review
141
+ instead of escalating (the review-wait timeout remains the backstop).
138
142
  ```
139
143
 
140
144
  Notes:
145
+ - **Convergence comment-gate + stale-review re-solicitation (issue #799).** The
146
+ agent's self-reported `converged` does not finalize directly: it first runs the
147
+ deterministic `pr.converge-gate` (`check-converge` → `gw-converge-gate`). That
148
+ gate **blocks** convergence (`convergeBlocked = true` → escalate "unaddressed
149
+ comments") while any review thread is unresolved or any suppressed advisory
150
+ lacks a resolved `nano-ack:` thread; otherwise it proceeds to the scope
151
+ classifier and finalizes. A third arm handles a **stale review** — one whose
152
+ `commit_id` predates the PR's current HEAD (its advisories describe code the
153
+ head has moved past, e.g. an advisory already fixed in a later commit). Rather
154
+ than block/escalate on the obsolete body, the gate signals `reviewStale = true`
155
+ and `f_convergeStale` re-enters `persist-round` → `check-progress` (which parks
156
+ the PR in `waiting_review`, the single writer), so the poller re-solicits a
157
+ fresh review of the current HEAD. The head is read via the shared
158
+ branch-ref-preferring reader (`makeDefaultReadHead`, atomic with the push, #786)
159
+ in BOTH the gate and the poller so they agree on the current head. `reviewStale`
160
+ is written only by the gate and cleared on BOTH loop re-entry paths — by the
161
+ `wait-review` catch when a fresh review lands, and by `record-answer` when a
162
+ human resumes after the review-stall timer — so the marker cannot leak into a
163
+ later round. Because a stale review is not a failure to converge, this path
164
+ bypasses the round cap (see the Guard above).
141
165
  - On `addressed`, the loop parks at an **event-based gateway** that races the
142
166
  canonical `readiness-ready` wait-gate message (ADR 0001 §2; correlated by the
143
167
  poller when a fresh review lands)
@@ -18,6 +18,7 @@ import {
18
18
  parseAckedAdvisories,
19
19
  parseReviewThreadsPage,
20
20
  parseSuppressedAdvisories,
21
+ pickLatestCopilotReview,
21
22
  pickLatestCopilotReviewBody,
22
23
  type ReviewThread,
23
24
  } from "./github.ts";
@@ -432,12 +433,63 @@ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was
432
433
  );
433
434
  });
434
435
 
436
+ // The COMMIT-ID-carrying picker (`pickLatestCopilotReview`) is the ONLY production path that carries
437
+ // GitHub's `commit_id` into the stale-review guard (#799); the worker tests inject `{ commitId }`
438
+ // directly, so without these the picker's commit_id selection could regress (disabling stale
439
+ // detection) while every other test stays green. These lock: the NEWEST Copilot review's commit_id
440
+ // (and body) is returned; a no-Copilot-review read is verified `{ body: "", commitId: null }`; a
441
+ // truncated read fails closed to `null`.
442
+ test("pickLatestCopilotReview: returns the NEWEST Copilot review's body AND commit_id (oldest\u2192newest)", () => {
443
+ const picked = pickLatestCopilotReview(
444
+ [
445
+ { user: { login: "human" }, body: "human review", commit_id: "humansha" },
446
+ { user: { login: "Copilot" }, body: "old copilot review", commit_id: "oldsha" },
447
+ { user: { login: "Copilot" }, body: "newest copilot review", commit_id: "newsha" },
448
+ ],
449
+ false,
450
+ );
451
+ assertEquals(picked, { body: "newest copilot review", commitId: "newsha" });
452
+ });
453
+
454
+ test('pickLatestCopilotReview: a complete read with NO Copilot review is verified { body: "", commitId: null }', () => {
455
+ assertEquals(pickLatestCopilotReview([{ user: { login: "human" }, body: "hi", commit_id: "x" }], false), {
456
+ body: "",
457
+ commitId: null,
458
+ });
459
+ assertEquals(pickLatestCopilotReview([], false), { body: "", commitId: null });
460
+ });
461
+
462
+ test("pickLatestCopilotReview: a Copilot review missing commit_id yields commitId null (not stale)", () => {
463
+ // A review with no commit_id must not fabricate a stale verdict: `isReviewStale` fails safe on a
464
+ // null review commit_id, and this picker must surface that null rather than an empty string.
465
+ assertEquals(pickLatestCopilotReview([{ user: { login: "Copilot" }, body: "b" }], false), {
466
+ body: "b",
467
+ commitId: null,
468
+ });
469
+ });
470
+
471
+ test("pickLatestCopilotReview: FAILS CLOSED (null) when the reviews read was TRUNCATED", () => {
472
+ assertEquals(pickLatestCopilotReview([{ user: { login: "Copilot" }, body: "possibly stale", commit_id: "s" }], true), null);
473
+ });
474
+
435
475
  async function makeUnderTest(deps: {
436
476
  readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
437
477
  readReviewBody: (repo: string, n: number) => Promise<string | null>;
478
+ // The commit SHA the latest Copilot review was submitted against, and the PR's current HEAD SHA
479
+ // (issue #799). Absent ⇒ both null ⇒ never stale (the pre-#799 behaviour every existing test
480
+ // relies on). A stale-review test supplies a `reviewCommitId` that differs from `headSha`.
481
+ reviewCommitId?: string | null;
482
+ headSha?: string | null;
438
483
  }) {
439
484
  const { makeHandler } = await import("../workers/converge-gate/worker.ts");
440
- return makeHandler(deps);
485
+ return makeHandler({
486
+ readThreads: deps.readThreads,
487
+ readReview: async (repo, n) => {
488
+ const body = await deps.readReviewBody(repo, n);
489
+ return body === null ? null : { body, commitId: deps.reviewCommitId ?? null };
490
+ },
491
+ readHeadSha: async () => deps.headSha ?? null,
492
+ });
441
493
  }
442
494
 
443
495
  test("converge-gate: a clean PR is allowed to converge", async () => {
@@ -446,7 +498,7 @@ test("converge-gate: a clean PR is allowed to converge", async () => {
446
498
  readReviewBody: async () => "## Overview\nNo suppressed block.",
447
499
  });
448
500
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
449
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
501
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
450
502
  });
451
503
 
452
504
  test("converge-gate: an unresolved thread blocks convergence", async () => {
@@ -488,7 +540,7 @@ test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed",
488
540
  readReviewBody: async () => SAMPLE_REVIEW_BODY,
489
541
  });
490
542
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
491
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
543
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
492
544
  });
493
545
 
494
546
  test("converge-gate: FAILS CLOSED when the threads read returns null (no transport)", async () => {
@@ -544,7 +596,7 @@ test("converge-gate: a non-string prKey does not throw — resolves from repo/pr
544
596
  readReviewBody: async () => "",
545
597
  });
546
598
  const out = await handler({ variables: { repo: "o/r", prNumber: 1 } } as any, {} as any);
547
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
599
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
548
600
  });
549
601
 
550
602
  test("converge-gate: FAILS CLOSED (no throw) when prKey is non-string and repo/prNumber are absent", async () => {
@@ -570,6 +622,57 @@ test("converge-gate: resolves repo/prNumber from the prKey when the vars are abs
570
622
  assertEquals(seen, ["o/r", 7]);
571
623
  });
572
624
 
625
+ // ── Stale-review guard (issue #799) ──────────────────────────────────────────
626
+ // FM2: when the PR HEAD has advanced past the commit the latest Copilot review was submitted
627
+ // against, that review is stale — its suppressed advisories may already be fixed in code. The gate
628
+ // must NOT block/escalate on it; it must signal `reviewStale` so the loop re-solicits a fresh
629
+ // review of the current HEAD. FM1 (an applied-but-unacked advisory re-escalating forever) is
630
+ // structurally cured by this: once the fixing commit lands, the review that still lists the
631
+ // advisory is stale, so the gate re-solicits instead of re-blocking.
632
+
633
+ test("converge-gate #799: a STALE review (commit_id predates HEAD) does not block — signals reviewStale", async () => {
634
+ // The review still lists an unacked suppressed advisory (would block if evaluated), but it was
635
+ // submitted against an OLD commit — the agent has since pushed a fix. The gate must re-solicit,
636
+ // not escalate.
637
+ const handler = await makeUnderTest({
638
+ readThreads: async () => [],
639
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
640
+ reviewCommitId: "oldsha1111111111111111111111111111111111",
641
+ headSha: "newsha2222222222222222222222222222222222",
642
+ });
643
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
644
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: true });
645
+ });
646
+
647
+ test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advisory (control)", async () => {
648
+ // Same unacked advisory, but the review's commit_id MATCHES HEAD — it is fresh, so the ordinary
649
+ // gate runs and blocks. Proves the stale guard does not swallow a genuine block.
650
+ const handler = await makeUnderTest({
651
+ readThreads: async () => [],
652
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
653
+ reviewCommitId: "samesha33333333333333333333333333333333",
654
+ headSha: "samesha33333333333333333333333333333333",
655
+ });
656
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
657
+ assertEquals(out.convergeBlocked, true);
658
+ assertEquals(out.reviewStale, false);
659
+ assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
660
+ });
661
+
662
+ test("converge-gate #799: an unknown review commit_id or unreadable HEAD is NOT stale (evaluates normally)", async () => {
663
+ // Fail-safe: without both SHAs we cannot prove staleness, so the gate must fall through to the
664
+ // ordinary evaluation (here: block on the unacked advisory), never fabricate a re-solicit loop.
665
+ const handler = await makeUnderTest({
666
+ readThreads: async () => [],
667
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
668
+ reviewCommitId: null,
669
+ headSha: "newsha2222222222222222222222222222222222",
670
+ });
671
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
672
+ assertEquals(out.convergeBlocked, true);
673
+ assertEquals(out.reviewStale, false);
674
+ });
675
+
573
676
  // ── Structural guard over the committed BPMN (no engine) ─────────────────────
574
677
 
575
678
  const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
@@ -606,6 +709,60 @@ test("gw-converge-gate blocks on an explicit convergeBlocked = true condition",
606
709
  assertStringIncludes(f, "convergeBlocked = true");
607
710
  });
608
711
 
712
+ test("gw-converge-gate routes a STALE review back to persist-round (re-solicit), not to escalation (#799)", () => {
713
+ const f = flowElement("f_convergeStale");
714
+ assert(f, "f_convergeStale flow missing");
715
+ assertStringIncludes(f, 'sourceRef="gw-converge-gate"');
716
+ // A stale review re-enters the round loop via persist-round → check-progress, which parks
717
+ // waiting_review (the single writer) and the poller re-solicits a fresh review.
718
+ assertStringIncludes(f, 'targetRef="persist-round"');
719
+ assertStringIncludes(f, "reviewStale = true");
720
+ // persist-round must accept the stale re-entry as an incoming.
721
+ const pr = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-round"[^>]*>.*?<\/bpmn:serviceTask>/);
722
+ assert(pr, "persist-round task missing");
723
+ assertStringIncludes(pr[0], "<bpmn:incoming>f_convergeStale</bpmn:incoming>");
724
+ // The gate's output envelope must declare reviewStale for the FEEL condition to read it.
725
+ assertStringIncludes(flat, 'id="PrConvergeGateOut"');
726
+ assert(
727
+ /<nano:shape\b[^>]*\bid="PrConvergeGateOut"[^>]*>.*?name="reviewStale".*?<\/nano:shape>/.test(flat),
728
+ "PrConvergeGateOut must declare reviewStale",
729
+ );
730
+ });
731
+
732
+ test("the round cap does NOT escalate a stale-retry round — f_guardMax is gated on reviewStale != true (#799)", () => {
733
+ // A stale review re-enters persist-round → check-progress → gw-guard. On the FINAL configured round
734
+ // the cap would escalate a human instead of soliciting a fresh review — but a stale review is not a
735
+ // failure to converge, it is a review of obsolete code. The round cap must therefore bypass the
736
+ // stale-retry path (the review-wait timeout remains the backstop against an indefinitely stalled
737
+ // re-solicitation).
738
+ const f = flowElement("f_guardMax");
739
+ assert(f, "f_guardMax flow missing");
740
+ assertStringIncludes(f, "maxRounds and reviewStale != true");
741
+ });
742
+
743
+ test("wait-review clears reviewStale when a fresh review lands, so the marker can't leak into a later round (#799)", () => {
744
+ // reviewStale is written ONLY by the converge-gate; once a fresh review arrives it is no longer
745
+ // known-stale, so wait-review resets it to false alongside the round increment. Without this reset
746
+ // a stale marker would persist and disable the round cap for a subsequent addressed round.
747
+ const wr = flat.match(/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review"[^>]*>.*?<\/bpmn:intermediateCatchEvent>/);
748
+ assert(wr, "wait-review catch event missing");
749
+ assertStringIncludes(wr[0], '<zeebe:output source="=round + 1" target="round" />');
750
+ assertStringIncludes(wr[0], '<zeebe:output source="=false" target="reviewStale" />');
751
+ });
752
+
753
+ test("record-answer clears reviewStale so a human-resumed round after a review-stall timeout is re-capped (#799)", () => {
754
+ // reviewStale is cleared on the wait-review MESSAGE arm, but the review-wait TIMER arm bypasses it:
755
+ // wait-review-timeout → persist-review-stalled → wait-answer → record-answer → capture-head. A
756
+ // stale-retry round that times out and is resumed by a human therefore re-enters the loop still
757
+ // carrying reviewStale = true, which — via `f_guardMax`'s `reviewStale != true` guard — would keep
758
+ // the round cap disabled for that (and every subsequent) human-directed addressed round. Once a
759
+ // human is answering escalations the automated stale-retry is over, so record-answer must reset the
760
+ // marker; a genuinely-still-stale next review re-sets it at the converge-gate.
761
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-answer"[^>]*>.*?<\/bpmn:serviceTask>/);
762
+ assert(task, "record-answer task missing");
763
+ assertStringIncludes(task[0], '<zeebe:output source="=false" target="reviewStale" />');
764
+ });
765
+
609
766
  test("gw-converge-gate default arm routes to the scope classifier (not straight to finalize)", () => {
610
767
  const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
611
768
  assert(gw, "gw-converge-gate gateway missing");
@@ -0,0 +1,60 @@
1
+ // The PR "current HEAD" reader shared by every step that must gate on the head the push landed —
2
+ // the capture-head / progress-check steps, the converge-gate's stale-review guard, AND the poller's
3
+ // stale-review re-solicitation (#799). It lives in this neutral module (not in a worker) so the
4
+ // poller in `app/service.ts` can reuse the EXACT reader the workers use without importing a worker
5
+ // (which would form a `service.ts ↔ worker` cycle) and without a second, drift-prone copy of the
6
+ // branch-ref-over-`head.sha` preference (#786).
7
+ import type { fetchBranchHead, fetchPrHead } from "./github.ts";
8
+
9
+ // Reads a PR's current head SHA. Injectable so unit tests never touch git/network; the default
10
+ // binds the real GitHub reader (the shared gh | token transport) and swallows any failure to
11
+ // `null` so the guard fails OPEN. It reads the BRANCH ref (`git/ref/heads/<branch>`) — updated
12
+ // atomically with the push — in preference to the PR object's asynchronously-denormalized
13
+ // `head.sha`, so a lagging PR projection can never fabricate a stale-but-valid no-advance
14
+ // escalation (#786). Once a head ref is known this trusts ONLY its atomic ref: a failed/absent
15
+ // ref read fails OPEN (`null`), never falling back to `head.sha`. The PR head is used only when
16
+ // the PR carries NO head ref at all.
17
+ // The optional `token` lets a caller that already holds a per-call GitHub credential (e.g. the
18
+ // poller in `app/service.ts`, which is handed a `token` for its review fetch) read the head with the
19
+ // SAME credential rather than silently diverging to `process.env.GITHUB_TOKEN`. Omitting it keeps
20
+ // the env-token default, so the worker callers (capture-head / progress-check / converge-gate) are
21
+ // unchanged. Binding both reads to one credential closes the drift where a caller supplying a token
22
+ // without that env var would get a `null` head (→ `isReviewStale` fails open, advancing a stale
23
+ // review); see #799.
24
+ export type HeadReader = (repo: string, prNumber: number, token?: string) => Promise<string | null>;
25
+
26
+ /** Build the real head reader from the GitHub fetchers (injected so tests can stub them). Prefers
27
+ * the branch ref (atomic with the push) over the PR object's denormalized `head.sha` (#786); fails
28
+ * OPEN (`null`) on any unreadable state so a transport hiccup never fabricates a stale verdict. The
29
+ * single canonical implementation — capture-head, progress-check, converge-gate, and the poller all
30
+ * bind this so they gate on the same head. */
31
+ export function makeDefaultReadHead(deps: {
32
+ fetchPrHead: typeof fetchPrHead;
33
+ fetchBranchHead: typeof fetchBranchHead;
34
+ }): HeadReader {
35
+ return async (repo, prNumber, token) => {
36
+ const tok = token ?? process.env.GITHUB_TOKEN ?? "";
37
+ const pr = await deps.fetchPrHead(repo, prNumber, tok).catch(() => null);
38
+ if (!pr) return null;
39
+ // Prefer the branch ref (atomic with the push) over the PR object's denormalized head.sha (#786).
40
+ // Once the head branch is known, trust ONLY its atomic ref: a failed/absent ref read fails OPEN
41
+ // (`null`) rather than falling back to the PR object's asynchronously-denormalized head.sha, which
42
+ // can still report a stale-but-valid SHA after a push and fabricate a no-advance escalation — the
43
+ // very projection this branch-ref read exists to avoid. The ref is read in the repository the
44
+ // head branch actually lives in (the fork for a cross-repo PR — see below), so a fork PR fails
45
+ // open safely instead of comparing an unrelated base-repo SHA. Fall back to the PR head only when
46
+ // there is NO head ref.
47
+ if (pr.headRef) {
48
+ // Resolve the head ref in the repository the head branch actually lives in — the FORK for a
49
+ // cross-repo PR (`pr.headRepo`), else the base `repo`. Querying the base repo unconditionally
50
+ // would, for a fork PR whose head branch shares a name with a base-repo branch, read the
51
+ // unrelated base-branch SHA and fabricate progress/no-progress (#786). When the head repo
52
+ // cannot be resolved (a deleted fork ⇒ `headRepo:null`) fail OPEN to `null` rather than fall
53
+ // back to the base repo and risk that collision.
54
+ const headRepo = pr.headRepo;
55
+ if (!headRepo) return null;
56
+ return await deps.fetchBranchHead(headRepo, pr.headRef, tok).catch(() => null);
57
+ }
58
+ return pr.headSha ?? null;
59
+ };
60
+ }
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
6
+ import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
7
7
  import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
8
8
 
9
9
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
@@ -60,6 +60,70 @@ test("fetchPrFiles: throws when the cap genuinely truncates (full last page + ne
60
60
  );
61
61
  });
62
62
 
63
+ // ── fetchPrReviews token-transport paging (issue #799) ──────────────────────────────────────────
64
+ // The poller picks the NEWEST review by id, so `fetchPrReviews` must page the FULL (oldest→newest)
65
+ // list — reading only the first `per_page=100` page would surface the oldest 100 and miss the
66
+ // genuinely newest review on a >100-review convergence loop. The token transport mirrors
67
+ // `fetchPrFiles`: it fails CLOSED (throws) when the paging cap genuinely truncates rather than
68
+ // returning a partial list the poller would treat as complete.
69
+
70
+ // A fake `fetch` that serves `pages` of review batches; each page N (1-based) returns `pages[N-1]`
71
+ // reviews (with ascending ids), setting `Link: rel="next"` whenever a later page exists.
72
+ function stubReviewFetch(pages: number[]) {
73
+ return (url: string | URL | Request): Promise<Response> => {
74
+ const u = new URL(String(url));
75
+ const page = Number(u.searchParams.get("page") ?? "1");
76
+ const count = pages[page - 1] ?? 0;
77
+ const start = pages.slice(0, page - 1).reduce((a, b) => a + b, 0);
78
+ const body = Array.from({ length: count }, (_, i) => ({
79
+ id: start + i + 1,
80
+ state: "COMMENTED",
81
+ submitted_at: "2026-01-01T00:00:00Z",
82
+ }));
83
+ const headers = new Headers();
84
+ if (page < pages.length) {
85
+ headers.set("link", `<https://api.github.com/next?page=${page + 1}>; rel="next"`);
86
+ }
87
+ return Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers }));
88
+ };
89
+ }
90
+
91
+ async function withReviewTransport<T>(pages: number[], fn: () => Promise<T>): Promise<T> {
92
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
93
+ const prevFetch = globalThis.fetch;
94
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
95
+ globalThis.fetch = stubReviewFetch(pages) as typeof fetch;
96
+ try {
97
+ return await fn();
98
+ } finally {
99
+ globalThis.fetch = prevFetch;
100
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
101
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
102
+ }
103
+ }
104
+
105
+ test("fetchPrReviews: pages the full list beyond the first 100 (newest review is seen)", async () => {
106
+ const reviews = await withReviewTransport([100, 37], () => fetchPrReviews("o/r", 1, "tok"));
107
+ assertEquals(reviews?.length, 137);
108
+ // The genuinely newest review (highest id) is on the SECOND page — it must be present.
109
+ assertEquals(reviews?.[reviews.length - 1]?.id, 137);
110
+ });
111
+
112
+ test("fetchPrReviews: no token → null (idle, not a throw)", async () => {
113
+ const reviews = await withReviewTransport([100], () => fetchPrReviews("o/r", 2, ""));
114
+ assertEquals(reviews, null);
115
+ });
116
+
117
+ test("fetchPrReviews: throws when the cap genuinely truncates (full last page + next)", async () => {
118
+ // MAX_PAGES=20 full pages, and the 20th still advertises `rel="next"` → fail closed.
119
+ const capped = Array.from({ length: 21 }, () => 100);
120
+ await assertRejects(
121
+ () => withReviewTransport(capped, () => fetchPrReviews("o/r", 3, "tok")),
122
+ Error,
123
+ "truncated",
124
+ );
125
+ });
126
+
63
127
  // ── ensureBaseBranch (ADR 0003 rule 2) ──────────────────────────────────────
64
128
  // Force the token transport and stub `globalThis.fetch` so the create-if-missing primitive is
65
129
  // exercised end-to-end without touching the network: git-ref lookups, default-branch resolution,
package/app/github.ts CHANGED
@@ -23,6 +23,10 @@ export interface GhReview {
23
23
  id: number;
24
24
  state: string;
25
25
  submitted_at?: string;
26
+ /** The commit SHA the review was submitted against (GitHub's `commit_id`). Used to detect a
27
+ * review that predates the PR's current HEAD — a STALE review whose advisories are about code the
28
+ * head has since moved past (issue #799). Absent on data GitHub did not carry a `commit_id` for. */
29
+ commit_id?: string | null;
26
30
  }
27
31
 
28
32
  export type GithubTransport = "gh" | "token" | "auto";
@@ -61,7 +65,12 @@ function isGhAvailable(): Promise<boolean> {
61
65
  }
62
66
 
63
67
  /** Fetch the reviews for one PR via the configured transport. Throws on transport failure so
64
- * the caller can log-and-continue; returns `null` when no transport is usable (idle). */
68
+ * the caller can log-and-continue; returns `null` when no transport is usable (idle). Pages the
69
+ * FULL (oldest→newest) reviews list — the poller picks the newest fresh review by id, so reading
70
+ * only the first `per_page=100` page would, on a >100-review convergence loop, surface the OLDEST
71
+ * 100 and miss the genuinely newest review (repeatedly nudging while a current-head review sits on a
72
+ * later page, or classifying an old review as stale). This mirrors {@link fetchLatestCopilotReview}'s
73
+ * paging so both readers agree on which review is newest. */
65
74
  export async function fetchPrReviews(
66
75
  repo: string,
67
76
  number: number | string,
@@ -71,17 +80,43 @@ export async function fetchPrReviews(
71
80
  const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
72
81
  const path = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
73
82
  if (useGh) {
74
- const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
83
+ // `--paginate --slurp` walks EVERY page of the (oldest→newest) reviews array, so a >100-review
84
+ // convergence loop still surfaces the genuinely newest review rather than the oldest 100. Plain
85
+ // `--paginate` concatenates one JSON array PER PAGE (multiple documents) which `JSON.parse`
86
+ // cannot read; `--slurp` wraps the pages in an outer array we flatten one level (mirrors
87
+ // {@link githubReleasesCommand}/{@link parseReleases}).
88
+ const out = await runGh([
89
+ "api", "--paginate", "--slurp", path, "-H", "Accept: application/vnd.github+json",
90
+ ]);
75
91
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
76
- return JSON.parse(out) as GhReview[];
92
+ return (JSON.parse(out) as GhReview[][]).flat();
77
93
  }
78
94
  if (!token) return null; // token mode with no token → poller idles
79
- const r = await fetch(`https://api.github.com/${path}`, {
80
- headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
81
- });
82
- if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
83
- // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
84
- return (await r.json()) as GhReview[];
95
+ // Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and
96
+ // a genuinely deeper history we can't reach is unverifiable → fail CLOSED (throw) rather than
97
+ // return a partial list the poller would treat as complete (selecting an older review, re-nudging).
98
+ const reviews: GhReview[] = [];
99
+ const MAX_PAGES = 20;
100
+ for (let page = 1; page <= MAX_PAGES; page++) {
101
+ const r = await fetch(`https://api.github.com/${path}&page=${page}`, {
102
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
103
+ });
104
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
105
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
106
+ const batch = (await r.json()) as GhReview[];
107
+ reviews.push(...batch);
108
+ // A short final page means we've read every review — the list is complete.
109
+ if (batch.length < 100) return reviews;
110
+ // A full page on the last allowed page is only truncated if GitHub says there's more; trust the
111
+ // `Link` header's `rel="next"` (mirrors {@link fetchPrFiles}) so an exact multiple of 100 isn't a
112
+ // false positive, and throw when the cap genuinely truncates rather than under-reading history.
113
+ if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) {
114
+ throw new Error(
115
+ `github pr reviews truncated: ${repo}#${number} exceeds ${MAX_PAGES * 100}-review paging cap`,
116
+ );
117
+ }
118
+ }
119
+ return reviews;
85
120
  }
86
121
 
87
122
  // ── Review-comment convergence gate (don't converge with unaddressed comments) ──────────────
@@ -279,36 +314,73 @@ export function pickLatestCopilotReviewBody(
279
314
  reviews: { user?: { login?: string }; body?: string }[],
280
315
  truncated: boolean,
281
316
  ): string | null {
317
+ const picked = pickLatestCopilotReview(reviews, truncated);
318
+ return picked === null ? null : picked.body;
319
+ }
320
+
321
+ /** Pick the newest Copilot review — body AND the commit SHA it was submitted against — from a
322
+ * reviews list (GitHub returns them oldest→newest). Semantics mirror {@link pickLatestCopilotReviewBody}
323
+ * exactly: `truncated = true` fails CLOSED (`null`, unverifiable); a verified-complete read with no
324
+ * Copilot review returns `{ body: "", commitId: null }` (a verified "no advisories"). The `commitId`
325
+ * lets a caller detect a review that predates the PR's current HEAD — a STALE review whose advisories
326
+ * are about code the head has since moved past (issue #799). Pure; unit-tested. */
327
+ export function pickLatestCopilotReview(
328
+ reviews: { user?: { login?: string }; body?: string; commit_id?: string | null }[],
329
+ truncated: boolean,
330
+ ): { body: string; commitId: string | null } | null {
282
331
  if (truncated) return null;
283
332
  const copilot = reviews.filter((rv) => isCopilot(rv.user?.login));
284
- return copilot[copilot.length - 1]?.body ?? "";
333
+ const latest = copilot[copilot.length - 1];
334
+ return { body: latest?.body ?? "", commitId: latest?.commit_id ?? null };
285
335
  }
286
336
 
287
337
  /** Fetch the latest Copilot review body for a PR (the newest review authored by the automated
288
338
  * Copilot reviewer). Returns `null` ONLY when no transport is usable (unverifiable → the worker
289
339
  * fails closed); returns `""` when transport is usable but the PR has no Copilot review yet (a
290
340
  * verified "no suppressed advisories"). Throws on a genuine transport failure. This split keeps
291
- * `null` from conflating "unverifiable" with "empty" and fail-OPENing the advisory dimension. */
341
+ * `null` from conflating "unverifiable" with "empty" and fail-OPENing the advisory dimension.
342
+ * Thin wrapper over {@link fetchLatestCopilotReview} (the single fetch implementation). */
292
343
  export async function fetchLatestCopilotReviewBody(
293
344
  repo: string,
294
345
  number: number | string,
295
346
  token: string,
296
347
  ): Promise<string | null> {
348
+ const picked = await fetchLatestCopilotReview(repo, number, token);
349
+ return picked === null ? null : picked.body;
350
+ }
351
+
352
+ /** Fetch the latest Copilot review — body AND the commit SHA it was submitted against — for a PR.
353
+ * Same null/`""`-vs-unverifiable semantics as {@link fetchLatestCopilotReviewBody} (which delegates
354
+ * here): `null` ONLY when no transport is usable (unverifiable → fail closed); a verified read with
355
+ * no Copilot review yet returns `{ body: "", commitId: null }`. The `commitId` lets the convergence
356
+ * gate detect a review that predates the PR's current HEAD — a STALE review whose advisories are
357
+ * about code the head has since moved past (issue #799) — and re-solicit a fresh review rather than
358
+ * block/escalate against the obsolete body. Throws on a genuine transport failure. */
359
+ export async function fetchLatestCopilotReview(
360
+ repo: string,
361
+ number: number | string,
362
+ token: string,
363
+ ): Promise<{ body: string; commitId: string | null } | null> {
297
364
  const mode = githubTransport();
298
365
  const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
299
366
  const basePath = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
300
367
  interface Review {
301
368
  user?: { login?: string };
302
369
  body?: string;
370
+ commit_id?: string | null;
303
371
  }
304
372
  if (useGh) {
305
- // `--paginate` merges EVERY page of the (oldest→newest) reviews array, so a >100-review
373
+ // `--paginate --slurp` walks EVERY page of the (oldest→newest) reviews array, so a >100-review
306
374
  // convergence loop still surfaces the genuinely newest Copilot review rather than the oldest
307
- // 100 — reading only the first page here would fail-OPEN the advisory dimension.
308
- const out = await runGh(["api", "--paginate", basePath, "-H", "Accept: application/vnd.github+json"]);
375
+ // 100 — reading only the first page here would fail-OPEN the advisory dimension. Plain
376
+ // `--paginate` concatenates one JSON array PER PAGE (multiple documents) which `JSON.parse`
377
+ // cannot read; `--slurp` wraps the pages in an outer array we flatten one level.
378
+ const out = await runGh([
379
+ "api", "--paginate", "--slurp", basePath, "-H", "Accept: application/vnd.github+json",
380
+ ]);
309
381
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
310
- const reviews = JSON.parse(out) as Review[];
311
- return pickLatestCopilotReviewBody(reviews, false);
382
+ const reviews = (JSON.parse(out) as Review[][]).flat();
383
+ return pickLatestCopilotReview(reviews, false);
312
384
  }
313
385
  if (!token) return null;
314
386
  // Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and
@@ -324,14 +396,14 @@ export async function fetchLatestCopilotReviewBody(
324
396
  const batch = (await r.json()) as Review[];
325
397
  reviews.push(...batch);
326
398
  // A short page means we've read every review — the list is complete.
327
- if (batch.length < 100) return pickLatestCopilotReviewBody(reviews, false);
399
+ if (batch.length < 100) return pickLatestCopilotReview(reviews, false);
328
400
  // A full page on the last allowed page is only truncated if GitHub says there's more; trust the
329
401
  // `Link` header's `rel="next"` so an exact multiple of 100 isn't a false positive.
330
402
  if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) {
331
- return pickLatestCopilotReviewBody(reviews, true);
403
+ return pickLatestCopilotReview(reviews, true);
332
404
  }
333
405
  }
334
- return pickLatestCopilotReviewBody(reviews, false);
406
+ return pickLatestCopilotReview(reviews, false);
335
407
  }
336
408
 
337
409
  /** Raw GraphQL response shape for the review-threads query. */