@nanobpm/nano-workforce 0.205.0 → 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,9 @@
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
+
1
7
  ## [0.205.0](https://github.com/nanobpm/nano-workforce/compare/v0.204.1...v0.205.0) (2026-11-04)
2
8
 
3
9
  ### Features
@@ -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
package/app/github.ts CHANGED
@@ -862,6 +862,21 @@ export interface PrState {
862
862
  * perpetually-null entry on such a repo must NOT read as an eviction, so the classifier stays
863
863
  * conservative and leaves the `landedWaitTimeout` human backstop to cover a never-lands wedge. */
864
864
  mergeQueueEntry: boolean | null;
865
+ /** Count of UNRESOLVED review threads on the PR (GraphQL `reviewThreads{isResolved}`). Tri-state
866
+ * (PR #922 review):
867
+ * • `>= 0` — the probe was opted into (`withReviewThreads: true`, the merge-stage
868
+ * `waiting_merge` poll + the `merge-stall-probe` worker) and succeeded: the real count.
869
+ * • `-1` — NOT probed: the caller did not opt in, so threads are not part of its verdict.
870
+ * This keeps {@link classifyMergeability} on exactly its pre-#921 behaviour for every
871
+ * non-merge-stage caller (e.g. the readiness `pr` probe).
872
+ * • `-2` ({@link THREAD_PROBE_FAILED}) — the probe WAS opted into but the read failed, so the
873
+ * count is UNKNOWN. A failed read must never be conflated with `-1` (unprobed): an opted-in
874
+ * merge-stage read that couldn't verify threads is not merge-ready.
875
+ * This is the signal that classifies a `BLOCKED` merge state whose checks are all green: branch
876
+ * protection's `required_conversation_resolution` blocks the merge while any review thread is
877
+ * open, and those threads are REVIEW FINDINGS the convergence loop can act on — not a human
878
+ * problem (issue #921). */
879
+ unresolvedThreads: number;
865
880
  }
866
881
 
867
882
  /** Map GitHub's REST `mergeable_state` (lower-case) onto the GraphQL `mergeStateStatus`
@@ -1115,7 +1130,7 @@ export async function fetchPrState(
1115
1130
  repo: string,
1116
1131
  number: number | string,
1117
1132
  token: string,
1118
- opts?: { withMergeQueue?: boolean },
1133
+ opts?: { withMergeQueue?: boolean; withReviewThreads?: boolean },
1119
1134
  ): Promise<PrState | null> {
1120
1135
  if (await useGh()) {
1121
1136
  const out = await runGh([
@@ -1145,6 +1160,7 @@ export async function fetchPrState(
1145
1160
  const mergeQueueEntry = opts?.withMergeQueue
1146
1161
  ? await fetchMergeQueueMembership(repo, number, j.baseRefName ?? "", token)
1147
1162
  : null;
1163
+ const unresolvedThreads = await countUnresolvedThreads(repo, number, token, opts);
1148
1164
  return {
1149
1165
  merged,
1150
1166
  state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
@@ -1158,6 +1174,7 @@ export async function fetchPrState(
1158
1174
  isDraft: !!j.isDraft,
1159
1175
  headRefOid: j.headRefOid ?? null,
1160
1176
  mergeQueueEntry,
1177
+ unresolvedThreads,
1161
1178
  };
1162
1179
  }
1163
1180
  if (!token) return null;
@@ -1196,9 +1213,43 @@ export async function fetchPrState(
1196
1213
  isDraft: !!j.draft,
1197
1214
  headRefOid: j.head?.sha ?? null,
1198
1215
  mergeQueueEntry, // native-queue membership (GraphQL), probed only for block-4 reconciliation
1216
+ unresolvedThreads: await countUnresolvedThreads(repo, number, token, opts),
1199
1217
  };
1200
1218
  }
1201
1219
 
1220
+ /** Sentinel for an OPTED-IN review-threads probe that FAILED (PR #922 review). Distinct from the
1221
+ * `-1` "not probed" sentinel: `-1` means the caller never asked (threads are not part of its
1222
+ * verdict — the pre-#921 default); `THREAD_PROBE_FAILED` means the caller asked and the read
1223
+ * errored, so the unresolved-thread count is UNKNOWN. Both are not-merge-ready, but a failed
1224
+ * opted-in read must never be conflated with an unprobed one — a CLEAN PR whose probe errored
1225
+ * must NOT classify `ready` and merge over threads that were never verified resolved. */
1226
+ export const THREAD_PROBE_FAILED = -2;
1227
+
1228
+ /** Count a PR's unresolved review threads for the BLOCKED-cause classification (issue #921), but
1229
+ * ONLY when the caller opted in (`withReviewThreads: true`) — it is an extra GraphQL round-trip, so
1230
+ * every other caller keeps `-1` ("not probed"), the conservative value that leaves
1231
+ * {@link classifyMergeability} on its pre-#921 behaviour.
1232
+ *
1233
+ * Failure semantics (PR #922 review): the two `-1`-producing cases are NOT interchangeable, so an
1234
+ * OPTED-IN read that fails does NOT collapse to the unprobed `-1` sentinel — it returns
1235
+ * {@link THREAD_PROBE_FAILED} (`-2`) instead. `-1` means "this caller never asked, so threads are
1236
+ * not part of its verdict" (the pre-#921 default); `-2` means "this caller asked and the read
1237
+ * failed, so the thread count is UNKNOWN". The classifier treats both as not-merge-ready, but a
1238
+ * failed opted-in read must never be mistaken for an unprobed one — otherwise a CLEAN PR whose
1239
+ * thread probe errored would classify `ready` and merge over threads that were never verified
1240
+ * resolved. */
1241
+ async function countUnresolvedThreads(
1242
+ repo: string,
1243
+ number: number | string,
1244
+ token: string,
1245
+ opts?: { withReviewThreads?: boolean },
1246
+ ): Promise<number> {
1247
+ if (!opts?.withReviewThreads) return -1; // not probed — threads are not part of this caller's verdict
1248
+ const threads = await fetchReviewThreads(repo, number, token).catch(() => null);
1249
+ if (threads === null) return THREAD_PROBE_FAILED; // opted in but the read failed → UNKNOWN, not unprobed
1250
+ return threads.filter((t) => !t.isResolved).length;
1251
+ }
1252
+
1202
1253
  /** Map a PR's live GitHub state to one **liveness** verdict shared by both durable loops (merge +
1203
1254
  * convergence), so neither can ever escalate against a non-open PR (#342):
1204
1255
  *
@@ -1423,9 +1474,52 @@ export async function baseBranchLanded(
1423
1474
  /** A settled landability verdict, or `waiting` when GitHub hasn't determined it yet (or is
1424
1475
  * still running checks / awaiting review). `draft` is a settled *not-landable* verdict: a draft PR
1425
1476
  * can never be merged (GitHub refuses it outright), regardless of its checks — so it outranks every
1426
- * other signal and carries its own actionable remedy (mark it ready). The poller only advances the
1427
- * process on a settled verdict; `waiting` means re-poll later. */
1428
- export type Mergeability = "ready" | "waiting" | "conflict" | "blocked" | "draft";
1477
+ * other signal and carries its own actionable remedy (mark it ready). `review-threads` (issue #921)
1478
+ * is likewise a settled *not-landable* verdict: the merge is `BLOCKED` with every check green, but
1479
+ * ≥1 review thread is still unresolved (branch protection `required_conversation_resolution`) —
1480
+ * those threads are review findings the loop acts on, not a "mergeability never settled" stall.
1481
+ * The poller only advances the process on a settled verdict; `waiting` means re-poll later. */
1482
+ export type Mergeability = "ready" | "waiting" | "conflict" | "blocked" | "draft" | "review-threads";
1483
+
1484
+ /** WHY a `mergeStateStatus = BLOCKED` PR is blocked (issue #921). GitHub reports one flat `BLOCKED`
1485
+ * for several distinct causes, each with a different remedy — so the merge loop must classify the
1486
+ * cause instead of escalating the misleading "mergeability never settled":
1487
+ * • `"checks"` — a required check failed (the existing `blocked` verdict → fix-ci).
1488
+ * • `"review-threads"` — checks are green but ≥1 review thread is unresolved (branch protection
1489
+ * `required_conversation_resolution`); the threads are review findings the
1490
+ * loop sends back into convergence, not a human escalation.
1491
+ * • `"reviews"` — checks green, threads probed and ALL resolved, yet still BLOCKED: the
1492
+ * remaining causes are a required human review approval (or a code-owner
1493
+ * review) — nothing an automated loop can produce → escalate with THAT
1494
+ * reason named.
1495
+ * • `"unknown"` — the cause could not be determined (checks unenumerable in token mode,
1496
+ * or the thread read was unverifiable) → stays on the conservative
1497
+ * `waiting` re-poll, exactly as before #921. */
1498
+ export type BlockedCause = "checks" | "review-threads" | "reviews" | "unknown";
1499
+
1500
+ /** Classify why a `BLOCKED` PR is blocked. Pure; unit-tested. Only meaningful for a state read
1501
+ * that opted into the review-threads probe (`unresolvedThreads >= 0`); an unprobed read (`-1`) or a
1502
+ * FAILED probe (`THREAD_PROBE_FAILED`, `-2`) can never distinguish "no threads" from "couldn't read
1503
+ * threads", so it reports `"unknown"` and the caller keeps waiting — a failed read must never
1504
+ * fabricate a "reviews"-caused escalation. */
1505
+ export function classifyBlockedCause(s: PrState): BlockedCause {
1506
+ if (s.failingChecks > 0) return "checks";
1507
+ // Checks unenumerable (token mode, `failingChecks === -1`): "green checks" can't be confirmed, so
1508
+ // no thread count can settle the cause — stay `unknown` (the caller keeps waiting), exactly as
1509
+ // before #921. This guard must precede the thread read or a token-mode BLOCKED with open threads
1510
+ // would fabricate a `review-threads` verdict off an unverifiable "checks green" premise.
1511
+ if (s.failingChecks < 0) return "unknown";
1512
+ // Zero FAILING checks does not mean checks are green: queued/running checks are tracked
1513
+ // separately in `pendingCheckNames`. While any check is still in flight the BLOCKED cause is
1514
+ // undetermined — the pending run may come back red (→ `checks`) — so stay `unknown` and let the
1515
+ // caller keep waiting rather than assign a thread/approval cause off a half-settled rollup
1516
+ // (PR #922 review). This guard must precede the thread read for the same reason the
1517
+ // unenumerable-checks guard does: "checks green" is the premise every later cause stands on.
1518
+ if (s.pendingCheckNames.length > 0) return "unknown";
1519
+ if (s.unresolvedThreads > 0) return "review-threads";
1520
+ if (s.unresolvedThreads === 0) return "reviews";
1521
+ return "unknown";
1522
+ }
1429
1523
 
1430
1524
  /** Intersect a repo's declared `requiredChecks` against the head's actual per-check conclusions —
1431
1525
  * an INDEPENDENT backstop that runs BEFORE the `mergeStateStatus` switch, so nwf never merges a red
@@ -1485,13 +1579,57 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
1485
1579
  // ready. The poller self-heals this (mark-ready) when the repo's protocol wants a fresh head run,
1486
1580
  // else escalates with an actionable message.
1487
1581
  if (s.isDraft) return "draft";
1488
- // Protocol-aware backstop FIRST (issue #392): honour the repo's declared `requiredChecks`
1582
+ // Protocol-aware backstop (issue #392): honour the repo's declared `requiredChecks`
1489
1583
  // against the actual head rollup, so an `UNSTABLE` PR with a red DECLARED-required
1490
1584
  // check is no longer blindly `ready`. This never weakens GitHub branch protection (the switch
1491
1585
  // below still gates) — it only tightens merges on repos that under-specify their required checks.
1586
+ // It ALSO outranks the thread gate (PR #922 review, thread 2): a declared-required check that is
1587
+ // red (`blocked`) or not-yet-satisfied (`waiting` — e.g. a NEUTRAL build a success-only protocol
1588
+ // rejects, or an absent required check) owns remediation/waiting, so a convergence round must not
1589
+ // start on top of it. Consulting the verdict here keeps the protocol's decisions ahead of the
1590
+ // settled-thread routing below.
1492
1591
  const gate = requiredChecksVerdict(s, protocol);
1493
1592
  if (gate === "blocked") return "blocked";
1494
1593
  if (gate === "waiting") return "waiting";
1594
+ // A GitHub-identified merge conflict (`mergeStateStatus: "DIRTY"`) is ground truth whose remedy is
1595
+ // a rebase, dispatched off the `conflict` verdict. It outranks the thread gate below (PR #922
1596
+ // review, thread 1): a FAILED thread probe (`THREAD_PROBE_FAILED`) returning `waiting` here would
1597
+ // park a KNOWN conflict forever (neither the poller nor the stall probe dispatches the rebase), and
1598
+ // an OPEN thread count returning `review-threads`/`waiting` would route a conflict into a review
1599
+ // round that cannot clear it. Surface the conflict first; the threads are re-triaged once the
1600
+ // rebased head is re-read. (A red declared-required check still outranks even this — see the
1601
+ // backstop above — so a conflicting PR with failing CI routes to fix-ci, not a doomed rebase.)
1602
+ if (s.mergeStateStatus === "DIRTY") return "conflict";
1603
+ // The merge-stage thread gate (issue #921, PR #922 review): a read that opted into the
1604
+ // review-threads probe and found ≥1 open thread (`unresolvedThreads > 0`) is NEVER merge-ready,
1605
+ // whatever `mergeStateStatus` says. A repo without branch-protection
1606
+ // `required_conversation_resolution` reports such a PR CLEAN/UNSTABLE — mergeable by GitHub's
1607
+ // rules — but issue #921's merge contract is zero unresolved threads: those threads are review
1608
+ // findings the loop converges with, so the PR routes back into convergence instead of landing
1609
+ // over them. An UNPROBED read (`-1`) keeps exactly its pre-#921 behaviour — every non-merge-stage
1610
+ // caller (e.g. the readiness `pr` probe, readiness.ts `matchPr`) never opted in and is untouched.
1611
+ // A FAILED opted-in probe (`THREAD_PROBE_FAILED`, `-2`) is likewise never merge-ready: the thread
1612
+ // count is UNKNOWN, so the read holds `waiting` rather than merging over threads it could not
1613
+ // verify (PR #922 review, thread 1).
1614
+ //
1615
+ // This gate runs BEFORE the `mergeStateStatus` switch's `ready` arms so a known open thread can
1616
+ // never fall through to a `ready` (PR #922 review, thread 2) — but AFTER the conflict and
1617
+ // required-check verdicts above, so it can only ever decide between `ready` and a thread verdict,
1618
+ // never suppress a known conflict or a declared-required-check remediation. The verdict splits on
1619
+ // whether the checks are KNOWN settled:
1620
+ // • a failing check (`failingChecks > 0`) owns remediation → `blocked` (fix-ci), never a review
1621
+ // round on top of the CI problem;
1622
+ // • checks settled green (none failing, none pending, enumerable) → the threads OWN the
1623
+ // verdict: `review-threads` routes the PR back into a convergence round;
1624
+ // • otherwise (a pending check, or checks unenumerable in token mode, `failingChecks === -1`) →
1625
+ // the threads still block the merge but the check state owns the verdict, so hold `waiting`
1626
+ // and re-fire the gate on the next pass once the rollup settles.
1627
+ if (s.unresolvedThreads === THREAD_PROBE_FAILED) return "waiting";
1628
+ if (s.unresolvedThreads > 0) {
1629
+ if (s.failingChecks > 0) return "blocked";
1630
+ if (s.failingChecks === 0 && s.pendingCheckNames.length === 0) return "review-threads";
1631
+ return "waiting";
1632
+ }
1495
1633
  switch (s.mergeStateStatus) {
1496
1634
  case "CLEAN":
1497
1635
  case "HAS_HOOKS":
@@ -1501,9 +1639,23 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
1501
1639
  case "DIRTY":
1502
1640
  return "conflict";
1503
1641
  case "BLOCKED":
1504
- // A required check failed -> a human must act. Pending checks / awaiting review -> wait.
1505
- // When we can't enumerate checks (failingChecks < 0, token mode) stay conservative: wait.
1506
- return s.failingChecks > 0 ? "blocked" : "waiting";
1642
+ // Classify WHY GitHub blocks the merge (issue #921): a failed required check routes to
1643
+ // fix-ci; unresolved review threads (checks green, branch protection
1644
+ // `required_conversation_resolution`) are a settled not-landable verdict of their own —
1645
+ // `review-threads` — so the loop routes them back into a review round instead of waiting
1646
+ // out the stall budget and escalating the misleading "mergeability never settled". A BLOCKED
1647
+ // whose cause can't be read (checks unenumerable in token mode, or the threads probe
1648
+ // unreadable) stays conservative: wait. A BLOCKED with green checks and zero unresolved
1649
+ // threads (a required human review) also keeps waiting here — the merge-stall escalation
1650
+ // names that specific cause once the re-poll budget is exhausted.
1651
+ switch (classifyBlockedCause(s)) {
1652
+ case "checks":
1653
+ return "blocked";
1654
+ case "review-threads":
1655
+ return "review-threads";
1656
+ default:
1657
+ return "waiting";
1658
+ }
1507
1659
  default: // UNKNOWN / "" — GitHub is still computing mergeability
1508
1660
  return "waiting";
1509
1661
  }