@nanobpm/nano-workforce 0.204.1 → 0.205.1

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.1](https://github.com/nanobpm/nano-workforce/compare/v0.205.0...v0.205.1) (2026-11-06)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **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)
6
+
7
+ ## [0.205.0](https://github.com/nanobpm/nano-workforce/compare/v0.204.1...v0.205.0) (2026-11-04)
8
+
9
+ ### Features
10
+
11
+ * **convergence:** make PR merge intent explicit — carry autoMerge end to end and show the effective merge policy in /status ([#917](https://github.com/nanobpm/nano-workforce/issues/917)) ([31e5b26](https://github.com/nanobpm/nano-workforce/commit/31e5b26f745f58920a9528bb448fe650af9631e3)), closes [#916](https://github.com/nanobpm/nano-workforce/issues/916) [#916](https://github.com/nanobpm/nano-workforce/issues/916)
12
+
1
13
  ## [0.204.1](https://github.com/nanobpm/nano-workforce/compare/v0.204.0...v0.204.1) (2026-11-04)
2
14
 
3
15
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -454,13 +454,16 @@ Notes:
454
454
  three because the unfiltered merge lets an input-echo replace the identity that
455
455
  `progress-check`, `converge-gate`, and `finalize` go on to read (a wrong
456
456
  `prNumber` would make `finalize` start the merge loop for the wrong PR) — **and**
457
- the gate controls `humanApproval` and `convergeOnly`. These last two are
458
- decision flags the loop acts on after the agent returns: `humanApproval = true`
459
- routes a converged PR through the `gw-approval` human gate instead of
460
- auto-merging, and `convergeOnly = true` ends a review-only run without ever
461
- handing the PR to the merge loop. Left unguarded, a converged completion that
462
- echoed `humanApproval: false` would **bypass** the approval gate, and one that
463
- echoed `convergeOnly: false` would hand a review-only PR to the merge loop.
457
+ the gate controls `humanApproval` and the merge-intent pair `autoMerge` /
458
+ `convergeOnly`. These are decision flags the loop acts on after the agent
459
+ returns: `humanApproval = true` routes a converged PR through the
460
+ `gw-approval` human gate instead of auto-merging, and the merge intent —
461
+ `autoMerge` (the canonical positive variable, issue #916), with the legacy
462
+ inverted `convergeOnly` as its in-flight fallback — decides whether a
463
+ converged PR is handed to the merge loop at all. Left unguarded, a converged
464
+ completion that echoed `humanApproval: false` would **bypass** the approval
465
+ gate, and one that echoed `convergeOnly: false` would hand a review-only PR
466
+ to the merge loop.
464
467
  `convergeOnly` is a plain seeded constant, so the usual `=guardConvergeOnly`
465
468
  restore applies. `humanApproval` is the one engine-owned flag that is **also
466
469
  legitimately mutated mid-flight** — a gated caller that adopts a live loop
@@ -319,6 +319,56 @@ test("a scope-classify agent echoing humanApproval/convergeOnly cannot alter the
319
319
  assertEquals(await variableValue(engine, processInstanceKey, "convergeOnly"), true, "convergeOnly flag must be unchanged");
320
320
  });
321
321
 
322
+ // Issue #916: `autoMerge` is the canonical positive merge intent and is engine-owned like
323
+ // `convergeOnly` — an agent echo in EITHER direction must not flip it.
324
+ for (const seeded of [true, false]) {
325
+ test(`a review-round agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
326
+ const { engine } = await boot({
327
+ vars: { autoMerge: seeded },
328
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", autoMerge: !seeded } },
329
+ });
330
+ const incidents = await engine.searchIncidents();
331
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
332
+ assertEquals(instanceVars(engine).autoMerge, seeded, "seeded autoMerge must survive the agent echo");
333
+ });
334
+
335
+ test(`an adversarial-review agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
336
+ const { engine } = await boot({
337
+ vars: { advMax: 1, autoMerge: seeded },
338
+ responses: {
339
+ "senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean", autoMerge: !seeded },
340
+ },
341
+ });
342
+ const incidents = await engine.searchIncidents();
343
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
344
+ assertEquals(instanceVars(engine).autoMerge, seeded, "seeded autoMerge must survive the agent echo");
345
+ });
346
+
347
+ test(`a scope-classify agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
348
+ const { engine, processInstanceKey } = await boot({
349
+ vars: { humanApproval: true, autoMerge: seeded },
350
+ responses: {
351
+ "senior:pr-review": { status: "converged", summary: "done" },
352
+ "senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "", autoMerge: !seeded },
353
+ },
354
+ });
355
+ const incidents = await engine.searchIncidents();
356
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
357
+ assertEquals(await variableValue(engine, processInstanceKey, "autoMerge"), seeded, "autoMerge must be unchanged");
358
+ });
359
+ }
360
+
361
+ test("an agent echoing autoMerge on a legacy (autoMerge-absent) run cannot inject a merge intent", async () => {
362
+ const { engine } = await boot({
363
+ vars: { convergeOnly: true },
364
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", autoMerge: true } },
365
+ });
366
+ const incidents = await engine.searchIncidents();
367
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
368
+ const v = instanceVars(engine).autoMerge;
369
+ assertEquals(v == null, true, `legacy run must keep autoMerge absent so the convergeOnly fallback applies; got ${v}`);
370
+ });
371
+
322
372
  // ---------------------------------------------------------------------------
323
373
  // Issue #887 (round 4 — Copilot "previously missed"): `reviewStale` and
324
374
  // `roundEntryHead` are ALSO process-owned and readable by downstream decisions,
package/app/feature.ts CHANGED
@@ -524,9 +524,9 @@ export async function startFeature(
524
524
  `(\`gh issue view ${parsed.number} -R ${parsed.repo}\`), implement it completely, ` +
525
525
  `and open exactly one pull request that closes it.`,
526
526
  },
527
- // Follow-on knobs, read by the record/converge workers. `autoMerge` maps to submitPr's
528
- // `convergeOnly` (inverted): converge-only stops at `converged`; auto-merge drives the
529
- // merge-loop. `converge=false` ⇒ merge is moot.
527
+ // Follow-on knobs, read by the record/converge workers. `autoMerge` is the canonical
528
+ // positive merge intent (issue #916), threaded to `submitPr` as-is: converge-only stops at
529
+ // `converged`; auto-merge drives the merge-loop. `converge=false` ⇒ merge is moot.
530
530
  converge,
531
531
  autoMerge,
532
532
  // Park the converged PR at a human approval user task before the merge-loop (issue #826).
@@ -215,6 +215,8 @@ test("pollFeatureDelivery: the re-enroll keeps the run's human approval gate (is
215
215
  await pollFeatureDelivery(data, engine);
216
216
 
217
217
  assertEquals(vars?.convergeOnly, false);
218
+ // Issue #916: the healed enrollment also carries the canonical positive merge-intent variable.
219
+ assertEquals(vars?.autoMerge, true);
218
220
  assertEquals(vars?.humanApproval, true, "a healed enrollment must not silently drop the approval gate");
219
221
  });
220
222
 
@@ -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