@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/app/github.ts CHANGED
@@ -290,6 +290,37 @@ export function parseSuppressedAdvisories(reviewBody: string | null | undefined)
290
290
  return out;
291
291
  }
292
292
 
293
+ /** Read the reviewer VERDICT out of a Copilot review body (issue #923 §2): does the review's own
294
+ * summary report OPEN, actionable findings? Convergence requires a head-current verdict that is
295
+ * CLEAN — a review whose body says findings are still open must block the same way an unresolved
296
+ * thread does, even when every inline thread happens to be resolved (a review can carry findings
297
+ * with no unresolved threads at all: the nano-bpm#1443 review raised 7 findings as a summary-only
298
+ * "Changes recommended").
299
+ *
300
+ * Recognised open-finding signals (each returns a short human label for the block reason):
301
+ * • "Changes recommended" — Copilot's 🟡 verdict line (vs. the ✅ clean verdict);
302
+ * • "N open issue(s)" — the summary count Copilot emits when findings remain;
303
+ * • a ccr-overview-v2 `<summary>…Open (N)…</summary>` section (issue #835's format) with N ≥ 1.
304
+ * A body carrying NONE of these reports no open findings (empty array) — including the empty body
305
+ * of a comment-only review. Pure; unit-tested. */
306
+ export function parseReviewOpenFindings(reviewBody: string | null | undefined): string[] {
307
+ const body = reviewBody ?? "";
308
+ if (body.trim() === "") return [];
309
+ const out: string[] = [];
310
+ // Only VERDICT lines and dedicated finding-count <summary> sections count — never descriptive
311
+ // prose (a clean review's file table may legitimately say `Recognizes "Changes recommended"`).
312
+ // A verdict line STARTS with the verdict (`### 🟡 Changes recommended`, `🟡 Changes recommended`).
313
+ if (/^[ \t]*(?:#{1,6}[ \t]*)?(?:🟡[ \t]*)?changes recommended\b/imu.test(body)) out.push("changes recommended");
314
+ // Each <summary> match is bounded by its own closing tag ([^<]* cannot cross `</summary>`).
315
+ for (const m of body.matchAll(/<summary>(?:<strong>)?\s*(\d+)\s+open\s+issues?\s*(?:<\/strong>)?\s*<\/summary>/gi)) {
316
+ if (Number(m[1]) > 0) out.push(`${m[1]} open issue${m[1] === "1" ? "" : "s"}`);
317
+ }
318
+ for (const m of body.matchAll(/<summary>(?:<strong>)?\s*Open\s*\((\d+)\)\s*(?:<\/strong>)?\s*<\/summary>/gi)) {
319
+ if (Number(m[1]) > 0) out.push(`${m[1]} open finding${m[1] === "1" ? "" : "s"}`);
320
+ }
321
+ return out;
322
+ }
323
+
293
324
  /** A finding clause's trailing `(<severity>, N vote[s])` tag in the ccr-overview-v2 file table. */
294
325
  const V2_FINDING_TAG = /\s*\(\s*[a-z]+\s*,\s*\d+\s+votes?\s*\)/gi;
295
326
 
@@ -862,6 +893,21 @@ export interface PrState {
862
893
  * perpetually-null entry on such a repo must NOT read as an eviction, so the classifier stays
863
894
  * conservative and leaves the `landedWaitTimeout` human backstop to cover a never-lands wedge. */
864
895
  mergeQueueEntry: boolean | null;
896
+ /** Count of UNRESOLVED review threads on the PR (GraphQL `reviewThreads{isResolved}`). Tri-state
897
+ * (PR #922 review):
898
+ * • `>= 0` — the probe was opted into (`withReviewThreads: true`, the merge-stage
899
+ * `waiting_merge` poll + the `merge-stall-probe` worker) and succeeded: the real count.
900
+ * • `-1` — NOT probed: the caller did not opt in, so threads are not part of its verdict.
901
+ * This keeps {@link classifyMergeability} on exactly its pre-#921 behaviour for every
902
+ * non-merge-stage caller (e.g. the readiness `pr` probe).
903
+ * • `-2` ({@link THREAD_PROBE_FAILED}) — the probe WAS opted into but the read failed, so the
904
+ * count is UNKNOWN. A failed read must never be conflated with `-1` (unprobed): an opted-in
905
+ * merge-stage read that couldn't verify threads is not merge-ready.
906
+ * This is the signal that classifies a `BLOCKED` merge state whose checks are all green: branch
907
+ * protection's `required_conversation_resolution` blocks the merge while any review thread is
908
+ * open, and those threads are REVIEW FINDINGS the convergence loop can act on — not a human
909
+ * problem (issue #921). */
910
+ unresolvedThreads: number;
865
911
  }
866
912
 
867
913
  /** Map GitHub's REST `mergeable_state` (lower-case) onto the GraphQL `mergeStateStatus`
@@ -1115,7 +1161,7 @@ export async function fetchPrState(
1115
1161
  repo: string,
1116
1162
  number: number | string,
1117
1163
  token: string,
1118
- opts?: { withMergeQueue?: boolean },
1164
+ opts?: { withMergeQueue?: boolean; withReviewThreads?: boolean },
1119
1165
  ): Promise<PrState | null> {
1120
1166
  if (await useGh()) {
1121
1167
  const out = await runGh([
@@ -1145,6 +1191,7 @@ export async function fetchPrState(
1145
1191
  const mergeQueueEntry = opts?.withMergeQueue
1146
1192
  ? await fetchMergeQueueMembership(repo, number, j.baseRefName ?? "", token)
1147
1193
  : null;
1194
+ const unresolvedThreads = await countUnresolvedThreads(repo, number, token, opts);
1148
1195
  return {
1149
1196
  merged,
1150
1197
  state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
@@ -1158,6 +1205,7 @@ export async function fetchPrState(
1158
1205
  isDraft: !!j.isDraft,
1159
1206
  headRefOid: j.headRefOid ?? null,
1160
1207
  mergeQueueEntry,
1208
+ unresolvedThreads,
1161
1209
  };
1162
1210
  }
1163
1211
  if (!token) return null;
@@ -1196,9 +1244,43 @@ export async function fetchPrState(
1196
1244
  isDraft: !!j.draft,
1197
1245
  headRefOid: j.head?.sha ?? null,
1198
1246
  mergeQueueEntry, // native-queue membership (GraphQL), probed only for block-4 reconciliation
1247
+ unresolvedThreads: await countUnresolvedThreads(repo, number, token, opts),
1199
1248
  };
1200
1249
  }
1201
1250
 
1251
+ /** Sentinel for an OPTED-IN review-threads probe that FAILED (PR #922 review). Distinct from the
1252
+ * `-1` "not probed" sentinel: `-1` means the caller never asked (threads are not part of its
1253
+ * verdict — the pre-#921 default); `THREAD_PROBE_FAILED` means the caller asked and the read
1254
+ * errored, so the unresolved-thread count is UNKNOWN. Both are not-merge-ready, but a failed
1255
+ * opted-in read must never be conflated with an unprobed one — a CLEAN PR whose probe errored
1256
+ * must NOT classify `ready` and merge over threads that were never verified resolved. */
1257
+ export const THREAD_PROBE_FAILED = -2;
1258
+
1259
+ /** Count a PR's unresolved review threads for the BLOCKED-cause classification (issue #921), but
1260
+ * ONLY when the caller opted in (`withReviewThreads: true`) — it is an extra GraphQL round-trip, so
1261
+ * every other caller keeps `-1` ("not probed"), the conservative value that leaves
1262
+ * {@link classifyMergeability} on its pre-#921 behaviour.
1263
+ *
1264
+ * Failure semantics (PR #922 review): the two `-1`-producing cases are NOT interchangeable, so an
1265
+ * OPTED-IN read that fails does NOT collapse to the unprobed `-1` sentinel — it returns
1266
+ * {@link THREAD_PROBE_FAILED} (`-2`) instead. `-1` means "this caller never asked, so threads are
1267
+ * not part of its verdict" (the pre-#921 default); `-2` means "this caller asked and the read
1268
+ * failed, so the thread count is UNKNOWN". The classifier treats both as not-merge-ready, but a
1269
+ * failed opted-in read must never be mistaken for an unprobed one — otherwise a CLEAN PR whose
1270
+ * thread probe errored would classify `ready` and merge over threads that were never verified
1271
+ * resolved. */
1272
+ async function countUnresolvedThreads(
1273
+ repo: string,
1274
+ number: number | string,
1275
+ token: string,
1276
+ opts?: { withReviewThreads?: boolean },
1277
+ ): Promise<number> {
1278
+ if (!opts?.withReviewThreads) return -1; // not probed — threads are not part of this caller's verdict
1279
+ const threads = await fetchReviewThreads(repo, number, token).catch(() => null);
1280
+ if (threads === null) return THREAD_PROBE_FAILED; // opted in but the read failed → UNKNOWN, not unprobed
1281
+ return threads.filter((t) => !t.isResolved).length;
1282
+ }
1283
+
1202
1284
  /** Map a PR's live GitHub state to one **liveness** verdict shared by both durable loops (merge +
1203
1285
  * convergence), so neither can ever escalate against a non-open PR (#342):
1204
1286
  *
@@ -1423,9 +1505,52 @@ export async function baseBranchLanded(
1423
1505
  /** A settled landability verdict, or `waiting` when GitHub hasn't determined it yet (or is
1424
1506
  * still running checks / awaiting review). `draft` is a settled *not-landable* verdict: a draft PR
1425
1507
  * 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";
1508
+ * other signal and carries its own actionable remedy (mark it ready). `review-threads` (issue #921)
1509
+ * is likewise a settled *not-landable* verdict: the merge is `BLOCKED` with every check green, but
1510
+ * ≥1 review thread is still unresolved (branch protection `required_conversation_resolution`) —
1511
+ * those threads are review findings the loop acts on, not a "mergeability never settled" stall.
1512
+ * The poller only advances the process on a settled verdict; `waiting` means re-poll later. */
1513
+ export type Mergeability = "ready" | "waiting" | "conflict" | "blocked" | "draft" | "review-threads";
1514
+
1515
+ /** WHY a `mergeStateStatus = BLOCKED` PR is blocked (issue #921). GitHub reports one flat `BLOCKED`
1516
+ * for several distinct causes, each with a different remedy — so the merge loop must classify the
1517
+ * cause instead of escalating the misleading "mergeability never settled":
1518
+ * • `"checks"` — a required check failed (the existing `blocked` verdict → fix-ci).
1519
+ * • `"review-threads"` — checks are green but ≥1 review thread is unresolved (branch protection
1520
+ * `required_conversation_resolution`); the threads are review findings the
1521
+ * loop sends back into convergence, not a human escalation.
1522
+ * • `"reviews"` — checks green, threads probed and ALL resolved, yet still BLOCKED: the
1523
+ * remaining causes are a required human review approval (or a code-owner
1524
+ * review) — nothing an automated loop can produce → escalate with THAT
1525
+ * reason named.
1526
+ * • `"unknown"` — the cause could not be determined (checks unenumerable in token mode,
1527
+ * or the thread read was unverifiable) → stays on the conservative
1528
+ * `waiting` re-poll, exactly as before #921. */
1529
+ export type BlockedCause = "checks" | "review-threads" | "reviews" | "unknown";
1530
+
1531
+ /** Classify why a `BLOCKED` PR is blocked. Pure; unit-tested. Only meaningful for a state read
1532
+ * that opted into the review-threads probe (`unresolvedThreads >= 0`); an unprobed read (`-1`) or a
1533
+ * FAILED probe (`THREAD_PROBE_FAILED`, `-2`) can never distinguish "no threads" from "couldn't read
1534
+ * threads", so it reports `"unknown"` and the caller keeps waiting — a failed read must never
1535
+ * fabricate a "reviews"-caused escalation. */
1536
+ export function classifyBlockedCause(s: PrState): BlockedCause {
1537
+ if (s.failingChecks > 0) return "checks";
1538
+ // Checks unenumerable (token mode, `failingChecks === -1`): "green checks" can't be confirmed, so
1539
+ // no thread count can settle the cause — stay `unknown` (the caller keeps waiting), exactly as
1540
+ // before #921. This guard must precede the thread read or a token-mode BLOCKED with open threads
1541
+ // would fabricate a `review-threads` verdict off an unverifiable "checks green" premise.
1542
+ if (s.failingChecks < 0) return "unknown";
1543
+ // Zero FAILING checks does not mean checks are green: queued/running checks are tracked
1544
+ // separately in `pendingCheckNames`. While any check is still in flight the BLOCKED cause is
1545
+ // undetermined — the pending run may come back red (→ `checks`) — so stay `unknown` and let the
1546
+ // caller keep waiting rather than assign a thread/approval cause off a half-settled rollup
1547
+ // (PR #922 review). This guard must precede the thread read for the same reason the
1548
+ // unenumerable-checks guard does: "checks green" is the premise every later cause stands on.
1549
+ if (s.pendingCheckNames.length > 0) return "unknown";
1550
+ if (s.unresolvedThreads > 0) return "review-threads";
1551
+ if (s.unresolvedThreads === 0) return "reviews";
1552
+ return "unknown";
1553
+ }
1429
1554
 
1430
1555
  /** Intersect a repo's declared `requiredChecks` against the head's actual per-check conclusions —
1431
1556
  * an INDEPENDENT backstop that runs BEFORE the `mergeStateStatus` switch, so nwf never merges a red
@@ -1485,13 +1610,57 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
1485
1610
  // ready. The poller self-heals this (mark-ready) when the repo's protocol wants a fresh head run,
1486
1611
  // else escalates with an actionable message.
1487
1612
  if (s.isDraft) return "draft";
1488
- // Protocol-aware backstop FIRST (issue #392): honour the repo's declared `requiredChecks`
1613
+ // Protocol-aware backstop (issue #392): honour the repo's declared `requiredChecks`
1489
1614
  // against the actual head rollup, so an `UNSTABLE` PR with a red DECLARED-required
1490
1615
  // check is no longer blindly `ready`. This never weakens GitHub branch protection (the switch
1491
1616
  // below still gates) — it only tightens merges on repos that under-specify their required checks.
1617
+ // It ALSO outranks the thread gate (PR #922 review, thread 2): a declared-required check that is
1618
+ // red (`blocked`) or not-yet-satisfied (`waiting` — e.g. a NEUTRAL build a success-only protocol
1619
+ // rejects, or an absent required check) owns remediation/waiting, so a convergence round must not
1620
+ // start on top of it. Consulting the verdict here keeps the protocol's decisions ahead of the
1621
+ // settled-thread routing below.
1492
1622
  const gate = requiredChecksVerdict(s, protocol);
1493
1623
  if (gate === "blocked") return "blocked";
1494
1624
  if (gate === "waiting") return "waiting";
1625
+ // A GitHub-identified merge conflict (`mergeStateStatus: "DIRTY"`) is ground truth whose remedy is
1626
+ // a rebase, dispatched off the `conflict` verdict. It outranks the thread gate below (PR #922
1627
+ // review, thread 1): a FAILED thread probe (`THREAD_PROBE_FAILED`) returning `waiting` here would
1628
+ // park a KNOWN conflict forever (neither the poller nor the stall probe dispatches the rebase), and
1629
+ // an OPEN thread count returning `review-threads`/`waiting` would route a conflict into a review
1630
+ // round that cannot clear it. Surface the conflict first; the threads are re-triaged once the
1631
+ // rebased head is re-read. (A red declared-required check still outranks even this — see the
1632
+ // backstop above — so a conflicting PR with failing CI routes to fix-ci, not a doomed rebase.)
1633
+ if (s.mergeStateStatus === "DIRTY") return "conflict";
1634
+ // The merge-stage thread gate (issue #921, PR #922 review): a read that opted into the
1635
+ // review-threads probe and found ≥1 open thread (`unresolvedThreads > 0`) is NEVER merge-ready,
1636
+ // whatever `mergeStateStatus` says. A repo without branch-protection
1637
+ // `required_conversation_resolution` reports such a PR CLEAN/UNSTABLE — mergeable by GitHub's
1638
+ // rules — but issue #921's merge contract is zero unresolved threads: those threads are review
1639
+ // findings the loop converges with, so the PR routes back into convergence instead of landing
1640
+ // over them. An UNPROBED read (`-1`) keeps exactly its pre-#921 behaviour — every non-merge-stage
1641
+ // caller (e.g. the readiness `pr` probe, readiness.ts `matchPr`) never opted in and is untouched.
1642
+ // A FAILED opted-in probe (`THREAD_PROBE_FAILED`, `-2`) is likewise never merge-ready: the thread
1643
+ // count is UNKNOWN, so the read holds `waiting` rather than merging over threads it could not
1644
+ // verify (PR #922 review, thread 1).
1645
+ //
1646
+ // This gate runs BEFORE the `mergeStateStatus` switch's `ready` arms so a known open thread can
1647
+ // never fall through to a `ready` (PR #922 review, thread 2) — but AFTER the conflict and
1648
+ // required-check verdicts above, so it can only ever decide between `ready` and a thread verdict,
1649
+ // never suppress a known conflict or a declared-required-check remediation. The verdict splits on
1650
+ // whether the checks are KNOWN settled:
1651
+ // • a failing check (`failingChecks > 0`) owns remediation → `blocked` (fix-ci), never a review
1652
+ // round on top of the CI problem;
1653
+ // • checks settled green (none failing, none pending, enumerable) → the threads OWN the
1654
+ // verdict: `review-threads` routes the PR back into a convergence round;
1655
+ // • otherwise (a pending check, or checks unenumerable in token mode, `failingChecks === -1`) →
1656
+ // the threads still block the merge but the check state owns the verdict, so hold `waiting`
1657
+ // and re-fire the gate on the next pass once the rollup settles.
1658
+ if (s.unresolvedThreads === THREAD_PROBE_FAILED) return "waiting";
1659
+ if (s.unresolvedThreads > 0) {
1660
+ if (s.failingChecks > 0) return "blocked";
1661
+ if (s.failingChecks === 0 && s.pendingCheckNames.length === 0) return "review-threads";
1662
+ return "waiting";
1663
+ }
1495
1664
  switch (s.mergeStateStatus) {
1496
1665
  case "CLEAN":
1497
1666
  case "HAS_HOOKS":
@@ -1501,9 +1670,23 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
1501
1670
  case "DIRTY":
1502
1671
  return "conflict";
1503
1672
  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";
1673
+ // Classify WHY GitHub blocks the merge (issue #921): a failed required check routes to
1674
+ // fix-ci; unresolved review threads (checks green, branch protection
1675
+ // `required_conversation_resolution`) are a settled not-landable verdict of their own —
1676
+ // `review-threads` — so the loop routes them back into a review round instead of waiting
1677
+ // out the stall budget and escalating the misleading "mergeability never settled". A BLOCKED
1678
+ // whose cause can't be read (checks unenumerable in token mode, or the threads probe
1679
+ // unreadable) stays conservative: wait. A BLOCKED with green checks and zero unresolved
1680
+ // threads (a required human review) also keeps waiting here — the merge-stall escalation
1681
+ // names that specific cause once the re-poll budget is exhausted.
1682
+ switch (classifyBlockedCause(s)) {
1683
+ case "checks":
1684
+ return "blocked";
1685
+ case "review-threads":
1686
+ return "review-threads";
1687
+ default:
1688
+ return "waiting";
1689
+ }
1507
1690
  default: // UNKNOWN / "" — GitHub is still computing mergeability
1508
1691
  return "waiting";
1509
1692
  }
@@ -1513,6 +1696,10 @@ export type MergeMethod = "squash" | "merge" | "rebase";
1513
1696
  export interface MergeOptions {
1514
1697
  method: MergeMethod;
1515
1698
  admin: boolean;
1699
+ /** The head SHA the caller verified (issue #923 backstop). When set, GitHub refuses the merge if
1700
+ * the PR head moved since — gh `--match-head-commit`, REST `sha` — so a later, unreviewed head
1701
+ * can never be landed by a check-then-merge race. The refusal surfaces as a transient `retry`. */
1702
+ expectedHeadSha?: string;
1516
1703
  }
1517
1704
  export interface MergeResult {
1518
1705
  outcome: "merged" | "queued" | "blocked" | "retry";
@@ -1525,9 +1712,21 @@ export interface MergeResult {
1525
1712
  * must re-attempt on the settled base, NOT page a human. Matches GitHub's stable message across
1526
1713
  * both the GraphQL `mergePullRequest` error and its HTTP 405 REST variant. Kept narrow — the exact
1527
1714
  * "<Base|Head> branch was modified" phrase — so a genuine block (conflict, failing required check,
1528
- * 403 perms, 422 not-mergeable) is never swallowed as transient. */
1715
+ * 403 perms, 422 not-mergeable) is never swallowed as transient.
1716
+ *
1717
+ * The same class covers the `--match-head-commit` / REST `sha` pin refusal (issue #923 review): the
1718
+ * PR head advanced past the verified head between the review backstop and the merge mutation, so
1719
+ * GitHub refused the pinned merge. That is a check-then-act race the loop is designed to absorb —
1720
+ * the bounded merge retry re-runs the review backstop on the new head — not a human-actionable
1721
+ * block. gh words it "Head commit of pull request #… does not match '--match-head-commit'"; the
1722
+ * REST `sha` mismatch surfaces as a 409 whose body carries the same "Head branch was modified"
1723
+ * phrase (matched by the first alternative) or a "SHA does not match" wording. */
1529
1724
  export function isTransientMergeRace(detail: string): boolean {
1530
- return /\b(?:base|head) branch was modified\b/i.test(detail);
1725
+ return (
1726
+ /\b(?:base|head) branch was modified\b/i.test(detail) ||
1727
+ /does not match ['"]--match-head-commit['"]/i.test(detail) ||
1728
+ /\bsha does not match\b/i.test(detail)
1729
+ );
1531
1730
  }
1532
1731
 
1533
1732
  /** Attempt to land the PR. Returns `merged` (landed now), `queued` (added to the repo's merge
@@ -1545,6 +1744,7 @@ export async function mergePr(
1545
1744
  if (await useGh()) {
1546
1745
  const args = ["pr", "merge", String(number), "--repo", repo, methodFlag];
1547
1746
  if (opts.admin) args.push("--admin");
1747
+ if (opts.expectedHeadSha) args.push("--match-head-commit", opts.expectedHeadSha);
1548
1748
  try {
1549
1749
  const out = await runGh(args);
1550
1750
  // gh prints "… will be added to the merge queue" when the branch requires one.
@@ -1569,7 +1769,9 @@ export async function mergePr(
1569
1769
  accept: "application/vnd.github+json",
1570
1770
  "content-type": "application/json",
1571
1771
  },
1572
- body: JSON.stringify({ merge_method: opts.method }),
1772
+ body: JSON.stringify(
1773
+ opts.expectedHeadSha ? { merge_method: opts.method, sha: opts.expectedHeadSha } : { merge_method: opts.method },
1774
+ ),
1573
1775
  });
1574
1776
  if (r.ok) {
1575
1777
  // A 2xx from the REST merge endpoint does not guarantee the PR has *landed*: the body's