@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/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
  }
@@ -80,7 +80,7 @@ test("with humanApproval a converged PR parks for a human, and approve finalizes
80
80
  });
81
81
 
82
82
  test("request changes sends the guidance to the review agent on the same PR, then asks again", async () => {
83
- const { engine, answers } = await boot({ humanApproval: true });
83
+ const { engine, answers, processInstanceKey } = await boot({ humanApproval: true });
84
84
  const first = await openApproval(engine);
85
85
  assert(first !== undefined, "the approval task is open");
86
86
  await engine.completeUserTask(first, { mergeDecision: "revise", answer: "Rename foo to bar." });
@@ -92,6 +92,10 @@ test("request changes sends the guidance to the review agent on the same PR, the
92
92
  assertEquals(completions(engine, "persist-converged"), 0);
93
93
  const second = await openApproval(engine);
94
94
  assert(second !== undefined && second !== first, "approval is asked again after the revision");
95
+ // A revise is NOT an approval: the gate must stay set, so /status keeps reporting
96
+ // `blockedBy: "human-approval"` while the re-asked approval is outstanding (PR #917 review).
97
+ const [row] = await engine.searchVariables({ processInstanceKey: String(processInstanceKey), name: "humanApproval" });
98
+ assertEquals(row ? JSON.parse(row.value) : undefined, true, "a revise keeps the human-approval gate set");
95
99
  });
96
100
 
97
101
  test("a live ungated loop narrowed to humanApproval (a gated submitPr adopting it) parks for approval", async () => {
@@ -113,6 +117,21 @@ test("fail-closed: a completion without an explicit approve never merges", async
113
117
  assertEquals(completions(engine, "persist-converged"), 0);
114
118
  });
115
119
 
120
+ test("an approve clears the humanApproval gate while finalization is still pending (PR #917 review)", async () => {
121
+ // The /status merge projection derives `blockedBy: "human-approval"` from the loop's
122
+ // `humanApproval` variable alone, so an approved PR whose `pr.finalize` handoff is still queued
123
+ // (or incidented) would otherwise keep reporting a human-approval blocker even though the
124
+ // approval is complete. Hold `pr.finalize` so the loop stays ACTIVE after the approve, then read
125
+ // the variable: it must be cleared by the approval itself, not by some later task.
126
+ const { engine, processInstanceKey } = await boot({ humanApproval: true }, ["pr.finalize"]);
127
+ const key = await openApproval(engine);
128
+ assert(key !== undefined, "the approval task is open");
129
+ await engine.completeUserTask(key, { mergeDecision: "approve" });
130
+ loop(engine).isActive().hasNoIncident();
131
+ const [row] = await engine.searchVariables({ processInstanceKey: String(processInstanceKey), name: "humanApproval" });
132
+ assertEquals(row ? JSON.parse(row.value) : undefined, false, "approval complete — the blocker must clear");
133
+ });
134
+
116
135
  test("merge-approval is human-only, and its form requires guidance only for request changes", () => {
117
136
  assert(HUMAN_COMPLETABLE_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "the human completer accepts it");
118
137
  assert(!ESCALATION_TASK_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "an agent must never approve a merge");