@nanobpm/nano-workforce 0.205.0 → 0.205.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/convergeAdversarial.test.ts +54 -0
- package/app/convergeGate.test.ts +186 -6
- package/app/github.test.ts +285 -1
- package/app/github.ts +213 -11
- package/app/mergeBlockedThreads.test.ts +551 -0
- package/app/mergeLoopBehaviour.test.ts +141 -0
- package/app/mergeRetry.test.ts +49 -0
- package/app/readiness.ts +1 -0
- package/app/reviewWait.test.ts +37 -0
- package/app/reviewWait.ts +26 -0
- package/app/roundProgress.test.ts +63 -2
- package/app/roundProgress.ts +6 -0
- package/app/service.ts +139 -1
- package/app/userTasks.ts +1 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +5 -0
- package/resources/processes/merge-loop.bpmn +56 -10
- package/workers/converge-gate/worker.ts +61 -11
- package/workers/merge/worker.test.ts +192 -1
- package/workers/merge/worker.ts +118 -7
- package/workers/merge-stall-probe/worker.ts +17 -3
- package/workers/progress-check/worker.ts +40 -6
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).
|
|
1427
|
-
*
|
|
1428
|
-
|
|
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
|
|
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
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
|
|
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
|
|
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(
|
|
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
|