@nanobpm/nano-workforce 0.206.0 → 0.206.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/github.test.ts +43 -0
- package/app/github.ts +11 -1
- package/app/mergeLoopBehaviour.test.ts +215 -4
- package/app/pollReviewsTerminal.test.ts +175 -0
- package/app/service.ts +62 -0
- package/build-info.json +4 -4
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +6 -1
- package/workers/merge/worker.test.ts +99 -46
- package/workers/merge/worker.ts +27 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.206.2](https://github.com/nanobpm/nano-workforce/compare/v0.206.1...v0.206.2) (2026-11-09)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **merge:** let a DIRTY state outrank absent required checks so a conflicting PR reaches the rebase arm ([#930](https://github.com/nanobpm/nano-workforce/issues/930)) ([71984d5](https://github.com/nanobpm/nano-workforce/commit/71984d5d607151b2c3690a12d6b05537b24c3ece)), closes [#392](https://github.com/nanobpm/nano-workforce/issues/392) [nanobpm/nano-bpm#1444](https://github.com/nanobpm/nano-bpm/issues/1444) [#929](https://github.com/nanobpm/nano-workforce/issues/929)
|
|
6
|
+
|
|
7
|
+
## [0.206.1](https://github.com/nanobpm/nano-workforce/compare/v0.206.0...v0.206.1) (2026-11-08)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **merge:** fail the review backstop closed and retire review waits on merged PRs ([#928](https://github.com/nanobpm/nano-workforce/issues/928)) ([973c161](https://github.com/nanobpm/nano-workforce/commit/973c161d8b279db21915601647c5e8c43f3cac2f)), closes [nano-bpm#1467](https://github.com/nanobpm/nano-bpm/issues/1467) [#927](https://github.com/nanobpm/nano-workforce/issues/927)
|
|
12
|
+
|
|
1
13
|
## [0.206.0](https://github.com/nanobpm/nano-workforce/compare/v0.205.2...v0.206.0) (2026-11-08)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/app/github.test.ts
CHANGED
|
@@ -719,6 +719,49 @@ const MERGE_CASES: MergeCase[] = [
|
|
|
719
719
|
protocol: protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true }),
|
|
720
720
|
want: "waiting",
|
|
721
721
|
},
|
|
722
|
+
// ── DIRTY outranks an ABSENT/PENDING required check (issue #929) ─────────────────────────────
|
|
723
|
+
// GitHub does not run `pull_request` workflows on a PR with merge conflicts, so a DIRTY head
|
|
724
|
+
// reports its declared-required checks as ABSENT (or skipped). Mapping that absence to `waiting`
|
|
725
|
+
// deadlocks the merge loop: the checks can never arrive until the conflict is resolved, and the
|
|
726
|
+
// conflict is never routed to the rebase arm because the checks read as "not yet run". The
|
|
727
|
+
// conflict is ground truth whose remedy (a rebase) is also exactly what re-triggers CI, so DIRTY
|
|
728
|
+
// outranks absence — but NOT a red declared-required check, which still routes to fix-ci.
|
|
729
|
+
{
|
|
730
|
+
name: "DIRTY + declared-required check ABSENT -> conflict, not waiting (the #929 deadlock)",
|
|
731
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "summary", conclusion: "SKIPPED" }] },
|
|
732
|
+
protocol: protocolWith({ requiredChecks: reqChecks("typecheck + test (Node)", "whole-repo invariants") }),
|
|
733
|
+
want: "conflict",
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: "DIRTY + no check runs at all -> conflict (absence is a consequence of the conflict)",
|
|
737
|
+
state: { mergeStateStatus: "DIRTY" },
|
|
738
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true }),
|
|
739
|
+
want: "conflict",
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "DIRTY + declared-required check PENDING -> conflict (rebase, then CI re-runs on the new head)",
|
|
743
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "build", conclusion: "QUEUED" }] },
|
|
744
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
745
|
+
want: "conflict",
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
name: "DIRTY + declared-required check present-but-unconcluded -> conflict",
|
|
749
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "build", conclusion: "IN_PROGRESS" }] },
|
|
750
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
751
|
+
want: "conflict",
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: "DIRTY + RED declared-required check -> blocked (a red check still outranks the conflict)",
|
|
755
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "build", conclusion: "FAILURE" }] },
|
|
756
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
757
|
+
want: "blocked",
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
name: "DIRTY + green declared-required checks -> conflict (unchanged pre-#929 behaviour)",
|
|
761
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "build", conclusion: "SUCCESS" }] },
|
|
762
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
763
|
+
want: "conflict",
|
|
764
|
+
},
|
|
722
765
|
];
|
|
723
766
|
|
|
724
767
|
for (const c of MERGE_CASES) {
|
package/app/github.ts
CHANGED
|
@@ -1621,7 +1621,6 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
|
|
|
1621
1621
|
// settled-thread routing below.
|
|
1622
1622
|
const gate = requiredChecksVerdict(s, protocol);
|
|
1623
1623
|
if (gate === "blocked") return "blocked";
|
|
1624
|
-
if (gate === "waiting") return "waiting";
|
|
1625
1624
|
// A GitHub-identified merge conflict (`mergeStateStatus: "DIRTY"`) is ground truth whose remedy is
|
|
1626
1625
|
// a rebase, dispatched off the `conflict` verdict. It outranks the thread gate below (PR #922
|
|
1627
1626
|
// review, thread 1): a FAILED thread probe (`THREAD_PROBE_FAILED`) returning `waiting` here would
|
|
@@ -1630,7 +1629,18 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
|
|
|
1630
1629
|
// round that cannot clear it. Surface the conflict first; the threads are re-triaged once the
|
|
1631
1630
|
// rebased head is re-read. (A red declared-required check still outranks even this — see the
|
|
1632
1631
|
// backstop above — so a conflicting PR with failing CI routes to fix-ci, not a doomed rebase.)
|
|
1632
|
+
//
|
|
1633
|
+
// DIRTY also outranks a `waiting` required-checks verdict (issue #929): GitHub does NOT run
|
|
1634
|
+
// `pull_request` workflows on a PR with merge conflicts, so a conflicting head reports its
|
|
1635
|
+
// declared-required checks as ABSENT (or skipped) — and `requiredChecksVerdict` maps absence to
|
|
1636
|
+
// `waiting`. Evaluated in the old order that deadlocked the merge loop: the checks can never
|
|
1637
|
+
// arrive until the conflict is resolved, and the conflict was never routed to the rebase arm
|
|
1638
|
+
// because the absent checks read as "not yet run". The absence is a CONSEQUENCE of the conflict,
|
|
1639
|
+
// and the conflict's remedy (a rebase) is exactly what re-triggers CI on the new head — so the
|
|
1640
|
+
// conflict owns the verdict. Only a RED declared-required check (`blocked`, handled above) may
|
|
1641
|
+
// still outrank DIRTY.
|
|
1633
1642
|
if (s.mergeStateStatus === "DIRTY") return "conflict";
|
|
1643
|
+
if (gate === "waiting") return "waiting";
|
|
1634
1644
|
// The merge-stage thread gate (issue #921, PR #922 review): a read that opted into the
|
|
1635
1645
|
// review-threads probe and found ≥1 open thread (`unresolvedThreads > 0`) is NEVER merge-ready,
|
|
1636
1646
|
// whatever `mergeStateStatus` says. A repo without branch-protection
|
|
@@ -498,7 +498,9 @@ test("the 'waiting' re-poll is bounded — exhausting mergeStallMax escalates to
|
|
|
498
498
|
const engine = await boot({
|
|
499
499
|
vars: { mergeStallMax: 0 },
|
|
500
500
|
responses: {
|
|
501
|
-
|
|
501
|
+
// The probe always emits the full tri-state (a failed read reports reviewThreads: -1) — the
|
|
502
|
+
// escalation wording reads it, so the test payload must carry it.
|
|
503
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
502
504
|
},
|
|
503
505
|
});
|
|
504
506
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -509,6 +511,186 @@ test("the 'waiting' re-poll is bounded — exhausting mergeStallMax escalates to
|
|
|
509
511
|
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("wait-mergeable-repoll", "merge-stall-probe", "merge-esc-conflict");
|
|
510
512
|
});
|
|
511
513
|
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// #929 — a DIRTY PR whose required checks are ABSENT (GitHub skips pull_request
|
|
516
|
+
// workflows on a conflicting head) must route to the rebase arm, never to the
|
|
517
|
+
// misleading "all checks green … needs approval" escalation.
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
test("a DIRTY PR with absent required checks reaches SP_rebase via the stall probe (#929)", async () => {
|
|
521
|
+
// The incident (nanobpm/nano-bpm#1444): REST reported mergeable=false/dirty and every
|
|
522
|
+
// declared-required check was ABSENT (GitHub skips CI on a conflicting head), yet the loop
|
|
523
|
+
// escalated "needs approval" twice and never dispatched the rebase. With the classifier fix,
|
|
524
|
+
// the stall probe re-derives `conflict` and the token reaches the rebase agent.
|
|
525
|
+
const engine = await boot({
|
|
526
|
+
responses: {
|
|
527
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
528
|
+
"senior:rebase": { status: "rebased" },
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
532
|
+
// The poller's pre-fix verdict was `waiting` (absent required checks outranked DIRTY), so the
|
|
533
|
+
// instance is parked on the mergeable wait when the stall timer fires.
|
|
534
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
535
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
536
|
+
.isActive()
|
|
537
|
+
.hasActiveElement("wait-mergeable")
|
|
538
|
+
.hasCompletedElements("merge-stall-probe", "rebase") // the rebase agent ran — SP_rebase was reached
|
|
539
|
+
.hasVariable("rebaseRound", 1);
|
|
540
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "a DIRTY PR must rebase, not escalate 'needs approval'");
|
|
541
|
+
const openTasks = await engine.searchUserTasks({});
|
|
542
|
+
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "no human escalation on the rebase path");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("a stall escalation on a DIRTY PR never claims 'all checks green' — it names the conflict (#929)", async () => {
|
|
546
|
+
// The pre-fix escalation built "all checks green (none failing, none pending)" from
|
|
547
|
+
// `failingChecks = 0 and pendingChecks = 0` — but on a DIRTY head those zeros mean GitHub
|
|
548
|
+
// SKIPPED the required checks, not that they passed. The approval wording must stay gated on a
|
|
549
|
+
// PROBED-and-clear thread count; a DIRTY PR (whose `conflict` verdict is only reachable via the
|
|
550
|
+
// rebase arm) that still lands here keeps the conservative wording instead of the false claim.
|
|
551
|
+
const engine = await boot({
|
|
552
|
+
vars: { mergeStallMax: 0, rebaseMax: 0 },
|
|
553
|
+
responses: {
|
|
554
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
558
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
559
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
560
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
561
|
+
const q = String(escalation(engine).question ?? "");
|
|
562
|
+
assert(!q.includes("all checks green"), "absent/unprobed checks must never read as 'all checks green'");
|
|
563
|
+
assert(!q.includes("required review approval"), "a conflict must never read as a review-approval wait");
|
|
564
|
+
assertStringIncludes(q, "merge conflict", "the conflict escalation must name the conflict");
|
|
565
|
+
// PR #930 review: the verdict alone does not establish that the required checks are ABSENT —
|
|
566
|
+
// a conflicting head can carry successful required checks, and a failed read reports the same
|
|
567
|
+
// zeros — so the escalation may only note that GitHub MAY not have run them, never assert it.
|
|
568
|
+
assert(!q.includes("absent, NOT green"), "the verdict alone never establishes that the required checks are absent");
|
|
569
|
+
assertStringIncludes(q, "may not have run", "the escalation notes the check-skip caveat without asserting it");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("a conflict-verdict escalation with GREEN required checks still names only the conflict (#930 review)", async () => {
|
|
573
|
+
// The mirror case the review called out: `conflict` with every required check successful. The
|
|
574
|
+
// escalation must not claim the checks are absent or not-green — only the conflict is known.
|
|
575
|
+
const engine = await boot({
|
|
576
|
+
vars: { mergeStallMax: 0, rebaseMax: 0 },
|
|
577
|
+
responses: {
|
|
578
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
582
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
583
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
584
|
+
const q = String(escalation(engine).question ?? "");
|
|
585
|
+
assertStringIncludes(q, "merge conflict", "the conflict escalation must name the conflict");
|
|
586
|
+
assert(!q.includes("NOT green"), "green required checks must never be described as not-green");
|
|
587
|
+
assert(!q.includes("all checks green"), "the escalation asserts the conflict, not the check state either way");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("a stalled 'waiting' with clear threads and zero checks never asserts the required checks ran (#930 review)", async () => {
|
|
591
|
+
// `waiting` + reviewThreads>0 + failing=0/pending=0 is ambiguous: the required checks may be
|
|
592
|
+
// absent (never ran on a conflicting head) rather than green. The escalation must float both
|
|
593
|
+
// candidates — a conflict whose checks never ran, or a review-approval requirement — and send
|
|
594
|
+
// the human to the PR, never assert "no failing or pending checks" as if the rollup were green.
|
|
595
|
+
const engine = await boot({
|
|
596
|
+
vars: { mergeStallMax: 0 },
|
|
597
|
+
responses: {
|
|
598
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
602
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
603
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
604
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
605
|
+
const q = String(escalation(engine).question ?? "");
|
|
606
|
+
assert(!q.includes("all checks green"), "zero counts with clear threads must never claim green checks");
|
|
607
|
+
assert(!q.includes("NO failing or pending checks"), "zero counts do not establish that the required checks ran");
|
|
608
|
+
assert(!q.includes("required review approval is outstanding"), "an ambiguous read must never assert a verified review-approval blocker");
|
|
609
|
+
assertStringIncludes(q, "not verified", "the approval blocker must be floated, not asserted");
|
|
610
|
+
assertStringIncludes(q, "may never have run", "the escalation must allow that the required checks never ran");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("a stalled 'waiting' with unresolved threads reports their count and asks to resolve them (#930 review)", async () => {
|
|
614
|
+
// Absent required checks can yield `waiting` before the thread gate runs; the verified unresolved
|
|
615
|
+
// threads must be surfaced, since resolving a conflict or approving does not clear them.
|
|
616
|
+
const engine = await boot({
|
|
617
|
+
vars: { mergeStallMax: 0 },
|
|
618
|
+
responses: {
|
|
619
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 2, pendingChecks: 0 },
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
623
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
624
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
625
|
+
const q = String(escalation(engine).question ?? "");
|
|
626
|
+
assertStringIncludes(q, "2 unresolved review thread(s)", "the verified thread count must be reported");
|
|
627
|
+
assertStringIncludes(q, "Resolve those conversations", "the operator must be asked to resolve the threads");
|
|
628
|
+
assertStringIncludes(q, "may never have run", "the check state stays unverified");
|
|
629
|
+
assert(!q.includes("all checks green"), "never claim green checks");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("a stall escalation after a FAILED GitHub read says the state could not be read — never a confirmed conflict (#930 review)", async () => {
|
|
633
|
+
// The stall probe's catch maps a failed read to `waiting`, reviewThreads -1, zero checks — the
|
|
634
|
+
// exact payload the pre-fix wording read as a confirmed conflict. The escalation must report an
|
|
635
|
+
// unreadable state and nothing more specific.
|
|
636
|
+
const engine = await boot({
|
|
637
|
+
vars: { mergeStallMax: 0 },
|
|
638
|
+
responses: {
|
|
639
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
643
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
644
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
645
|
+
const q = String(escalation(engine).question ?? "");
|
|
646
|
+
assertStringIncludes(q, "could not be fully read", "a failed read must be named as such");
|
|
647
|
+
assert(!q.includes("CONFLICTING"), "a failed read must never assert a confirmed conflict");
|
|
648
|
+
assert(!q.includes("required review approval"), "a failed read must never assert a review-approval blocker");
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("a stall give-up with no remediation attempted never claims a remediation budget was exhausted (#930 review)", async () => {
|
|
652
|
+
for (const mergeState of ["conflict", "blocked"]) {
|
|
653
|
+
const engine = await boot({
|
|
654
|
+
vars: { mergeStallMax: 0 },
|
|
655
|
+
responses: {
|
|
656
|
+
"pr.merge-stall-probe": { mergeState, failingChecks: mergeState === "blocked" ? 1 : 0, failingChecksList: mergeState === "blocked" ? "ci" : "", reviewThreads: 0, pendingChecks: 0 },
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
660
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
661
|
+
const q = String(escalation(engine).question ?? "");
|
|
662
|
+
if (q === "") continue; // routed to a remediation agent rather than the direct give-up
|
|
663
|
+
assert(!/budget|exhausted/.test(q), `${mergeState}: the escalation must not claim an exhausted budget: ${q}`);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("answering 'retry' on a DIRTY PR's escalation re-arms the poller, which re-derives conflict and rebases (#929)", async () => {
|
|
668
|
+
// The incident's second half: a human answered "retry" and the loop re-classified the same way
|
|
669
|
+
// and escalated again — the rebase was never reached. Post-fix the retry re-arms the poller; the
|
|
670
|
+
// next verdict for the still-DIRTY PR is `conflict`, which dispatches the rebase agent.
|
|
671
|
+
const engine = await boot({
|
|
672
|
+
vars: { mergeStallMax: 0 },
|
|
673
|
+
responses: {
|
|
674
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
675
|
+
"senior:rebase": { status: "rebased" },
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
679
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
680
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
681
|
+
// The human replies "retry" — the answer reconciles then re-arms the poller.
|
|
682
|
+
await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "retry" });
|
|
683
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
684
|
+
// The re-armed poller now classifies the DIRTY PR as `conflict` (absent required checks no
|
|
685
|
+
// longer outrank DIRTY) and the rebase agent is dispatched.
|
|
686
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "conflict" } });
|
|
687
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
688
|
+
.isActive()
|
|
689
|
+
.hasActiveElement("wait-mergeable")
|
|
690
|
+
.hasCompletedElements("record-merge-answer", "rebase")
|
|
691
|
+
.hasVariable("rebaseRound", 1);
|
|
692
|
+
});
|
|
693
|
+
|
|
512
694
|
test("an unclassified verdict routes to auto-rebase (DEFAULT), not to a human (#774)", async () => {
|
|
513
695
|
// The DEFAULT arm is a last-resort self-heal, not a page: an unexpected/garbage `mergeState` routes
|
|
514
696
|
// to the bounded rebase agent (which reconciles from ground truth), never straight to a human.
|
|
@@ -637,7 +819,7 @@ test("the 'never settled' question is reserved for a genuinely unreadable/unsett
|
|
|
637
819
|
const engine = await boot({
|
|
638
820
|
vars: { mergeStallMax: 0 },
|
|
639
821
|
responses: {
|
|
640
|
-
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1 },
|
|
822
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
641
823
|
},
|
|
642
824
|
});
|
|
643
825
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -645,8 +827,8 @@ test("the 'never settled' question is reserved for a genuinely unreadable/unsett
|
|
|
645
827
|
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
646
828
|
assertStringIncludes(
|
|
647
829
|
String(escalation(engine).question ?? ""),
|
|
648
|
-
"
|
|
649
|
-
"a genuinely unsettled/unreadable mergeability
|
|
830
|
+
"could not be fully read",
|
|
831
|
+
"a genuinely unsettled/unreadable mergeability names the unreadable state",
|
|
650
832
|
);
|
|
651
833
|
});
|
|
652
834
|
|
|
@@ -871,6 +1053,31 @@ test("a conflict runs the bounded rebase agent and a rebased result re-arms the
|
|
|
871
1053
|
.hasVariable("rebaseRound", 1); // the rebase counter advanced across the sub-process boundary
|
|
872
1054
|
});
|
|
873
1055
|
|
|
1056
|
+
test("a rebase agent cannot spoof the escalation verdict by returning mergeState (#930 self-review)", async () => {
|
|
1057
|
+
// The agent's result keys are hoisted into process scope, and `mergeState` now selects the
|
|
1058
|
+
// shared merge-esc-conflict wording. A rebase agent that returns a bogus `mergeState:"ready"`
|
|
1059
|
+
// must NOT be able to rewrite a "could not resolve the conflict" escalation into a
|
|
1060
|
+
// green-checks/approval message: the trusted `end_reb_blocked` end event re-stamps the verdict.
|
|
1061
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "blocked", mergeState: "ready" } } });
|
|
1062
|
+
await driveToRebase(engine);
|
|
1063
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
1064
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
1065
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "rebase agent could not resolve", "the question must name the conflict trigger, not the spoofed verdict");
|
|
1066
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasVariable("mergeState", "conflict");
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
test("a fix-ci agent cannot spoof the escalation verdict by returning mergeState (#930 self-review)", async () => {
|
|
1070
|
+
// Same guard on the CI-fix side: a fix-ci agent that exhausts its budget after returning a bogus
|
|
1071
|
+
// `mergeState` must still escalate with the CI-fix wording — `end_ci_esc` re-stamps the verdict.
|
|
1072
|
+
const engine = await boot({ responses: { "senior:fix-ci": { status: "blocked", pushed: true, mergeState: "ready" } } });
|
|
1073
|
+
await driveToCiFix(engine);
|
|
1074
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
1075
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
1076
|
+
const q = String(escalation(engine).question ?? "");
|
|
1077
|
+
assertStringIncludes(q, "could not make the required checks green", "the question must name the CI-fix failure, not the spoofed verdict");
|
|
1078
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasVariable("mergeState", "blocked");
|
|
1079
|
+
});
|
|
1080
|
+
|
|
874
1081
|
test("a rebase that cannot resolve escalates with a conflict question", async () => {
|
|
875
1082
|
const engine = await boot({ responses: { "senior:rebase": { status: "blocked" } } });
|
|
876
1083
|
await driveToRebase(engine);
|
|
@@ -967,6 +1174,10 @@ test("the rebase agent SLA interrupts the sub-process and escalates as not-merge
|
|
|
967
1174
|
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
|
|
968
1175
|
// #879 regression: the trusted rebase SLA arm must deliver its job-type marker to the shared sink.
|
|
969
1176
|
assertEquals(escalation(engine).agentSlaJobType, "senior:rebase", "the rebase SLA arm must reach the recorder with its job-type marker");
|
|
1177
|
+
// The SLA escalation must never surface the stale `conflict` verdict's wording — the recorder's
|
|
1178
|
+
// SLA builder (keyed on the trusted marker) owns the question, not the verdict FEEL.
|
|
1179
|
+
const q = String(escalation(engine).question ?? "");
|
|
1180
|
+
assert(!q.includes("CONFLICTING") && !q.includes("merge conflict with the base branch"), "an SLA escalation must not read as a verdict escalation");
|
|
970
1181
|
});
|
|
971
1182
|
|
|
972
1183
|
test("a non-SLA escalation carrying a spoofed agentSlaJobType is scrubbed before the recorder (#879 fail-closed)", async () => {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Regression for issue #927 §3 (nano-bpm#1467): after the PR merged, the convergence loop kept
|
|
2
|
+
// re-requesting Copilot reviews on the MERGED PR for six rounds and finally escalated a review
|
|
3
|
+
// timeout. `pollReviews` must check the PR's live state before re-soliciting: a merged/closed PR
|
|
4
|
+
// retires the parked convergence instance and closes the row out — no reviewer-request POST.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
7
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
8
|
+
import { pollReviews } from "./service.ts";
|
|
9
|
+
|
|
10
|
+
const REPO = "owner/repo";
|
|
11
|
+
const NUMBER = 9;
|
|
12
|
+
|
|
13
|
+
function memTable(rows: any[], key: string, lookups: unknown[] = []) {
|
|
14
|
+
return {
|
|
15
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
16
|
+
all: () => Promise.resolve([...rows]),
|
|
17
|
+
find: (q: any) => Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
18
|
+
findOne: (q: any) => {
|
|
19
|
+
lookups.push(q);
|
|
20
|
+
return Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null);
|
|
21
|
+
},
|
|
22
|
+
insert: (r: any) => {
|
|
23
|
+
rows.push(r);
|
|
24
|
+
return Promise.resolve(r);
|
|
25
|
+
},
|
|
26
|
+
update: (k: any, patch: any) => {
|
|
27
|
+
const r = rows.find((x) => x[key] === k);
|
|
28
|
+
if (r) Object.assign(r, patch);
|
|
29
|
+
return Promise.resolve(r);
|
|
30
|
+
},
|
|
31
|
+
delete: () => Promise.resolve(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Opts = { reviewsFail?: boolean; reviews?: unknown[]; cancelFails?: boolean; searchState?: string | null };
|
|
36
|
+
|
|
37
|
+
async function run(prState: { merged: boolean; state: string }, opts: Opts = {}) {
|
|
38
|
+
const row: any = {
|
|
39
|
+
pr_key: `${REPO}#${NUMBER}`,
|
|
40
|
+
repo: REPO,
|
|
41
|
+
number: NUMBER,
|
|
42
|
+
status: "waiting_review",
|
|
43
|
+
last_review_id: 0,
|
|
44
|
+
waiting_since: "2026-11-08T15:08:58Z",
|
|
45
|
+
last_nudge_at: "2000-01-01T00:00:00Z",
|
|
46
|
+
process_key: "589000",
|
|
47
|
+
updated_at: "t0",
|
|
48
|
+
};
|
|
49
|
+
const planLookups: unknown[] = [];
|
|
50
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
51
|
+
pull_requests: { rows: [row], key: "pr_key" },
|
|
52
|
+
merges: { rows: [], key: "id" },
|
|
53
|
+
plan_tasks: { rows: [], key: "id" },
|
|
54
|
+
};
|
|
55
|
+
const data = {
|
|
56
|
+
table: withTrackingViews((name: string, key: string) => {
|
|
57
|
+
stores[name] ??= { rows: [], key };
|
|
58
|
+
return memTable(stores[name].rows, stores[name].key, name === "plan_tasks" ? planLookups : []);
|
|
59
|
+
}),
|
|
60
|
+
} as any;
|
|
61
|
+
const cancelled: string[] = [];
|
|
62
|
+
const published: string[] = [];
|
|
63
|
+
const engine = {
|
|
64
|
+
publishMessage: (m: { name: string }) => {
|
|
65
|
+
published.push(m.name);
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
},
|
|
68
|
+
cancelInstance: (a: { processInstanceKey: string }) => {
|
|
69
|
+
if (opts.cancelFails) return Promise.reject(new Error("transient engine error"));
|
|
70
|
+
cancelled.push(a.processInstanceKey);
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
},
|
|
73
|
+
searchProcessInstances: () =>
|
|
74
|
+
Promise.resolve(
|
|
75
|
+
opts.searchState === null ? [] : [{ processInstanceKey: "589000", state: opts.searchState ?? "ACTIVE" }],
|
|
76
|
+
),
|
|
77
|
+
} as any;
|
|
78
|
+
const posts: string[] = [];
|
|
79
|
+
const prevFetch = globalThis.fetch;
|
|
80
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
81
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
82
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
83
|
+
process.env["GITHUB_TOKEN"] = "test-token";
|
|
84
|
+
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
|
|
85
|
+
const u = String(url);
|
|
86
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
87
|
+
const json = (b: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(b), { status }));
|
|
88
|
+
if (method !== "GET") posts.push(`${method} ${u}`);
|
|
89
|
+
if (u.includes(`/pulls/${NUMBER}/requested_reviewers`)) return method === "GET" ? json({ users: [] }) : json({}, 201);
|
|
90
|
+
if (u.includes(`/pulls/${NUMBER}/reviews`) && opts.reviewsFail) return json({ message: "bad gateway" }, 502);
|
|
91
|
+
if (u.includes(`/pulls/${NUMBER}/reviews`)) return json(opts.reviews ?? []); // default: review requested, none arrived
|
|
92
|
+
if (u.includes("/git/ref")) return json({ object: { sha: "e597b399" } });
|
|
93
|
+
if (u.endsWith(`/pulls/${NUMBER}`)) return json({ ...prState, mergeable_state: "clean", head: { sha: "e597b399" } });
|
|
94
|
+
return json({}, 404);
|
|
95
|
+
}) as typeof fetch;
|
|
96
|
+
try {
|
|
97
|
+
await pollReviews(data, engine, "test-token");
|
|
98
|
+
} finally {
|
|
99
|
+
globalThis.fetch = prevFetch;
|
|
100
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
101
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
102
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
103
|
+
else delete process.env["GITHUB_TOKEN"];
|
|
104
|
+
}
|
|
105
|
+
return { row, posts, cancelled, published, stores, planLookups };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("pollReviews #927: a PR MERGED while awaiting review retires the loop — no review re-request", async () => {
|
|
109
|
+
const { row, posts, cancelled, published } = await run({ merged: true, state: "closed" });
|
|
110
|
+
assertEquals(posts.length, 0, "no reviewer-request POST on a merged PR");
|
|
111
|
+
assertEquals(cancelled, ["589000"], "the parked convergence instance is retired");
|
|
112
|
+
assertEquals(published.length, 0);
|
|
113
|
+
assertEquals(row.status, "merged");
|
|
114
|
+
assertEquals(typeof row.merged_at, "string");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("pollReviews #927: a PR CLOSED unmerged while awaiting review is abandoned — no review re-request", async () => {
|
|
118
|
+
const { row, posts, cancelled, stores } = await run({ merged: false, state: "closed" });
|
|
119
|
+
assertEquals(posts.length, 0);
|
|
120
|
+
assertEquals(cancelled, ["589000"]);
|
|
121
|
+
assertEquals((stores.merges.rows as any[]).some((m) => m.method === "pr-closed"), true);
|
|
122
|
+
assertEquals(row.status === "waiting_review", false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("pollReviews #927: an OPEN PR awaiting review is still re-solicited (control)", async () => {
|
|
126
|
+
const { row, posts, cancelled } = await run({ merged: false, state: "open" });
|
|
127
|
+
assertEquals(cancelled.length, 0);
|
|
128
|
+
assertEquals(row.status, "waiting_review");
|
|
129
|
+
assertEquals(posts.length, 1, "the review nudge still fires for a live PR");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const HEAD_CURRENT_REVIEW = [
|
|
133
|
+
{ id: 77, state: "COMMENTED", submitted_at: "2026-11-08T16:00:00Z", commit_id: "e597b399", user: { login: "copilot-pull-request-reviewer[bot]" } },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
for (const [label, prState] of [
|
|
137
|
+
["MERGED", { merged: true, state: "closed" }],
|
|
138
|
+
["CLOSED", { merged: false, state: "closed" }],
|
|
139
|
+
] as const) {
|
|
140
|
+
test(`pollReviews #927: a HEAD-CURRENT review on a ${label} PR retires the loop — never resumes a round`, async () => {
|
|
141
|
+
const { row, posts, cancelled, published } = await run(prState, { reviews: HEAD_CURRENT_REVIEW });
|
|
142
|
+
assertEquals(published.length, 0, "no readiness-ready published for a terminal PR");
|
|
143
|
+
assertEquals(posts.length, 0);
|
|
144
|
+
assertEquals(cancelled, ["589000"]);
|
|
145
|
+
assertEquals(row.status === "waiting_review" || row.status === "converging", false);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test("pollReviews #927: a failed cancel of a still-LIVE instance leaves the row pollable", async () => {
|
|
150
|
+
const { row, posts, published } = await run({ merged: true, state: "closed" }, { cancelFails: true, searchState: "ACTIVE" });
|
|
151
|
+
assertEquals(row.status, "waiting_review", "not finalized while the instance (and its timer) lives");
|
|
152
|
+
assertEquals(posts.length, 0, "no re-solicit this pass");
|
|
153
|
+
assertEquals(published.length, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
for (const searchState of ["COMPLETED", null] as const) {
|
|
157
|
+
test(`pollReviews #927: a failed cancel of an already-${searchState ?? "gone"} instance still finalizes`, async () => {
|
|
158
|
+
const { row } = await run({ merged: true, state: "closed" }, { cancelFails: true, searchState });
|
|
159
|
+
assertEquals(row.status, "merged");
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("pollReviews #927: a PR merged externally while awaiting review runs the retro hook", async () => {
|
|
164
|
+
// maybeStartRetro resolves the PR's plan via `plan_tasks`; the lookup proves the hook ran.
|
|
165
|
+
const { planLookups } = await run({ merged: true, state: "closed" });
|
|
166
|
+
assertEquals(planLookups, [{ pr_key: `${REPO}#${NUMBER}` }]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("pollReviews #927: a MERGED PR whose review fetch FAILS is still retired (terminal check precedes fetch)", async () => {
|
|
170
|
+
const { row, posts, cancelled, published } = await run({ merged: true, state: "closed" }, { reviewsFail: true });
|
|
171
|
+
assertEquals(cancelled, ["589000"]);
|
|
172
|
+
assertEquals(published.length, 0);
|
|
173
|
+
assertEquals(posts.length, 0);
|
|
174
|
+
assertEquals(row.status, "merged");
|
|
175
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -100,6 +100,7 @@ import { ENGINE_TERMINAL_STATES } from "./reconcile.ts";
|
|
|
100
100
|
// (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
|
|
101
101
|
// "./service.ts"` call sites (and its tests) keep resolving.
|
|
102
102
|
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
103
|
+
import { maybeStartRetro } from "./retro.ts";
|
|
103
104
|
import { clampNudgeMinutes, isoDurationToMs, isReviewStale, reviewWaitTimeout } from "./reviewWait.ts";
|
|
104
105
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
105
106
|
import {
|
|
@@ -1283,6 +1284,13 @@ export async function pollReviews(data: DataLayer, engine: EngineClient, token:
|
|
|
1283
1284
|
const { repo, number, pr_key: prKey } = pr;
|
|
1284
1285
|
const lastId = pr.last_review_id ?? 0;
|
|
1285
1286
|
try {
|
|
1287
|
+
// #927: a PR that already landed/closed must never resume or re-solicit a review — whatever
|
|
1288
|
+
// the review freshness (no review, stale review, OR a head-current one landing after the
|
|
1289
|
+
// merge). Check terminal state FIRST — before even fetching reviews, so a failing
|
|
1290
|
+
// reviews endpoint (e.g. 502) can't strand a merged PR in `waiting_review` with a live timer — so no branch below can route a merged PR back into a
|
|
1291
|
+
// review round. `null` → handled-but-not-finalized (cancel failed): skip this pass, stay pollable.
|
|
1292
|
+
const retired = await retireTerminalReviewWait(data, engine, pr, token);
|
|
1293
|
+
if (retired !== false) continue;
|
|
1286
1294
|
const reviews = await fetchPrReviews(repo, number, token);
|
|
1287
1295
|
if (reviews === null) return; // no usable transport (no gh, no token) → idle
|
|
1288
1296
|
const fresh = reviews
|
|
@@ -1342,6 +1350,60 @@ export async function pollReviews(data: DataLayer, engine: EngineClient, token:
|
|
|
1342
1350
|
}
|
|
1343
1351
|
}
|
|
1344
1352
|
|
|
1353
|
+
/** Review-wait out-of-band terminal check (issue #927). nano-bpm#1467 landed while its convergence
|
|
1354
|
+
* loop was parked at `wait-review`; the poller then kept re-requesting Copilot reviews on the MERGED
|
|
1355
|
+
* PR for six rounds and finally escalated a review timeout no human could act on. Before any
|
|
1356
|
+
* re-solicitation, read the PR's live state: if it is terminal (merged or closed) the review wait is
|
|
1357
|
+
* moot, so RETIRE the parked convergence instance (`process_key`) and close the row out — `merged`
|
|
1358
|
+
* (with `merged_at`) for a landed PR, the canonical `abandonClosedPr` for a closed-unmerged one.
|
|
1359
|
+
* Conservative: an unreadable/open state returns `false` and the caller re-solicits as before.
|
|
1360
|
+
* Exported for tests. */
|
|
1361
|
+
export async function retireTerminalReviewWait(
|
|
1362
|
+
data: DataLayer,
|
|
1363
|
+
engine: EngineClient,
|
|
1364
|
+
pr: PullRequest,
|
|
1365
|
+
token: string,
|
|
1366
|
+
): Promise<boolean | null> {
|
|
1367
|
+
const st = await fetchPrState(pr.repo, pr.number, token).catch(() => null);
|
|
1368
|
+
const liveness = classifyPrLiveness(st);
|
|
1369
|
+
if (liveness !== "merged" && liveness !== "closed") return false;
|
|
1370
|
+
if (pr.process_key != null) {
|
|
1371
|
+
const key = String(pr.process_key);
|
|
1372
|
+
try {
|
|
1373
|
+
await engine.cancelInstance({ processInstanceKey: key });
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
// Only finalize the row once the instance is confirmed gone/terminal: a transiently-failed
|
|
1376
|
+
// cancel would otherwise drop the row from `waiting_review` scans while the live instance's
|
|
1377
|
+
// review-timeout timer still escalates a human. Unconfirmed → stay pollable, retry next pass.
|
|
1378
|
+
let confirmedTerminal = false;
|
|
1379
|
+
try {
|
|
1380
|
+
const match = (await engine.searchProcessInstances({ processInstanceKeys: [key] })).find(
|
|
1381
|
+
(s) => String(s.processInstanceKey) === key,
|
|
1382
|
+
);
|
|
1383
|
+
confirmedTerminal = !match || ENGINE_TERMINAL_STATES.has(String(match.state ?? "").trim().toUpperCase());
|
|
1384
|
+
} catch {
|
|
1385
|
+
confirmedTerminal = false;
|
|
1386
|
+
}
|
|
1387
|
+
if (!confirmedTerminal) {
|
|
1388
|
+
console.error(`[poller] ${pr.pr_key}: retire review-wait instance ${key} failed, retrying next pass: ${err}`);
|
|
1389
|
+
return null;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (liveness === "merged") {
|
|
1394
|
+
const ts = now();
|
|
1395
|
+
await prs(data).update(pr.pr_key, { status: "merged", merged_at: ts, updated_at: ts });
|
|
1396
|
+
// Reuse the merged-completion side effect `pr.mark-merged`/`pr.finalize` run: an externally
|
|
1397
|
+
// merged PR may be its plan's last to settle, so its automatic retrospective must still start.
|
|
1398
|
+
// `maybeStartRetro` is best-effort and never throws.
|
|
1399
|
+
await maybeStartRetro(data, engine, pr.pr_key);
|
|
1400
|
+
} else {
|
|
1401
|
+
await abandonClosedPr(data, pr.pr_key, "PR was closed on GitHub while awaiting review — retiring the convergence loop");
|
|
1402
|
+
}
|
|
1403
|
+
console.log(`[poller] ${liveness} while waiting for review -> retired loop ${pr.pr_key}`);
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1345
1407
|
/** Ensure a Copilot review is in flight for a PR still waiting, throttled to one attempt per
|
|
1346
1408
|
* REVIEW_NUDGE_MS window. Skips when Copilot is already a pending reviewer (a review is coming);
|
|
1347
1409
|
* otherwise re-requests one and records the nudge. This is the primary liveness mechanism — the
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.206.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.206.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.206.2",
|
|
3
|
+
"gitSha": "71984d5d607151b2c3690a12d6b05537b24c3ece",
|
|
4
|
+
"gitTag": "v0.206.2",
|
|
5
|
+
"builtAt": "2026-11-09T11:18:01.340Z"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.206.
|
|
3
|
+
"version": "0.206.2",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -115,6 +115,9 @@
|
|
|
115
115
|
<zeebe:output source="=null" target="status" />
|
|
116
116
|
<zeebe:output source="=null" target="ciBlockedReconciled" />
|
|
117
117
|
<zeebe:output source="=null" target="agentSlaJobType" />
|
|
118
|
+
<zeebe:output source="=null" target="reviewThreads" />
|
|
119
|
+
<zeebe:output source="=null" target="failingChecks" />
|
|
120
|
+
<zeebe:output source="=null" target="pendingChecks" />
|
|
118
121
|
</zeebe:ioMapping>
|
|
119
122
|
</bpmn:extensionElements>
|
|
120
123
|
<bpmn:incoming>f_m_deps</bpmn:incoming>
|
|
@@ -263,7 +266,7 @@
|
|
|
263
266
|
</zeebe:properties>
|
|
264
267
|
<zeebe:ioMapping>
|
|
265
268
|
<zeebe:input source="="blocked"" target="status" />
|
|
266
|
-
<zeebe:input source="=if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else if mergeState = "waiting" and reviewThreads = 0 and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked with
|
|
269
|
+
<zeebe:input source="=if agentSlaJobType != null then "An automated merge-stage agent exceeded its time budget (SLA) before this PR could be merged. Check its progress or intervene, then reply to retry." else if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else if mergeState = "conflict" then "This PR has a merge conflict with the base branch (GitHub reports the branch as CONFLICTING / mergeable_state=dirty). GitHub may not have run the pull_request checks on the conflicting head, so their state is unknown from this signal — the conflict alone is what is established. Resolve the conflict on the branch (git rebase / merge the base branch and push) — the rebase re-triggers CI — then reply to retry." else if mergeState = "blocked" then "GitHub reports failing required checks on this PR, and the merge loop could not get them green automatically. Fix the failing checks on the branch (or reply with guidance), then reply to retry." else if mergeState = "waiting" and reviewThreads = 0 and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked with every review thread resolved and no failing or pending checks reported. The likely cause is a required review approval (or code-owner review), but that is not verified: the declared-required checks may never have run on this head (GitHub skips pull_request workflows on a conflicting branch), so their state is not established. Check the PR on GitHub — approve it (or adjust the review requirement), or resolve any conflict (a rebase re-triggers CI) — then reply to retry." else if mergeState = "waiting" and reviewThreads != null and reviewThreads > 0 and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked with " + string(reviewThreads) + " unresolved review thread(s). Resolve those conversations on GitHub. Separately, no failing or pending checks were reported, but the declared-required checks may never have run on this head (GitHub skips pull_request workflows on a conflicting branch), so their state is not established — the block may also be a merge conflict whose checks never ran, or a required review approval. Resolve the threads, then resolve any conflict (a rebase re-triggers CI) or approve — then reply to retry." else if mergeState = "waiting" and (reviewThreads = null or reviewThreads < 0) and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked and its GitHub state could not be fully read (the review-thread probe was unreadable or failed), so neither a conflict nor the check/approval state is established. Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry." else if mergeState = "waiting" then "GitHub never settled this PR's mergeability (still computing or checks still pending after repeated re-polls, or the state could not be read). Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry." else "This PR is not mergeable (state: " + mergeState + "). Resolve the conflict or failing required check on the branch, then reply to retry."" target="question" />
|
|
267
270
|
</zeebe:ioMapping>
|
|
268
271
|
</bpmn:extensionElements>
|
|
269
272
|
<bpmn:incoming>f_m_mDraft</bpmn:incoming>
|
|
@@ -528,6 +531,7 @@
|
|
|
528
531
|
<zeebe:ioMapping>
|
|
529
532
|
<zeebe:output source="="escalate"" target="ciOutcome" />
|
|
530
533
|
<zeebe:output source="=null" target="agentSlaJobType" />
|
|
534
|
+
<zeebe:output source="="blocked"" target="mergeState" />
|
|
531
535
|
</zeebe:ioMapping>
|
|
532
536
|
</bpmn:extensionElements>
|
|
533
537
|
<bpmn:incoming>f_cib_esc</bpmn:incoming>
|
|
@@ -684,6 +688,7 @@
|
|
|
684
688
|
<zeebe:ioMapping>
|
|
685
689
|
<zeebe:output source="="escalate-attempt"" target="rebaseOutcome" />
|
|
686
690
|
<zeebe:output source="=null" target="agentSlaJobType" />
|
|
691
|
+
<zeebe:output source="="conflict"" target="mergeState" />
|
|
687
692
|
</zeebe:ioMapping>
|
|
688
693
|
</bpmn:extensionElements>
|
|
689
694
|
<bpmn:incoming>f_reb_blocked</bpmn:incoming>
|
|
@@ -112,6 +112,13 @@ test("pr.merge short-circuits an already-merged PR without re-running the land p
|
|
|
112
112
|
// would stay green if the verb regressed.
|
|
113
113
|
type GithubCall = { url: string; method: string };
|
|
114
114
|
|
|
115
|
+
// A handler whose review backstop sees a VERIFIED head-current review, so land-protocol tests
|
|
116
|
+
// exercise the protocol dispatch (the backstop now fails CLOSED on an unreadable read, #927).
|
|
117
|
+
const reviewedHandler = makeHandler({
|
|
118
|
+
readReview: async () => ({ body: "lgtm", commitId: "reviewedhead" }),
|
|
119
|
+
readHeadSha: async () => "reviewedhead",
|
|
120
|
+
});
|
|
121
|
+
|
|
115
122
|
function withGithub(
|
|
116
123
|
routes: (url: string, init: RequestInit | undefined) => Response | null,
|
|
117
124
|
run: (calls: GithubCall[]) => Promise<void>,
|
|
@@ -153,7 +160,7 @@ test("pr.merge routes a mergify-queue repo through the queued branch (enqueue co
|
|
|
153
160
|
},
|
|
154
161
|
async (calls) => {
|
|
155
162
|
const { app, stores } = fakeApp();
|
|
156
|
-
const out = (await
|
|
163
|
+
const out = (await reviewedHandler(
|
|
157
164
|
{ variables: { prKey: "acme/widgets#7", repo: "acme/widgets", prNumber: 7 } } as any,
|
|
158
165
|
app,
|
|
159
166
|
)) as Record<string, unknown>;
|
|
@@ -189,7 +196,7 @@ test("pr.merge routes a refused merge through the blocked branch (escalation pay
|
|
|
189
196
|
},
|
|
190
197
|
async (calls) => {
|
|
191
198
|
const { app, stores } = fakeApp();
|
|
192
|
-
const out = (await
|
|
199
|
+
const out = (await reviewedHandler(
|
|
193
200
|
{ variables: { prKey: "acme/widgets#9", repo: "acme/widgets", prNumber: 9 } } as any,
|
|
194
201
|
app,
|
|
195
202
|
)) as Record<string, unknown>;
|
|
@@ -229,7 +236,7 @@ test("pr.merge routes a transient base-moved race through the retry branch (no e
|
|
|
229
236
|
},
|
|
230
237
|
async (calls) => {
|
|
231
238
|
const { app, stores } = fakeApp();
|
|
232
|
-
const out = (await
|
|
239
|
+
const out = (await reviewedHandler(
|
|
233
240
|
{ variables: { prKey: "acme/widgets#11", repo: "acme/widgets", prNumber: 11 } } as any,
|
|
234
241
|
app,
|
|
235
242
|
)) as Record<string, unknown>;
|
|
@@ -383,33 +390,63 @@ test("pr.merge #923: a HEAD-CURRENT review lands normally (backstop control)", a
|
|
|
383
390
|
);
|
|
384
391
|
});
|
|
385
392
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
393
|
+
for (const [label, readReview, readHeadSha] of [
|
|
394
|
+
["review read null", async () => null, async () => "h9"],
|
|
395
|
+
["review read throws", async () => { throw new Error("boom"); }, async () => "h9"],
|
|
396
|
+
["head read null", async () => ({ body: "lgtm", commitId: "h9" }), async () => null],
|
|
397
|
+
["head read throws", async () => ({ body: "lgtm", commitId: "h9" }), async () => { throw new Error("boom"); }],
|
|
398
|
+
["head read empty", async () => ({ body: "lgtm", commitId: "h9" }), async () => " "],
|
|
399
|
+
] as const) {
|
|
400
|
+
test(`pr.merge #927: an UNVERIFIABLE backstop read (${label}) fails CLOSED — no land, audited refusal`, async () => {
|
|
401
|
+
await withGithub(
|
|
402
|
+
(url) => {
|
|
403
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
404
|
+
return backstopRoutes(url);
|
|
405
|
+
},
|
|
406
|
+
async (calls) => {
|
|
407
|
+
const { app, stores } = fakeApp();
|
|
408
|
+
const backstop = makeHandler({ readReview, readHeadSha } as any);
|
|
409
|
+
const out = (await backstop(
|
|
410
|
+
{ variables: { prKey: "acme/widgets#27", repo: "acme/widgets", prNumber: 27 } } as any,
|
|
411
|
+
app,
|
|
412
|
+
)) as Record<string, unknown>;
|
|
413
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
414
|
+
assertEquals(out.status, "blocked");
|
|
415
|
+
assertEquals((out.question as string).includes("could not be read"), true);
|
|
416
|
+
assertEquals(stores.merges.length, 1);
|
|
417
|
+
assertEquals(stores.merges[0].outcome, "blocked");
|
|
418
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
419
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
420
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
404
425
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
426
|
+
test("pr.merge #927: reproduces nano-bpm#1467 — review REQUESTED but not yet arrived on a mergify-queue repo → no `@mergifyio queue`", async () => {
|
|
427
|
+
const protocol =
|
|
428
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
429
|
+
for (const readReview of [async () => ({ body: "", commitId: null }), async () => null]) {
|
|
430
|
+
await withGithub(
|
|
431
|
+
(url) => {
|
|
432
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
433
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
434
|
+
return backstopRoutes(url);
|
|
435
|
+
},
|
|
436
|
+
async (calls) => {
|
|
437
|
+
const { app, stores } = fakeApp();
|
|
438
|
+
const backstop = makeHandler({ readReview, readHeadSha: async () => "e597b399" } as any);
|
|
439
|
+
const out = (await backstop(
|
|
440
|
+
{ variables: { prKey: "acme/widgets#1467", repo: "acme/widgets", prNumber: 1467 } } as any,
|
|
441
|
+
app,
|
|
442
|
+
)) as Record<string, unknown>;
|
|
443
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
444
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
445
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
446
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
}
|
|
413
450
|
});
|
|
414
451
|
|
|
415
452
|
test("pr.merge #923: a head-current review whose summary reports OPEN findings blocks landing", async () => {
|
|
@@ -427,23 +464,6 @@ test("pr.merge #923: a head-current review whose summary reports OPEN findings b
|
|
|
427
464
|
});
|
|
428
465
|
});
|
|
429
466
|
|
|
430
|
-
for (const [label, readHeadSha] of [
|
|
431
|
-
["returns null", async () => null],
|
|
432
|
-
["throws", async () => { throw new Error("transient"); }],
|
|
433
|
-
] as const) {
|
|
434
|
-
test(`pr.merge #923: an unverifiable head (read ${label}) never blocks on a possibly-stale review's open findings`, async () => {
|
|
435
|
-
await withGithub(backstopRoutes, async () => {
|
|
436
|
-
const { app, stores } = fakeApp();
|
|
437
|
-
const backstop = makeHandler({
|
|
438
|
-
readReview: async () => ({ body: "### 🟡 Changes recommended\n<summary>2 open issues</summary>", commitId: "old" }),
|
|
439
|
-
readHeadSha,
|
|
440
|
-
});
|
|
441
|
-
await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app);
|
|
442
|
-
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
443
|
-
});
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
|
|
447
467
|
test("pr.merge #923: the direct merge pins the verified head SHA (no check-then-merge race)", async () => {
|
|
448
468
|
let putBody = "";
|
|
449
469
|
await withGithub(
|
|
@@ -485,3 +505,36 @@ test("pr.merge #923: a queue-comment repo whose head moved after the backstop re
|
|
|
485
505
|
},
|
|
486
506
|
);
|
|
487
507
|
});
|
|
508
|
+
|
|
509
|
+
for (const [label, finalRead] of [
|
|
510
|
+
["null", async () => null],
|
|
511
|
+
["throwing", async () => {
|
|
512
|
+
throw new Error("502");
|
|
513
|
+
}],
|
|
514
|
+
["blank", async () => " "],
|
|
515
|
+
] as const) {
|
|
516
|
+
test(`pr.merge #927: a queue-comment repo whose FINAL head re-read is ${label} retries instead of enqueueing`, async () => {
|
|
517
|
+
const protocol =
|
|
518
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
519
|
+
await withGithub(
|
|
520
|
+
(url) => {
|
|
521
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
522
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
523
|
+
return backstopRoutes(url);
|
|
524
|
+
},
|
|
525
|
+
async (calls) => {
|
|
526
|
+
const { app, stores } = fakeApp();
|
|
527
|
+
let n = 0;
|
|
528
|
+
const backstop = makeHandler({
|
|
529
|
+
readReview: async () => ({ body: "lgtm", commitId: "h3" }),
|
|
530
|
+
readHeadSha: async () => (n++ === 0 ? "h3" : finalRead()),
|
|
531
|
+
} as any);
|
|
532
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#34", repo: "acme/widgets", prNumber: 34 } } as any, app)) as Record<string, unknown>;
|
|
533
|
+
assertEquals(out.mergeStatus, "retry");
|
|
534
|
+
assertEquals(stores.merges.some((m) => m.outcome === "retry"), true, "retry audit row");
|
|
535
|
+
assertEquals(calls.some((c) => /\/issues\/\d+\/comments$/.test(c.url) && c.method === "POST"), false);
|
|
536
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false, "no merge call");
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
}
|
package/workers/merge/worker.ts
CHANGED
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
// it reviewed — a head that MOVED between convergence and landing, or a review that never existed
|
|
16
16
|
// (the #1443 fail-open), must not slip through at land time. Before running ANY land protocol this
|
|
17
17
|
// worker re-checks that a HEAD-CURRENT Copilot review exists; a missing/stale review blocks the
|
|
18
|
-
// landing (escalating to a human) instead of enqueueing/merging unreviewed code.
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// would wedge every merge on a network blip.
|
|
18
|
+
// landing (escalating to a human) instead of enqueueing/merging unreviewed code. An UNREADABLE
|
|
19
|
+
// review/head ALSO blocks (fails CLOSED, issue #927) — the old fail-open let nano-bpm#1467 land
|
|
20
|
+
// 49 s before its first review.
|
|
22
21
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
23
22
|
import { matchTags, tag } from "@nanobpm/urban/effect";
|
|
24
23
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
@@ -59,6 +58,9 @@ const defaultReadReview: MergeReviewReader = (repo, prNumber) =>
|
|
|
59
58
|
// bind (#786/#799), so the backstop's staleness comparison sees the exact head the gate did.
|
|
60
59
|
const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
|
|
61
60
|
|
|
61
|
+
const BLOCK_UNVERIFIABLE_REVIEW =
|
|
62
|
+
"the PR's Copilot review state or current HEAD could not be read, so a head-current reviewer verdict cannot be verified (the backstop fails CLOSED on an unverifiable read)";
|
|
63
|
+
|
|
62
64
|
const BLOCK_NO_HEAD_CURRENT_REVIEW =
|
|
63
65
|
"no head-current Copilot review exists for the PR's current HEAD (the head moved after the last review, or no review was ever submitted). A head-current reviewer verdict is required before landing";
|
|
64
66
|
|
|
@@ -132,26 +134,31 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
132
134
|
// existed at all (the nano-bpm#1443 fail-open: merged with ZERO reviews), must not slip through
|
|
133
135
|
// at land time. Runs AFTER the merged/closed short-circuits (an already-terminal PR needs no
|
|
134
136
|
// verdict) and BEFORE any land protocol (the `@mergifyio queue` comment is itself the landing
|
|
135
|
-
// for a queue repo). Fails
|
|
136
|
-
// hiccup → `null` review or head): a transient read failure must not wedge every merge — the
|
|
137
|
-
// merge-stage mergeability polling is the independent backstop there.
|
|
137
|
+
// for a queue repo). Fails CLOSED on an UNREADABLE review/head too (issue #927).
|
|
138
138
|
const [backstopReview, backstopHead] = await Promise.all([
|
|
139
139
|
readReview(repo, prNumber).catch(() => null),
|
|
140
140
|
readHeadSha(repo, prNumber).catch(() => null),
|
|
141
141
|
]);
|
|
142
|
-
|
|
142
|
+
// FAIL CLOSED (issue #927): a gate whose job is "never land unreviewed" must not land on an
|
|
143
|
+
// UNVERIFIABLE read. nano-bpm#1467 merged 49 s before its first Copilot review because a `null`
|
|
144
|
+
// review/head read (or an empty head) skipped this check entirely. An unreadable review or head now
|
|
145
|
+
// blocks (escalating to a human, who replies to retry) exactly like a missing/stale review.
|
|
146
|
+
const headTrimmed = (backstopHead ?? "").trim();
|
|
147
|
+
const unverifiable = backstopReview === null || headTrimmed === "";
|
|
148
|
+
if (unverifiable || isReviewMissingOrStale(backstopReview.commitId, headTrimmed)) {
|
|
149
|
+
const detail = unverifiable ? BLOCK_UNVERIFIABLE_REVIEW : BLOCK_NO_HEAD_CURRENT_REVIEW;
|
|
143
150
|
await app.data.table("merges", "id").insert({
|
|
144
151
|
pr_key: prKey,
|
|
145
152
|
outcome: "blocked",
|
|
146
153
|
method: "review-backstop",
|
|
147
|
-
detail
|
|
154
|
+
detail,
|
|
148
155
|
at: now,
|
|
149
156
|
});
|
|
150
157
|
return {
|
|
151
158
|
mergeStatus: "blocked",
|
|
152
159
|
status: "blocked",
|
|
153
160
|
question:
|
|
154
|
-
`Merge refused: ${
|
|
161
|
+
`Merge refused: ${detail}. ` +
|
|
155
162
|
`Push no further commits, wait for the Copilot review of the current HEAD to land (or request one: ` +
|
|
156
163
|
`gh api repos/${repo}/pulls/${prNumber}/requested_reviewers -X POST -f "reviewers[]=copilot-pull-request-reviewer[bot]"), ` +
|
|
157
164
|
`let the convergence loop gate on it, then reply to retry.`,
|
|
@@ -161,12 +168,8 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
161
168
|
// A head-current review whose OWN summary still reports open findings (the same canonical
|
|
162
169
|
// verdict parser the converge-gate uses) must block too: a summary-only "Changes recommended"
|
|
163
170
|
// review that landed after convergence has no unresolved thread for mergeability polling to see.
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
// possibly-stale review's body.
|
|
167
|
-
const reviewIsHeadCurrent =
|
|
168
|
-
backstopReview !== null && backstopHead !== null && backstopHead !== "" && backstopReview.commitId === backstopHead;
|
|
169
|
-
const openFindings = reviewIsHeadCurrent ? parseReviewOpenFindings(backstopReview.body) : [];
|
|
171
|
+
// Head-currentness is VERIFIED at this point (the fail-closed check above returned otherwise).
|
|
172
|
+
const openFindings = parseReviewOpenFindings(backstopReview.body);
|
|
170
173
|
if (openFindings.length > 0) {
|
|
171
174
|
const detail = `the head-current Copilot review reports open findings (${openFindings.join(", ")})`;
|
|
172
175
|
await app.data.table("merges", "id").insert({
|
|
@@ -186,7 +189,7 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
186
189
|
}
|
|
187
190
|
// The head the backstop verified — pinned through the land call so a push between this check
|
|
188
191
|
// and the merge can never land a later, unreviewed head.
|
|
189
|
-
const verifiedHead
|
|
192
|
+
const verifiedHead: string | undefined = headTrimmed;
|
|
190
193
|
|
|
191
194
|
// Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
|
|
192
195
|
// to the default branch — the merge would land into a dead branch and never reach `main`.
|
|
@@ -227,10 +230,14 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
|
|
|
227
230
|
const comment = protocol?.land.comment ?? "@mergifyio queue";
|
|
228
231
|
// A comment can't pin a SHA, so re-read the head right before enqueueing: if it moved past the
|
|
229
232
|
// verified head, re-enter via the bounded retry (which re-runs the backstop on the new head).
|
|
230
|
-
|
|
231
|
-
|
|
233
|
+
// Fail CLOSED (#927): an unreadable re-read retries rather than enqueueing an unverified head.
|
|
234
|
+
const headNow = await readHeadSha(repo, prNumber).catch(() => null);
|
|
235
|
+
if (headNow === null || headNow.trim() !== verifiedHead) {
|
|
232
236
|
outcome = "retry";
|
|
233
|
-
detail =
|
|
237
|
+
detail =
|
|
238
|
+
headNow === null
|
|
239
|
+
? `head could not be re-read after the review backstop; re-verifying before enqueue`
|
|
240
|
+
: `head moved from ${verifiedHead} to ${headNow} after the review backstop; re-verifying before enqueue`;
|
|
234
241
|
} else {
|
|
235
242
|
const ok = await enqueueViaComment(repo, prNumber, token, comment);
|
|
236
243
|
outcome = ok ? "queued" : "blocked";
|