@nanobpm/nano-workforce 0.205.0 → 0.205.2

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,15 @@
1
+ ## [0.205.2](https://github.com/nanobpm/nano-workforce/compare/v0.205.1...v0.205.2) (2026-11-08)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **convergence:** fail closed when no head-current Copilot review exists ([#924](https://github.com/nanobpm/nano-workforce/issues/924)) ([e0ded57](https://github.com/nanobpm/nano-workforce/commit/e0ded57e0888755fa6fd77495f3361e8dafc0980)), closes [#923](https://github.com/nanobpm/nano-workforce/issues/923) [nano-bpm#1443](https://github.com/nanobpm/nano-bpm/issues/1443) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923)
6
+
7
+ ## [0.205.1](https://github.com/nanobpm/nano-workforce/compare/v0.205.0...v0.205.1) (2026-11-06)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **merge:** classify BLOCKED merge state and route unresolved review threads back into convergence ([#922](https://github.com/nanobpm/nano-workforce/issues/922)) ([5265f66](https://github.com/nanobpm/nano-workforce/commit/5265f66f7a0aca05fbb205c15b2ee0ef45c2aead)), closes [nano-bpm#1427](https://github.com/nanobpm/nano-bpm/issues/1427) [#921](https://github.com/nanobpm/nano-workforce/issues/921) [#921](https://github.com/nanobpm/nano-workforce/issues/921) [pre-#921](https://github.com/nanobpm/pre-/issues/921) [#921](https://github.com/nanobpm/nano-workforce/issues/921) [pre-#921](https://github.com/nanobpm/pre-/issues/921) [#921](https://github.com/nanobpm/nano-workforce/issues/921)
12
+
1
13
  ## [0.205.0](https://github.com/nanobpm/nano-workforce/compare/v0.204.1...v0.205.0) (2026-11-04)
2
14
 
3
15
  ### Features
@@ -292,3 +292,57 @@ test("the pass budget resets when the next review lands (new round)", async () =
292
292
  // Round 2 gets its own full budget of 1 pass.
293
293
  assert(completions(engine, "adversarial-review") === 2, `round 2 must get a fresh pass: ${runs(engine)}`);
294
294
  });
295
+
296
+ // Issue #923 §4 (behavioural, not substring): a `converged` agent verdict with NO head-current review
297
+ // makes the converge-gate report `reviewStale`; the loop must record the round as `review-stale`
298
+ // (not the agent's self-reported `converged`) and park for a fresh review — never reaching
299
+ // `persist-converged`.
300
+ test("#923: a stale converge-gate verdict persists the round as review-stale and parks without converging", async () => {
301
+ const { engine, seen } = await boot({
302
+ responses: {
303
+ "senior:pr-review": [{ status: "converged", summary: "done" }, { status: "waiting", summary: "awaiting" }],
304
+ "pr.converge-gate": {
305
+ convergeBlocked: false,
306
+ convergeBlockReason: "",
307
+ convergeAckOnly: false,
308
+ reviewStale: true,
309
+ },
310
+ },
311
+ vars: { advMax: 0 },
312
+ });
313
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
314
+ assert(completions(engine, "persist-converged") === 0, `must not converge on a stale review: ${runs(engine)}`);
315
+ const recorded = seen["pr.persist-round"][0];
316
+ assert(recorded?.status === "review-stale", `persist-round status: ${JSON.stringify(recorded?.status)}`);
317
+ assert(completions(engine, "wait-review") === 0, "must be parked waiting for a fresh review");
318
+ });
319
+
320
+ // Issue #923 review ("previously missed", behavioural): the arrival-window cutoff the converge-gate
321
+ // emits on a stale verdict must PROPAGATE through the process to the check-progress park — the
322
+ // variable crosses persist-round untouched, so check-progress can stamp `waiting_since` at the
323
+ // gate's read time (not the later park time) and a head-current review landing in the read→park
324
+ // window still satisfies the wait. This is the process-level wiring the worker unit tests cannot
325
+ // see (each worker is tested in isolation with an injected variable).
326
+ test("#923 review: the converge-gate's reviewWaitCutoff reaches check-progress on a stale verdict", async () => {
327
+ const CUTOFF = "2026-11-08T01:00:00.000Z";
328
+ const { engine, seen } = await boot({
329
+ responses: {
330
+ "senior:pr-review": [{ status: "converged", summary: "done" }, { status: "waiting", summary: "awaiting" }],
331
+ "pr.converge-gate": {
332
+ convergeBlocked: false,
333
+ convergeBlockReason: "",
334
+ convergeAckOnly: false,
335
+ reviewStale: true,
336
+ reviewWaitCutoff: CUTOFF,
337
+ },
338
+ },
339
+ vars: { advMax: 0 },
340
+ });
341
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
342
+ const progressJobs = seen["pr.progress-check"];
343
+ assert(progressJobs.length >= 1, "check-progress ran");
344
+ assert(
345
+ progressJobs[0]?.reviewWaitCutoff === CUTOFF,
346
+ `check-progress must receive the gate's reviewWaitCutoff, got ${JSON.stringify(progressJobs[0]?.reviewWaitCutoff)}`,
347
+ );
348
+ });
@@ -17,6 +17,7 @@ import {
17
17
  advisoryStableKey,
18
18
  isAckThread,
19
19
  parseAckedAdvisories,
20
+ parseReviewOpenFindings,
20
21
  parseReviewThreadsPage,
21
22
  parseSuppressedAdvisories,
22
23
  pickLatestCopilotReview,
@@ -820,7 +821,11 @@ test("converge-gate #799: a STALE review (commit_id predates HEAD) does not bloc
820
821
  headSha: "newsha2222222222222222222222222222222222",
821
822
  });
822
823
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
823
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: true });
824
+ assertEquals(out.convergeBlocked, false);
825
+ assertEquals(out.reviewStale, true);
826
+ // The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
827
+ // landing in the read→park window still satisfies the wait (issue #923 review).
828
+ assertEquals(typeof out.reviewWaitCutoff, "string");
824
829
  });
825
830
 
826
831
  test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advisory (control)", async () => {
@@ -838,9 +843,14 @@ test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advis
838
843
  assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
839
844
  });
840
845
 
841
- test("converge-gate #799: an unknown review commit_id or unreadable HEAD is NOT stale (evaluates normally)", async () => {
842
- // Fail-safe: without both SHAs we cannot prove staleness, so the gate must fall through to the
843
- // ordinary evaluation (here: block on the unacked advisory), never fabricate a re-solicit loop.
846
+ test("converge-gate #799/#923: a review commit_id GitHub did not carry is treated as MISSING — re-solicits (no fail-open)", async () => {
847
+ // #799's fail-safe ("a null review commit_id is not stale") was written for a gate that only
848
+ // handled a review that EXISTS. #923 closes the class: a null commit_id is ambiguous between "no
849
+ // review at all" and "a review GitHub carried no commit_id for", and the gate cannot distinguish
850
+ // them — converging on either is converging without a verifiable head-current verdict (the
851
+ // #1443 fail-open). With a readable head the gate now signals reviewStale for BOTH, so the
852
+ // poller re-solicits a review whose commit_id IS carried, un-wedging the loop. The head-side
853
+ // fail-safe is unchanged: an unreadable head still evaluates normally (see the #923 test below).
844
854
  const handler = await makeUnderTest({
845
855
  readThreads: async () => [],
846
856
  readReviewBody: async () => SAMPLE_REVIEW_BODY,
@@ -848,8 +858,66 @@ test("converge-gate #799: an unknown review commit_id or unreadable HEAD is NOT
848
858
  headSha: "newsha2222222222222222222222222222222222",
849
859
  });
850
860
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
851
- assertEquals(out.convergeBlocked, true);
852
- assertEquals(out.reviewStale, false);
861
+ assertEquals(out.convergeBlocked, false);
862
+ assertEquals(out.reviewStale, true);
863
+ // The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
864
+ // landing in the read→park window still satisfies the wait (issue #923 review).
865
+ assertEquals(typeof out.reviewWaitCutoff, "string");
866
+ });
867
+
868
+ // ── Fail closed on "no review exists" (issue #923 §1) ────────────────────────
869
+ // The gate used to treat `{ body: "", commitId: null }` — a VERIFIED "no Copilot review on this
870
+ // PR" — as "a verified clean review": zero threads + zero advisories → not blocked → merged with
871
+ // ZERO reviews (nano-bpm#1443). Convergence requires a HEAD-CURRENT reviewer verdict, so a missing
872
+ // review must signal `reviewStale` and route to the review wait (the poller solicits a review of
873
+ // the head), exactly like a stale one.
874
+
875
+ test("converge-gate #923: NO Copilot review at all signals reviewStale (never converges unreviewed)", async () => {
876
+ // `readReview` returns `{ body: "", commitId: null }` (verified: no Copilot review) and the head
877
+ // read succeeds. The old gate fell through as the most permissive case and converged.
878
+ const handler = await makeUnderTest({
879
+ readThreads: async () => [],
880
+ readReviewBody: async () => "",
881
+ reviewCommitId: null,
882
+ headSha: "headsha4444444444444444444444444444444444",
883
+ });
884
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
885
+ assertEquals(out.convergeBlocked, false);
886
+ assertEquals(out.reviewStale, true);
887
+ // The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
888
+ // landing in the read→park window still satisfies the wait (issue #923 review).
889
+ assertEquals(typeof out.reviewWaitCutoff, "string");
890
+ });
891
+
892
+ test("converge-gate #923: a review GitHub carried no commit_id for is ALSO treated as missing (fail closed)", async () => {
893
+ // A null commit_id is ambiguous — "no review" vs "a review with no commit_id" — and the gate
894
+ // cannot distinguish them, so BOTH must take the re-solicit path. Converging on an unverifiable
895
+ // verdict is the fail-open this issue closes; the re-solicit produces a review whose commit_id
896
+ // IS carried, un-wedging the loop.
897
+ const handler = await makeUnderTest({
898
+ readThreads: async () => [],
899
+ readReviewBody: async () => "## Overview\nNo suppressed block.",
900
+ reviewCommitId: null,
901
+ headSha: "headsha4444444444444444444444444444444444",
902
+ });
903
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
904
+ assertEquals(out.reviewStale, true);
905
+ assertEquals(out.convergeBlocked, false);
906
+ });
907
+
908
+ test("converge-gate #923: a missing review with an UNREADABLE head still fails safe (no fabricated re-solicit)", async () => {
909
+ // Without the head we cannot prove anything — the gate keeps `isReviewStale`'s fail-safe and
910
+ // falls through to the ordinary evaluation (here: a clean empty review converges). A transport
911
+ // hiccup must never strand the loop re-soliciting forever; the review-wait timeout is the
912
+ // backstop.
913
+ const handler = await makeUnderTest({
914
+ readThreads: async () => [],
915
+ readReviewBody: async () => "",
916
+ reviewCommitId: null,
917
+ headSha: null,
918
+ });
919
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
920
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
853
921
  });
854
922
 
855
923
  // ── Structural guard over the committed BPMN (no engine) ─────────────────────
@@ -971,6 +1039,20 @@ test("the round cap does NOT escalate a stale-retry round — f_guardMax is gate
971
1039
  assertStringIncludes(f, "maxRounds and reviewStale != true");
972
1040
  });
973
1041
 
1042
+ test("persist-round records a stale re-entry as 'review-stale', not the agent's self-report (#923 §4)", () => {
1043
+ // Round accounting must reflect whether a reviewer verdict was obtained: a round that re-enters
1044
+ // persist-round via f_convergeStale (no head-current review existed, or it predated the head)
1045
+ // never received a verdict, so recording the agent's self-reported status ("converged") would
1046
+ // tell the operator a review round happened that never did (/status showed nano-bpm#1443 as
1047
+ // "round 2" with zero reviews). The stale re-entry maps `status` from `reviewStale` so the
1048
+ // durable round history reads `review-stale`.
1049
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-round"[^>]*>.*?<\/bpmn:serviceTask>/);
1050
+ assert(task, "persist-round task missing");
1051
+ assertStringIncludes(task[0], 'target="status"');
1052
+ assertStringIncludes(task[0], "reviewStale");
1053
+ assertStringIncludes(task[0], "review-stale");
1054
+ });
1055
+
974
1056
  test("wait-review clears reviewStale when a fresh review lands, so the marker can't leak into a later round (#799)", () => {
975
1057
  // reviewStale is written ONLY by the converge-gate; once a fresh review arrives it is no longer
976
1058
  // known-stale, so wait-review resets it to false alongside the round increment. Without this reset
@@ -1196,3 +1278,101 @@ test("parseSuppressedAdvisories: v2 table rows without findings yield nothing; n
1196
1278
  );
1197
1279
  assertEquals(parseSuppressedAdvisories("| `a.ts` | Broken thing (moderate, 1 vote). |"), []);
1198
1280
  });
1281
+
1282
+ // ── The reviewer-verdict gate (issue #923 §2) ────────────────────────────────
1283
+ // Convergence requires a head-current reviewer VERDICT, not just zero unresolved threads: a
1284
+ // head-current review whose own summary reports open findings must block the same way an
1285
+ // unresolved thread does. `parseReviewOpenFindings` reads that verdict out of the review body
1286
+ // (Copilot's "Changes recommended" / "N open issue(s)" / ccr-overview-v2 "Open (N)" section).
1287
+
1288
+ test("parseReviewOpenFindings: a 'Changes recommended' review reports open findings", () => {
1289
+ const body = "## Pull Request Overview\n🟡 Changes recommended — 7 findings (6 high).";
1290
+ assertEquals(parseReviewOpenFindings(body), ["changes recommended"]);
1291
+ });
1292
+
1293
+ test("parseReviewOpenFindings: an 'N open issue(s)' summary reports N", () => {
1294
+ assertEquals(
1295
+ parseReviewOpenFindings("<details open>\n<summary><strong>3 open issues</strong></summary>\n</details>"),
1296
+ ["3 open issues"],
1297
+ );
1298
+ assertEquals(parseReviewOpenFindings("<summary>1 open issue</summary>"), ["1 open issue"]);
1299
+ });
1300
+
1301
+ test("parseReviewOpenFindings: '0 open issues' is a clean verdict, not a finding", () => {
1302
+ assertEquals(parseReviewOpenFindings("Review complete. 0 open issues."), []);
1303
+ assertEquals(parseReviewOpenFindings("<details><summary><strong>0 open issues</strong></summary></details>"), []);
1304
+ });
1305
+
1306
+ test("parseReviewOpenFindings: a ccr-overview-v2 'Open (N)' section reports N", () => {
1307
+ assertEquals(parseReviewOpenFindings(V2_REVIEW_BODY), ["1 open finding"]);
1308
+ });
1309
+
1310
+ test("parseReviewOpenFindings: a clean review (no open-issues signal) reports none", () => {
1311
+ assertEquals(parseReviewOpenFindings("## Pull Request Overview\n✅ Looks good — no issues found."), []);
1312
+ assertEquals(parseReviewOpenFindings(""), []);
1313
+ assertEquals(parseReviewOpenFindings(null), []);
1314
+ assertEquals(parseReviewOpenFindings(undefined), []);
1315
+ });
1316
+
1317
+ test("parseReviewOpenFindings: verdict phrases in descriptive prose / table rows are NOT findings", () => {
1318
+ const clean = [
1319
+ "<!-- ccr-overview-v2 -->",
1320
+ "",
1321
+ "### ✅ Looks good",
1322
+ "",
1323
+ "<details>",
1324
+ "<summary><strong>What changed in this PR</strong></summary>",
1325
+ "",
1326
+ "| File | Summary and final review notes |",
1327
+ "|---|---|",
1328
+ '| `a.ts` | Recognizes "Changes recommended" and counts 3 open issues; parses Open (2) sections. |',
1329
+ "</details>",
1330
+ "<summary>Notes</summary> then later Open (4) outside any summary",
1331
+ ].join("\n");
1332
+ assertEquals(parseReviewOpenFindings(clean), []);
1333
+ });
1334
+
1335
+ test("parseReviewOpenFindings: a v2 overview with NO Open section reports none", () => {
1336
+ const clean = [
1337
+ "<!-- ccr-overview-v2 -->",
1338
+ "",
1339
+ "## Copilot review overview",
1340
+ "",
1341
+ "<details>",
1342
+ "<summary><strong>What changed in this PR</strong></summary>",
1343
+ "",
1344
+ "| File | Summary and final review notes |",
1345
+ "|---|---|",
1346
+ "| `a.ts` | Adds a thing. |",
1347
+ "</details>",
1348
+ ].join("\n");
1349
+ assertEquals(parseReviewOpenFindings(clean), []);
1350
+ });
1351
+
1352
+ test("converge-gate #923: a head-current review whose body reports OPEN ISSUES blocks (verdict gate, no threads)", async () => {
1353
+ // The verdict gate: zero unresolved threads and no suppressed advisories, but the head-current
1354
+ // review's own summary says findings are open. This must block EXACTLY like an unresolved
1355
+ // thread — NOT ack-only (the round agent's code/reply work is needed, not a nano-ack repost).
1356
+ const handler = await makeUnderTest({
1357
+ readThreads: async () => [],
1358
+ readReviewBody: async () => "## Pull Request Overview\n🟡 Changes recommended — 7 findings (6 high).",
1359
+ reviewCommitId: "headsha4444444444444444444444444444444444",
1360
+ headSha: "headsha4444444444444444444444444444444444",
1361
+ });
1362
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
1363
+ assertEquals(out.convergeBlocked, true);
1364
+ assertEquals(out.reviewStale, false);
1365
+ assertEquals(out.convergeAckOnly, false);
1366
+ assertStringIncludes(out.convergeBlockReason ?? "", "changes recommended");
1367
+ });
1368
+
1369
+ test("converge-gate #923: a head-current CLEAN review with no findings still converges (verdict-gate control)", async () => {
1370
+ const handler = await makeUnderTest({
1371
+ readThreads: async () => [],
1372
+ readReviewBody: async () => "## Pull Request Overview\n✅ Looks good — no issues found.",
1373
+ reviewCommitId: "headsha4444444444444444444444444444444444",
1374
+ headSha: "headsha4444444444444444444444444444444444",
1375
+ });
1376
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
1377
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
1378
+ });
@@ -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, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
6
+ import { BaseBranchMustExistError, checkConclusions, classifyBlockedCause, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, THREAD_PROBE_FAILED, updateBranchRef, type GhReview, 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]`
@@ -556,6 +556,7 @@ function prState(over: Partial<PrState>): PrState {
556
556
  isDraft: false,
557
557
  headRefOid: null,
558
558
  mergeQueueEntry: null,
559
+ unresolvedThreads: -1,
559
560
  ...over,
560
561
  };
561
562
  }
@@ -604,6 +605,7 @@ function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclu
604
605
  isDraft: false,
605
606
  headRefOid: "abc123",
606
607
  mergeQueueEntry: null,
608
+ unresolvedThreads: -1,
607
609
  };
608
610
  if (rollup) {
609
611
  const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
@@ -808,6 +810,288 @@ test("classifyMergeability: a non-draft PR is unaffected (regression guard, issu
808
810
  assertEquals(classifyMergeability(prState({ mergeStateStatus: "CLEAN", isDraft: false })), "ready");
809
811
  });
810
812
 
813
+ // ── BLOCKED-cause classification (issue #921) ────────────────────────────────────────────────────
814
+ //
815
+ // GitHub reports one flat `mergeStateStatus: BLOCKED` for several distinct causes. Before the fix,
816
+ // `classifyMergeability` mapped `BLOCKED` with no failing checks to `"waiting"` — so a PR blocked
817
+ // ONLY by unresolved review threads (branch protection `required_conversation_resolution`, checks
818
+ // green) was never published to the merge loop; the mergeable-wait timer fired, the stall probe
819
+ // re-derived the same `"waiting"`, and after the stall budget the escalation read "GitHub never
820
+ // settled this PR's mergeability" — when GitHub HAD settled (MERGEABLE/BLOCKED). The classifier now
821
+ // distinguishes the causes: unresolved threads are a settled `review-threads` verdict the loop
822
+ // routes back into convergence; a failed required check stays `blocked` (fix-ci); an unreadable
823
+ // cause stays a conservative `waiting`.
824
+
825
+ test("classifyBlockedCause: a failing required check is the 'checks' cause", () => {
826
+ assertEquals(classifyBlockedCause(prState({ failingChecks: 2, unresolvedThreads: 3 })), "checks");
827
+ assertEquals(classifyBlockedCause(prState({ failingChecks: 1, unresolvedThreads: -1 })), "checks");
828
+ });
829
+
830
+ test("classifyBlockedCause: green checks + unresolved threads is the 'review-threads' cause", () => {
831
+ assertEquals(classifyBlockedCause(prState({ failingChecks: 0, unresolvedThreads: 1 })), "review-threads");
832
+ assertEquals(classifyBlockedCause(prState({ failingChecks: 0, unresolvedThreads: 5 })), "review-threads");
833
+ });
834
+
835
+ test("classifyBlockedCause: green checks + zero unresolved threads is the 'reviews' cause", () => {
836
+ // Everything the loop can act on is clear, yet GitHub still blocks → a required human review
837
+ // approval (or code-owner review) is outstanding.
838
+ assertEquals(classifyBlockedCause(prState({ failingChecks: 0, unresolvedThreads: 0 })), "reviews");
839
+ });
840
+
841
+ test("classifyBlockedCause: an unprobed or unenumerable read is 'unknown' (never fabricates a cause)", () => {
842
+ // Threads not probed (-1) — the pre-#921 default for every non-merge-stage read.
843
+ assertEquals(classifyBlockedCause(prState({ failingChecks: 0, unresolvedThreads: -1 })), "unknown");
844
+ // Token mode: checks unenumerable (-1) even with threads probed.
845
+ assertEquals(classifyBlockedCause(prState({ failingChecks: -1, unresolvedThreads: 0 })), "unknown");
846
+ assertEquals(classifyBlockedCause(prState({ failingChecks: -1, unresolvedThreads: 2 })), "unknown");
847
+ });
848
+
849
+ test("classifyBlockedCause: a pending check keeps the cause 'unknown' — zero failing ≠ green (PR #922 review)", () => {
850
+ // Queued/running checks are tracked in `pendingCheckNames`, not `failingChecks`. While any check
851
+ // is still in flight the BLOCKED cause is undetermined (the pending run may come back red), so no
852
+ // thread/approval cause may be assigned — the caller keeps waiting.
853
+ assertEquals(
854
+ classifyBlockedCause(prState({ failingChecks: 0, pendingCheckNames: ["build"], unresolvedThreads: 2 })),
855
+ "unknown",
856
+ );
857
+ assertEquals(
858
+ classifyBlockedCause(prState({ failingChecks: 0, pendingCheckNames: ["build"], unresolvedThreads: 0 })),
859
+ "unknown",
860
+ );
861
+ });
862
+
863
+ test("classifyMergeability: BLOCKED + green checks + unresolved threads is a settled 'review-threads' verdict (#921)", () => {
864
+ // The incident shape (nano-bpm#1427): MERGEABLE/BLOCKED, all required checks green, ≥1 unresolved
865
+ // Copilot thread. Before the fix this read as "waiting" and escalated as "never settled".
866
+ assertEquals(
867
+ classifyMergeability(prState({ mergeStateStatus: "BLOCKED", failingChecks: 0, unresolvedThreads: 2 })),
868
+ "review-threads",
869
+ );
870
+ });
871
+
872
+ test("classifyMergeability: BLOCKED + failing checks still routes to fix-ci, threads or not (#921)", () => {
873
+ assertEquals(
874
+ classifyMergeability(prState({ mergeStateStatus: "BLOCKED", failingChecks: 1, unresolvedThreads: 2 })),
875
+ "blocked",
876
+ );
877
+ });
878
+
879
+ test("classifyMergeability: BLOCKED with an unprobed thread read keeps waiting conservatively (#921)", () => {
880
+ // `unresolvedThreads: -1` (not probed / unreadable) must never fabricate the review-threads
881
+ // verdict — the PR keeps polling exactly as before #921.
882
+ assertEquals(
883
+ classifyMergeability(prState({ mergeStateStatus: "BLOCKED", failingChecks: 0, unresolvedThreads: -1 })),
884
+ "waiting",
885
+ );
886
+ // Token mode (checks unenumerable) likewise stays a conservative wait.
887
+ assertEquals(
888
+ classifyMergeability(prState({ mergeStateStatus: "BLOCKED", failingChecks: -1, unresolvedThreads: 0 })),
889
+ "waiting",
890
+ );
891
+ });
892
+
893
+ test("classifyMergeability: BLOCKED + green checks + zero unresolved threads keeps waiting, gated on resolution (#921)", () => {
894
+ // A required human review holds the merge. The verdict stays "waiting" (the poller keeps
895
+ // watching), and the merge-stall escalation names the specific cause once the budget exhausts —
896
+ // the merge step never fires while the block stands.
897
+ assertEquals(
898
+ classifyMergeability(prState({ mergeStateStatus: "BLOCKED", failingChecks: 0, unresolvedThreads: 0 })),
899
+ "waiting",
900
+ );
901
+ });
902
+
903
+ test("classifyMergeability: an opted-in read with unresolved threads is never merge-ready — the merge-stage thread gate (#921, PR #922 review)", () => {
904
+ // Issue #921's merge contract is ZERO unresolved threads — independent of `mergeStateStatus`. A
905
+ // repo WITHOUT branch-protection `required_conversation_resolution` reports a thread-blocked PR
906
+ // CLEAN/UNSTABLE (mergeable by GitHub's rules), but the threads are review findings the loop
907
+ // converges with, so an opted-in (`withReviewThreads`) merge-stage read routes the PR back into
908
+ // convergence instead of landing over them.
909
+ assertEquals(
910
+ classifyMergeability(prState({ mergeStateStatus: "CLEAN", failingChecks: 0, unresolvedThreads: 3 })),
911
+ "review-threads",
912
+ );
913
+ assertEquals(
914
+ classifyMergeability(prState({ mergeStateStatus: "UNSTABLE", failingChecks: 0, unresolvedThreads: 3 })),
915
+ "review-threads",
916
+ );
917
+ assertEquals(
918
+ classifyMergeability(prState({ mergeStateStatus: "HAS_HOOKS", failingChecks: 0, unresolvedThreads: 1 })),
919
+ "review-threads",
920
+ );
921
+ assertEquals(
922
+ classifyMergeability(prState({ mergeStateStatus: "BEHIND", failingChecks: 0, unresolvedThreads: 1 })),
923
+ "review-threads",
924
+ );
925
+ });
926
+
927
+ test("classifyMergeability: an UNPROBED read keeps the pre-#921 verdicts — non-merge-stage callers are untouched (#921)", () => {
928
+ // `unresolvedThreads: -1` (the probe not opted into — e.g. the readiness `pr` probe's
929
+ // `matchPr`) must never fabricate the review-threads verdict: the mergeStateStatus switch
930
+ // governs, exactly as before #921.
931
+ assertEquals(
932
+ classifyMergeability(prState({ mergeStateStatus: "CLEAN", failingChecks: 0, unresolvedThreads: -1 })),
933
+ "ready",
934
+ );
935
+ assertEquals(
936
+ classifyMergeability(prState({ mergeStateStatus: "UNSTABLE", failingChecks: 0, unresolvedThreads: -1 })),
937
+ "ready",
938
+ );
939
+ });
940
+
941
+ test("classifyMergeability: BLOCKED with pending checks keeps waiting — no cause off a half-settled rollup (PR #922 review)", () => {
942
+ // Zero failing checks with a check still queued/running is not "checks green": the BLOCKED cause
943
+ // is undetermined until the pending run concludes, so the verdict stays a conservative wait even
944
+ // with open threads.
945
+ assertEquals(
946
+ classifyMergeability(
947
+ prState({ mergeStateStatus: "BLOCKED", failingChecks: 0, pendingCheckNames: ["build"], unresolvedThreads: 2 }),
948
+ ),
949
+ "waiting",
950
+ );
951
+ });
952
+
953
+ test("classifyMergeability: known unresolved threads never reach 'ready' when checks are NOT settled (PR #922 review, thread 2)", () => {
954
+ // The pre-switch thread gate must hold the merge even when the checks aren't green: a token-mode
955
+ // CLEAN PR has `failingChecks === -1` (checks unenumerable), and an UNSTABLE PR can have a failing
956
+ // non-required check. Without conversation-resolution protection, `pollMerges` would otherwise
957
+ // authorise merging over the open threads. The threads still block the merge, but a failing check
958
+ // owns remediation (`blocked` → fix-ci) while a pending / unenumerable check holds `waiting` —
959
+ // either way the verdict is never `ready`.
960
+ // Token mode: checks unenumerable (failingChecks -1), threads open, CLEAN → waiting (not ready).
961
+ assertEquals(
962
+ classifyMergeability(prState({ mergeStateStatus: "CLEAN", failingChecks: -1, unresolvedThreads: 2 })),
963
+ "waiting",
964
+ );
965
+ // UNSTABLE with a failing non-required check + open threads → the failing check owns remediation
966
+ // (`blocked` → fix-ci), never `ready`: the merge never lands over the open threads.
967
+ assertEquals(
968
+ classifyMergeability(prState({ mergeStateStatus: "UNSTABLE", failingChecks: 1, unresolvedThreads: 2 })),
969
+ "blocked",
970
+ );
971
+ // A pending check + open threads on a CLEAN PR → waiting (not ready).
972
+ assertEquals(
973
+ classifyMergeability(prState({ mergeStateStatus: "CLEAN", failingChecks: 0, pendingCheckNames: ["build"], unresolvedThreads: 2 })),
974
+ "waiting",
975
+ );
976
+ // …and a BLOCKED PR with a failing required check + open threads still routes to fix-ci (the
977
+ // check owns remediation), NOT to a review round on top of the CI problem.
978
+ assertEquals(
979
+ classifyMergeability(prState({ mergeStateStatus: "BLOCKED", failingChecks: 1, unresolvedThreads: 2 })),
980
+ "blocked",
981
+ );
982
+ });
983
+
984
+ test("classifyMergeability: a FAILED opted-in thread probe is never merge-ready — it holds waiting (PR #922 review, thread 1)", () => {
985
+ // `unresolvedThreads === THREAD_PROBE_FAILED (-2)` means the merge-stage read opted into the
986
+ // probe but it errored, so the thread count is UNKNOWN. That must NOT collapse to the unprobed
987
+ // `-1` (which would let a CLEAN PR classify `ready` and merge over threads that were never
988
+ // verified resolved). The verdict is a conservative `waiting` for every mergeable state.
989
+ for (const mergeStateStatus of ["CLEAN", "HAS_HOOKS", "UNSTABLE", "BEHIND"]) {
990
+ assertEquals(
991
+ classifyMergeability(prState({ mergeStateStatus, failingChecks: 0, unresolvedThreads: THREAD_PROBE_FAILED })),
992
+ "waiting",
993
+ `${mergeStateStatus} with a failed thread probe must hold waiting, never ready`,
994
+ );
995
+ }
996
+ // BLOCKED with a failed probe also stays waiting (the cause is unknown — never fabricate one).
997
+ assertEquals(
998
+ classifyMergeability(prState({ mergeStateStatus: "BLOCKED", failingChecks: 0, unresolvedThreads: THREAD_PROBE_FAILED })),
999
+ "waiting",
1000
+ );
1001
+ // …and the BLOCKED-cause classifier likewise refuses to assign a cause off a failed probe.
1002
+ assertEquals(classifyBlockedCause(prState({ failingChecks: 0, unresolvedThreads: THREAD_PROBE_FAILED })), "unknown");
1003
+ });
1004
+
1005
+ test("classifyMergeability: a known merge conflict is never suppressed by the thread gate (PR #922 review, thread 1)", () => {
1006
+ // `mergeStateStatus: "DIRTY"` is GitHub's ground-truth "the head conflicts with the base" signal —
1007
+ // its remedy is a rebase, dispatched off the `conflict` verdict. A thread-probe read must never
1008
+ // mask it: a FAILED probe (`THREAD_PROBE_FAILED`, `-2`) returning `waiting` before the switch would
1009
+ // park a KNOWN conflict forever (neither the poller nor the stall probe dispatches the rebase), and
1010
+ // an OPEN thread count returning `review-threads`/`waiting` would route a conflict into a review
1011
+ // round that cannot clear it. The conflict outranks the thread gate either way.
1012
+ assertEquals(
1013
+ classifyMergeability(prState({ mergeStateStatus: "DIRTY", failingChecks: 0, unresolvedThreads: THREAD_PROBE_FAILED })),
1014
+ "conflict",
1015
+ "DIRTY + a failed thread probe still surfaces the known conflict (rebase), not a blind wait",
1016
+ );
1017
+ assertEquals(
1018
+ classifyMergeability(prState({ mergeStateStatus: "DIRTY", failingChecks: 0, unresolvedThreads: 3 })),
1019
+ "conflict",
1020
+ "DIRTY + open threads still surfaces the known conflict (rebase), not a review round",
1021
+ );
1022
+ // …and an UNPROBED read keeps the pre-#921 conflict verdict (unchanged).
1023
+ assertEquals(
1024
+ classifyMergeability(prState({ mergeStateStatus: "DIRTY", failingChecks: 0, unresolvedThreads: -1 })),
1025
+ "conflict",
1026
+ );
1027
+ // A red DECLARED-required check still outranks even the conflict: a conflicting PR whose CI is
1028
+ // also red routes to fix-ci (the check owns remediation), not a doomed rebase. This preserves the
1029
+ // required-checks backstop's priority over the `mergeStateStatus` switch.
1030
+ assertEquals(
1031
+ classifyMergeability(
1032
+ prState({
1033
+ mergeStateStatus: "DIRTY",
1034
+ failingChecks: 1,
1035
+ presentCheckNames: ["build"],
1036
+ checkConclusions: { build: "FAILURE" },
1037
+ unresolvedThreads: 2,
1038
+ }),
1039
+ { requiredChecks: [{ name: "build", acceptedConclusions: ["SUCCESS"] }] },
1040
+ ),
1041
+ "blocked",
1042
+ "a red declared-required check owns remediation even over a known conflict",
1043
+ );
1044
+ });
1045
+
1046
+ test("classifyMergeability: the declared required-check verdict outranks the settled-thread verdict (PR #922 review, thread 2)", () => {
1047
+ // The `unresolvedThreads > 0` arm must consult `requiredChecksVerdict` before assigning
1048
+ // `review-threads`: a repo whose protocol declares a success-only required check reads a NEUTRAL
1049
+ // build as `blocked` (fix-ci), and an absent declared-required check reads as `waiting` — starting
1050
+ // a convergence round on top of either begins remediation before the check requirements are met.
1051
+ const successOnly = { requiredChecks: [{ name: "build", acceptedConclusions: ["SUCCESS"] }] };
1052
+ // NEUTRAL build rejected by the success-only protocol + open threads → the required check owns
1053
+ // remediation (`blocked` → fix-ci), NOT `review-threads`.
1054
+ assertEquals(
1055
+ classifyMergeability(
1056
+ prState({
1057
+ mergeStateStatus: "CLEAN",
1058
+ failingChecks: 0,
1059
+ presentCheckNames: ["build"],
1060
+ checkConclusions: { build: "NEUTRAL" },
1061
+ unresolvedThreads: 2,
1062
+ }),
1063
+ successOnly,
1064
+ ),
1065
+ "blocked",
1066
+ "a NEUTRAL required check rejected by a success-only protocol keeps fix-ci priority over threads",
1067
+ );
1068
+ // An absent declared-required check + open threads → `waiting` (the not-yet-run required check
1069
+ // owns the verdict), NOT `review-threads`.
1070
+ assertEquals(
1071
+ classifyMergeability(
1072
+ prState({ mergeStateStatus: "CLEAN", failingChecks: 0, presentCheckNames: [], unresolvedThreads: 2 }),
1073
+ successOnly,
1074
+ ),
1075
+ "waiting",
1076
+ "an absent declared-required check holds waiting before the threads route to convergence",
1077
+ );
1078
+ // …and when every declared-required check IS satisfied, the open threads still own the verdict.
1079
+ assertEquals(
1080
+ classifyMergeability(
1081
+ prState({
1082
+ mergeStateStatus: "CLEAN",
1083
+ failingChecks: 0,
1084
+ presentCheckNames: ["build"],
1085
+ checkConclusions: { build: "SUCCESS" },
1086
+ unresolvedThreads: 2,
1087
+ }),
1088
+ successOnly,
1089
+ ),
1090
+ "review-threads",
1091
+ "satisfied required checks leave the open threads owning the verdict",
1092
+ );
1093
+ });
1094
+
811
1095
  // `checkConclusions` must report a terminal conclusion per check but normalise a STILL-IN-FLIGHT run
812
1096
  // to "" for BOTH rollup shapes — a CheckRun whose `status` is not COMPLETED, and a legacy
813
1097
  // StatusContext whose `state` is PENDING/EXPECTED — so a caller never mistakes a pending