@nanobpm/nano-workforce 0.39.1 → 0.39.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.39.3](https://github.com/nanobpm/nano-workforce/compare/v0.39.2...v0.39.3) (2026-08-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **merge-loop:** judge fresh-head-run by required checks, not rollup length ([#113](https://github.com/nanobpm/nano-workforce/issues/113)) ([a10c4c5](https://github.com/nanobpm/nano-workforce/commit/a10c4c5b3ee5c452591eeb6c6206c4815674959a))
7
+
8
+ ## [0.39.2](https://github.com/nanobpm/nano-workforce/compare/v0.39.1...v0.39.2) (2026-08-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge:** recover PRs merged out-of-band instead of wedging in "merging" ([#112](https://github.com/nanobpm/nano-workforce/issues/112)) ([037d8aa](https://github.com/nanobpm/nano-workforce/commit/037d8aa2538b85f9c1b43927378f3578530a924e)), closes [Magikcraft/nano-bpm#723](https://github.com/Magikcraft/nano-bpm/issues/723)
14
+
1
15
  ## [0.39.1](https://github.com/nanobpm/nano-workforce/compare/v0.39.0...v0.39.1) (2026-08-10)
2
16
 
3
17
 
package/app/github.ts CHANGED
@@ -214,6 +214,12 @@ export interface PrState {
214
214
  * frugal-CI stuck state the fresh-head-run remedy targets); `-1` when the transport can't
215
215
  * enumerate checks (token mode). */
216
216
  totalChecks: number;
217
+ /** Names of every head check present in any state (pending/failed/passed). Empty in token mode
218
+ * (the REST fallback can't enumerate checks). Lets the fresh-head-run remedy judge whether the
219
+ * repo's *required* checks (per its merge protocol) are actually present on the head — an
220
+ * unrelated always-on check (e.g. Mergify's "Merge Queue") must not read as "the required run
221
+ * already happened". */
222
+ presentCheckNames: string[];
217
223
  /** Whether the PR is a draft (a fresh head run is produced by marking it ready, not reopen). */
218
224
  isDraft: boolean;
219
225
  /** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
@@ -248,6 +254,19 @@ function failingCheckNames(rollup: RollupEntry[]): string[] {
248
254
  return names;
249
255
  }
250
256
 
257
+ /** Names of every head check present, regardless of state. Covers both the CheckRun shape
258
+ * (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
259
+ * repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
260
+ * Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened. */
261
+ function allCheckNames(rollup: RollupEntry[]): string[] {
262
+ const names: string[] = [];
263
+ for (const c of rollup) {
264
+ const name = c.name || c.context || c.workflowName;
265
+ if (name) names.push(name);
266
+ }
267
+ return names;
268
+ }
269
+
251
270
  export async function fetchPrState(
252
271
  repo: string,
253
272
  number: number | string,
@@ -280,6 +299,7 @@ export async function fetchPrState(
280
299
  failingChecks: names.length,
281
300
  failingCheckNames: names,
282
301
  totalChecks: rollup.length,
302
+ presentCheckNames: allCheckNames(rollup),
283
303
  isDraft: !!j.isDraft,
284
304
  headRefOid: j.headRefOid ?? null,
285
305
  };
@@ -305,6 +325,7 @@ export async function fetchPrState(
305
325
  failingChecks: -1, // REST here doesn't enumerate checks → classifier treats BLOCKED as "wait"
306
326
  failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
307
327
  totalChecks: -1, // …and the fresh-head-run remedy stays conservative (never reopens blind)
328
+ presentCheckNames: [], // …can't enumerate checks in token mode → no required-check presence signal
308
329
  isDraft: !!j.draft,
309
330
  headRefOid: j.head?.sha ?? null,
310
331
  };
@@ -10,8 +10,10 @@ import {
10
10
  DEFAULT_MERGE_PROTOCOL,
11
11
  extractProtocolBlock,
12
12
  freshHeadRunAction,
13
+ headRunPresenceCount,
13
14
  type MergeProtocol,
14
15
  parseMergeProtocol,
16
+ presentRequiredCheckCount,
15
17
  } from "./mergeProtocol.ts";
16
18
 
17
19
  test("parseMergeProtocol: non-object / junk → defaults (total, never throws)", () => {
@@ -27,7 +29,10 @@ test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
27
29
  freshHeadRun: "ready-or-reopen",
28
30
  waitForChecks: true,
29
31
  land: { method: "mergify-queue", comment: "@mergifyio queue" },
30
- requiredChecks: ["rustfmt (pinned nightly)", "server (clippy + test)"],
32
+ requiredChecks: [
33
+ { name: "rustfmt (pinned nightly)", acceptedConclusions: ["success"] },
34
+ { name: "processos (clippy + test)", acceptedConclusions: ["success", "skipped"] },
35
+ ],
31
36
  doc: "AGENTS.md#merging-prs",
32
37
  });
33
38
  assertEquals(got.autoMerge, false);
@@ -35,20 +40,38 @@ test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
35
40
  assertEquals(got.waitForChecks, true);
36
41
  assertEquals(got.land, { method: "mergify-queue", comment: "@mergifyio queue" });
37
42
  assertEquals(got.requiredChecks.length, 2);
43
+ assertEquals(got.requiredChecks[0], { name: "rustfmt (pinned nightly)", acceptedConclusions: ["success"] });
44
+ assertEquals(got.requiredChecks[1].acceptedConclusions, ["success", "skipped"]);
38
45
  assertEquals(got.doc, "AGENTS.md#merging-prs");
39
46
  });
40
47
 
48
+ test("parseMergeProtocol: requiredChecks tolerates bare-string entries + drops nameless/junk", () => {
49
+ const got = parseMergeProtocol({
50
+ requiredChecks: [
51
+ "server (clippy + test)", // bare name → default acceptedConclusions ["success"]
52
+ { name: "engine-core (clippy + test)" }, // object, no acceptedConclusions → default
53
+ { name: "", acceptedConclusions: ["success"] }, // empty name → dropped
54
+ { acceptedConclusions: ["success"] }, // no name → dropped
55
+ 42, // junk → dropped
56
+ ],
57
+ });
58
+ assertEquals(got.requiredChecks, [
59
+ { name: "server (clippy + test)", acceptedConclusions: ["success"] },
60
+ { name: "engine-core (clippy + test)", acceptedConclusions: ["success"] },
61
+ ]);
62
+ });
63
+
41
64
  test("parseMergeProtocol: invalid enums / wrong types fall back per-field", () => {
42
65
  const got = parseMergeProtocol({
43
66
  autoMerge: "yes", // not a boolean → default
44
67
  freshHeadRun: "sometimes", // not in the enum → default (none)
45
68
  land: { method: "teleport" }, // not in the enum → default (gh-merge)
46
- requiredChecks: ["ok", 7, null], // keep only strings
69
+ requiredChecks: ["ok", 7, null], // keep only usable names
47
70
  });
48
71
  assertEquals(got.autoMerge, DEFAULT_MERGE_PROTOCOL.autoMerge);
49
72
  assertEquals(got.freshHeadRun, "none");
50
73
  assertEquals(got.land.method, "gh-merge");
51
- assertEquals(got.requiredChecks, ["ok"]);
74
+ assertEquals(got.requiredChecks, [{ name: "ok", acceptedConclusions: ["success"] }]);
52
75
  });
53
76
 
54
77
  test("parseMergeProtocol: comment dropped when absent", () => {
@@ -123,3 +146,52 @@ test("freshHeadRunAction: mode=ready only acts on drafts", () => {
123
146
  assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, true), "ready");
124
147
  assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, false), null); // not a draft → nothing to ready
125
148
  });
149
+
150
+ // The nano-bpm merge protocol: 3 required checks, one skip-tolerant.
151
+ const NANO_REQ: MergeProtocol = parseMergeProtocol({
152
+ freshHeadRun: "ready-or-reopen",
153
+ land: { method: "mergify-queue" },
154
+ requiredChecks: [
155
+ { name: "rustfmt (pinned nightly)", acceptedConclusions: ["success"] },
156
+ { name: "server (clippy + test)", acceptedConclusions: ["success"] },
157
+ { name: "processos (clippy + test)", acceptedConclusions: ["success", "skipped"] },
158
+ ],
159
+ });
160
+
161
+ test("presentRequiredCheckCount: counts only declared required checks present on the head", () => {
162
+ // Only an unrelated always-on check (Mergify) is present → zero required checks present.
163
+ assertEquals(presentRequiredCheckCount(NANO_REQ, ["Mergify Merge Queue"]), 0);
164
+ // Two of the three required checks present (plus the incidental Mergify one).
165
+ assertEquals(
166
+ presentRequiredCheckCount(NANO_REQ, ["Mergify Merge Queue", "server (clippy + test)", "rustfmt (pinned nightly)"]),
167
+ 2,
168
+ );
169
+ // A repo that declares no required checks → nothing to count.
170
+ assertEquals(presentRequiredCheckCount(DEFAULT_MERGE_PROTOCOL, ["anything"]), 0);
171
+ });
172
+
173
+ test("headRunPresenceCount: required-aware — Mergify's incidental check doesn't mask a missing run", () => {
174
+ // The #727 stuck state: BLOCKED head carries only Mergify's neutral check, none of the 3
175
+ // required checks ran. Raw rollup length is 1, but the required-check presence is 0 → the
176
+ // remedy must see 0 and fire the reopen.
177
+ const st = { totalChecks: 1, presentCheckNames: ["Mergify Merge Queue"] };
178
+ assertEquals(headRunPresenceCount(NANO_REQ, st), 0);
179
+ assertEquals(freshHeadRunAction(NANO_REQ, "waiting", headRunPresenceCount(NANO_REQ, st), false), "reopen");
180
+ });
181
+
182
+ test("headRunPresenceCount: a present required check reads as run-exists (no reopen)", () => {
183
+ const st = { totalChecks: 2, presentCheckNames: ["Mergify Merge Queue", "server (clippy + test)"] };
184
+ assertEquals(headRunPresenceCount(NANO_REQ, st), 1);
185
+ assertEquals(freshHeadRunAction(NANO_REQ, "waiting", headRunPresenceCount(NANO_REQ, st), false), null);
186
+ });
187
+
188
+ test("headRunPresenceCount: no declared required checks → falls back to total rollup length", () => {
189
+ const proto = parseMergeProtocol({ freshHeadRun: "ready-or-reopen", land: { method: "gh-merge" } });
190
+ assertEquals(headRunPresenceCount(proto, { totalChecks: 0, presentCheckNames: [] }), 0);
191
+ assertEquals(headRunPresenceCount(proto, { totalChecks: 3, presentCheckNames: ["a", "b", "c"] }), 3);
192
+ });
193
+
194
+ test("headRunPresenceCount: token mode (totalChecks < 0) stays conservative (-1)", () => {
195
+ assertEquals(headRunPresenceCount(NANO_REQ, { totalChecks: -1, presentCheckNames: [] }), -1);
196
+ assertEquals(freshHeadRunAction(NANO_REQ, "waiting", -1, false), null);
197
+ });
@@ -28,6 +28,16 @@ export type FreshHeadRun = "none" | "ready" | "reopen" | "ready-or-reopen";
28
28
  * `ui` = a human clicks Merge (Merlin can't do it → escalate). */
29
29
  export type LandMethod = "gh-merge" | "admin" | "mergify-queue" | "ui";
30
30
 
31
+ /** One required status check a repo declares in its merge protocol. `name` is the check-run /
32
+ * status-context name exactly as GitHub reports it in the head `statusCheckRollup`.
33
+ * `acceptedConclusions` are the conclusions that count as satisfied (default `["success"]`); a
34
+ * change-gated check that is skipped for irrelevant PRs also lists `"skipped"` so a skip counts
35
+ * as satisfied (required-when-run, skip-tolerant). */
36
+ export interface RequiredCheck {
37
+ name: string;
38
+ acceptedConclusions: string[];
39
+ }
40
+
31
41
  export interface MergeProtocol {
32
42
  /** Does the repo auto-merge a PR once its checks go green? (Informational; Merlin never relies
33
43
  * on auto-merge — it lands deliberately.) */
@@ -38,8 +48,10 @@ export interface MergeProtocol {
38
48
  waitForChecks: boolean;
39
49
  /** How to land the PR. */
40
50
  land: { method: LandMethod; comment?: string };
41
- /** Names of the required checks (informational; the poller reads live state). */
42
- requiredChecks: string[];
51
+ /** The checks that gate the merge. A repo publishing these lets the fresh-head-run remedy judge
52
+ * "is the required CI run present on the head?" by *these* checks — not by total rollup length,
53
+ * which an unrelated always-on check (e.g. Mergify's "Merge Queue") would otherwise satisfy. */
54
+ requiredChecks: RequiredCheck[];
43
55
  /** Pointer to the human doc, for escalation messages. */
44
56
  doc?: string;
45
57
  }
@@ -70,6 +82,30 @@ function strArray(v: unknown): string[] | undefined {
70
82
  if (!Array.isArray(v)) return undefined;
71
83
  return v.filter((x): x is string => typeof x === "string");
72
84
  }
85
+ /** Parse `requiredChecks`, tolerating both the rich object shape (`{ name, acceptedConclusions }`)
86
+ * and a bare list of check names (each → `{ name, acceptedConclusions: ["success"] }`). Entries
87
+ * without a usable `name` are dropped. Total — never throws. */
88
+ function requiredCheckArray(v: unknown): RequiredCheck[] {
89
+ if (!Array.isArray(v)) return [];
90
+ const out: RequiredCheck[] = [];
91
+ for (const entry of v) {
92
+ if (typeof entry === "string") {
93
+ const name = entry.trim();
94
+ if (name === "") continue;
95
+ out.push({ name, acceptedConclusions: ["success"] });
96
+ continue;
97
+ }
98
+ if (!isRecord(entry)) continue;
99
+ const name = str(entry.name)?.trim();
100
+ if (name === undefined || name === "") continue;
101
+ const accepted = strArray(entry.acceptedConclusions);
102
+ out.push({
103
+ name,
104
+ acceptedConclusions: accepted && accepted.length > 0 ? accepted : ["success"],
105
+ });
106
+ }
107
+ return out;
108
+ }
73
109
  function oneOf<T extends string>(v: unknown, allowed: ReadonlySet<string>): T | undefined {
74
110
  const s = str(v);
75
111
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
@@ -88,7 +124,7 @@ export function parseMergeProtocol(raw: unknown): MergeProtocol {
88
124
  freshHeadRun: oneOf<FreshHeadRun>(raw.freshHeadRun, FRESH_HEAD_RUNS) ?? DEFAULT_MERGE_PROTOCOL.freshHeadRun,
89
125
  waitForChecks: bool(raw.waitForChecks) ?? DEFAULT_MERGE_PROTOCOL.waitForChecks,
90
126
  land: comment !== undefined ? { method, comment } : { method },
91
- requiredChecks: strArray(raw.requiredChecks) ?? [],
127
+ requiredChecks: requiredCheckArray(raw.requiredChecks),
92
128
  doc: str(raw.doc),
93
129
  };
94
130
  }
@@ -159,27 +195,53 @@ export interface FreshHeadRunAttempt {
159
195
  lastActionHeadRefOid?: string | null;
160
196
  }
161
197
 
198
+ /** Count of the protocol's required checks currently present on the head (in any state). This is
199
+ * the signal the fresh-head-run remedy actually wants — "has the required CI run happened?" — as
200
+ * opposed to the raw rollup length, which an unrelated always-on check (e.g. Mergify's "Merge
201
+ * Queue") inflates. A required check matches by exact name against the head `statusCheckRollup`. */
202
+ export function presentRequiredCheckCount(protocol: MergeProtocol, presentCheckNames: string[]): number {
203
+ const present = new Set(presentCheckNames);
204
+ return protocol.requiredChecks.filter((c) => present.has(c.name)).length;
205
+ }
206
+
207
+ /** The "does a head run already exist?" count to feed {@link freshHeadRunAction}. When the repo
208
+ * declares `requiredChecks`, judge presence by *those* checks — so an incidental always-on check
209
+ * (Mergify) never masks a genuinely-missing required run and wedges the merge. When it declares
210
+ * none, fall back to the total rollup length (legacy behaviour, default repos unchanged). Token
211
+ * mode (`totalChecks < 0`, checks unenumerable) stays `-1` so the remedy remains conservative and
212
+ * never reopens blind. */
213
+ export function headRunPresenceCount(
214
+ protocol: MergeProtocol,
215
+ state: { totalChecks: number; presentCheckNames: string[] },
216
+ ): number {
217
+ if (state.totalChecks < 0) return -1; // token mode → unknown → conservative
218
+ if (protocol.requiredChecks.length === 0) return state.totalChecks;
219
+ return presentRequiredCheckCount(protocol, state.presentCheckNames);
220
+ }
221
+
162
222
  /** Whether the merge poller should produce a synthetic fresh head run *now*, and how.
163
223
  *
164
- * Fires only when the protocol asks for a fresh run AND the PR currently has **no head check run
165
- * at all** (`totalChecks === 0`) while GitHub still reports it un-landable-but-not-conflicting
224
+ * Fires only when the protocol asks for a fresh run AND the PR currently has **no required head
225
+ * run** (`headRunCount === 0`) while GitHub still reports it un-landable-but-not-conflicting
166
226
  * (`waiting`). That is exactly the frugal-CI stuck state: review converged, the last push produced
167
- * no run, so branch protection's required checks read as *expected* forever. Once a run exists for
168
- * the current head (`totalChecks > 0`, pending or done), or this same head already got its nudge,
169
- * this returns `null`, so the poller never re-triggers inside one landing attempt. A rebase changes
170
- * `headRefOid`, so the decision is re-derived and can fire again for the fresh post-rebase head.
171
- * A genuinely-failing check (`blocked`) is left to the fix-ci arm, a conflict (`conflict`) to the
172
- * rebase arm (#42). */
227
+ * no run, so branch protection's required checks read as *expected* forever. `headRunCount` is the
228
+ * required-check-aware presence count from {@link headRunPresenceCount} NOT the raw rollup
229
+ * length so an incidental always-on check (e.g. Mergify's "Merge Queue") does not read as "a run
230
+ * already exists". Once the required run is present (`headRunCount > 0`, pending or done), or this
231
+ * same head already got its nudge, this returns `null`, so the poller never re-triggers inside one
232
+ * landing attempt. A rebase changes `headRefOid`, so the decision is re-derived and can fire again
233
+ * for the fresh post-rebase head. A genuinely-failing check (`blocked`) is left to the fix-ci arm,
234
+ * a conflict (`conflict`) to the rebase arm (#42). */
173
235
  export function freshHeadRunAction(
174
236
  protocol: MergeProtocol,
175
237
  verdict: "ready" | "waiting" | "conflict" | "blocked",
176
- totalChecks: number,
238
+ headRunCount: number,
177
239
  isDraft: boolean,
178
240
  attempt: FreshHeadRunAttempt = {},
179
241
  ): "ready" | "reopen" | null {
180
242
  if (protocol.freshHeadRun === "none") return null;
181
243
  if (verdict !== "waiting") return null; // ready = go land; blocked/conflict = other arms
182
- if (totalChecks !== 0) return null; // a run already exists (or unknown in token mode) → wait
244
+ if (headRunCount !== 0) return null; // required run already present (or unknown in token mode) → wait
183
245
  if (attempt.headRefOid && attempt.headRefOid === attempt.lastActionHeadRefOid) return null;
184
246
  switch (protocol.freshHeadRun) {
185
247
  case "ready":
package/app/service.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  requestCopilotReview,
21
21
  } from "./github.ts";
22
22
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
23
- import { freshHeadRunAction, loadMergeProtocol } from "./mergeProtocol.ts";
23
+ import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
24
24
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
25
25
  import { plans, planTaskDeps, planTasks } from "./plan.ts";
26
26
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
@@ -668,11 +668,17 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
668
668
  const st = await fetchPrState(repo, number, token);
669
669
  if (st === null) continue; // no transport → skip this PR (others may still advance)
670
670
  if (st.merged) {
671
- // Landed out-of-band (someone merged it) skip straight to done.
671
+ // Landed out-of-band (a maintainer clicked Merge, a mergify queue merged it, etc.). The
672
+ // instance is parked at `wait-mergeable`, which subscribes to `merge-ready` — NOT
673
+ // `merge-landed` (that catch, `wait-landed`, only exists later, after we enqueue). Publishing
674
+ // `merge-landed` here has no subscription to correlate to, so the engine drops it and the PR
675
+ // wedges forever in the transient `merging` status (which no poller branch re-scans).
676
+ // Publish `merge-ready` with a `ready` verdict instead: it routes through `gw-mergeable` to
677
+ // `attempt-merge`, whose idempotent already-merged check completes the loop (`mark-merged`).
672
678
  await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
673
- name: "merge-landed",
679
+ name: "merge-ready",
674
680
  correlationKey: prKey,
675
- variables: {},
681
+ variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
676
682
  });
677
683
  console.log(`[poller] already merged -> ${prKey}`);
678
684
  continue;
@@ -680,14 +686,17 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
680
686
  const verdict = classifyMergeability(st);
681
687
  if (verdict === "waiting") {
682
688
  // Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
683
- // head run and the PR has NO head run at all, review has converged but the last push
689
+ // head run and the PR has NO required head run yet, review has converged but the last push
684
690
  // produced no CI run — so branch protection's required checks read as "expected" forever
685
- // and this PR would wait indefinitely. Produce a fresh `pull_request` run once per head
686
- // (mark ready / close+reopen); rebases change `headRefOid`, so downstream merge-train PRs
687
- // get a new nudge after every post-rebase landing attempt.
691
+ // and this PR would wait indefinitely. Judge "no run yet" by the protocol's *required*
692
+ // checks (headRunPresenceCount), not the raw rollup length, so an incidental always-on
693
+ // check (e.g. Mergify's "Merge Queue") doesn't mask a missing run. Produce a fresh
694
+ // `pull_request` run once per head (mark ready / close+reopen); rebases change
695
+ // `headRefOid`, so downstream merge-train PRs get a new nudge after every post-rebase
696
+ // landing attempt.
688
697
  const protocol = await loadMergeProtocol(repo, token).catch(() => null);
689
698
  if (protocol) {
690
- const action = freshHeadRunAction(protocol, verdict, st.totalChecks, st.isDraft, {
699
+ const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
691
700
  headRefOid: st.headRefOid,
692
701
  lastActionHeadRefOid: pr.fresh_head_run_head,
693
702
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.39.1",
3
+ "version": "0.39.3",
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",
@@ -45,7 +45,7 @@
45
45
  "lint:fix": "biome check --write app operations workers pages components scripts main.ts"
46
46
  },
47
47
  "dependencies": {
48
- "@nanobpm/urban": "^0.38.0"
48
+ "@nanobpm/urban": "^0.40.1"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "^2.4.11",
@@ -0,0 +1,96 @@
1
+ // Regression for the out-of-band merge wedge (Magikcraft/nano-bpm#723): when a PR is merged
2
+ // independently of the process (a maintainer clicks Merge, a mergify queue lands it), the poller
3
+ // routes the merge-loop instance back through `attempt-merge`. This worker must detect the
4
+ // already-merged state and complete the loop directly — NOT re-run the land protocol, which would post a
5
+ // spurious `@mergifyio queue` comment (mergify-queue repos) or a redundant merge call. Forces the
6
+ // token transport and stubs `globalThis.fetch` so the single-PR GET reports `merged: true`.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import handler from "./worker.ts";
10
+
11
+ function fakeApp() {
12
+ const stores: Record<string, Record<string, unknown>[]> = {
13
+ pull_requests: [],
14
+ merges: [],
15
+ };
16
+ return {
17
+ app: {
18
+ data: {
19
+ table(name: string, key: string) {
20
+ const store = (stores[name] ??= []);
21
+ return {
22
+ get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
23
+ find: (q: any) =>
24
+ Promise.resolve(
25
+ store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
26
+ ),
27
+ insert: (row: any) => {
28
+ store.push(row);
29
+ return Promise.resolve(store.length);
30
+ },
31
+ update: (k: any, patch: any) => {
32
+ const row = store.find((r) => r[key] === k);
33
+ if (row) Object.assign(row, patch);
34
+ return Promise.resolve(row);
35
+ },
36
+ };
37
+ },
38
+ },
39
+ log: () => undefined,
40
+ engine: {},
41
+ } as any,
42
+ stores,
43
+ };
44
+ }
45
+
46
+ function withMergedPr(run: (calls: string[]) => Promise<void>): Promise<void> {
47
+ const oldTransport = process.env["NANO_PR_GITHUB_TRANSPORT"];
48
+ const oldToken = process.env["GITHUB_TOKEN"];
49
+ const oldFetch = globalThis.fetch;
50
+ const calls: string[] = [];
51
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
52
+ process.env["GITHUB_TOKEN"] = "test-token";
53
+ globalThis.fetch = ((input: string | URL | Request) => {
54
+ const url = String(input);
55
+ calls.push(url);
56
+ // Single-PR GET → report the PR as already merged.
57
+ if (/\/pulls\/\d+$/.test(url)) {
58
+ return Promise.resolve(new Response(JSON.stringify({ merged: true, mergeable_state: "clean" })));
59
+ }
60
+ return Promise.resolve(new Response("not found", { status: 404 }));
61
+ }) as typeof fetch;
62
+ return run(calls).finally(() => {
63
+ if (oldTransport == null) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
64
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = oldTransport;
65
+ if (oldToken == null) delete process.env["GITHUB_TOKEN"];
66
+ else process.env["GITHUB_TOKEN"] = oldToken;
67
+ globalThis.fetch = oldFetch;
68
+ });
69
+ }
70
+
71
+ test("pr.merge short-circuits an already-merged PR without re-running the land protocol", async () => {
72
+ await withMergedPr(async (calls) => {
73
+ const { app, stores } = fakeApp();
74
+ const out = (await handler(
75
+ {
76
+ variables: {
77
+ prKey: "Magikcraft/nano-bpm#723",
78
+ repo: "Magikcraft/nano-bpm",
79
+ prNumber: 723,
80
+ },
81
+ } as any,
82
+ app,
83
+ )) as Record<string, unknown>;
84
+
85
+ // Completes the loop directly.
86
+ assertEquals(out, { mergeStatus: "merged" });
87
+
88
+ // Records exactly one audit row, tagged as the idempotent already-merged path.
89
+ assertEquals(stores.merges.length, 1);
90
+ assertEquals(stores.merges[0].outcome, "merged");
91
+ assertEquals(stores.merges[0].method, "already-merged");
92
+
93
+ // Never posts an enqueue comment or issues a merge call (only the read GET happened).
94
+ assertEquals(calls.some((u) => /comments|merge$/.test(u)), false);
95
+ });
96
+ });
@@ -11,7 +11,7 @@
11
11
  import type { AppJobHandler } from "@nanobpm/urban";
12
12
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
13
13
  import { checkBaseTarget } from "../../app/baseGuard.ts";
14
- import { enqueueViaComment, mergePr } from "../../app/github.ts";
14
+ import { enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
15
15
  import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
16
16
  import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
17
17
 
@@ -51,6 +51,25 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
51
51
  abandonToken: abandonTokenFromUrl(abandonUrl),
52
52
  });
53
53
 
54
+ // Idempotent already-merged short-circuit. When the poller routes an out-of-band-merged PR back
55
+ // through `attempt-merge` (service.ts publishes `merge-ready` on the `waiting_merge` out-of-band
56
+ // branch), the PR is already landed on GitHub. Re-running the land protocol would post a spurious
57
+ // `@mergifyio queue` comment (mergify-queue repos) or a redundant merge call, so detect the merged
58
+ // state first and complete the loop directly. Runs AFTER ensurePr so the `merges` audit row has its
59
+ // FK parent, and BEFORE the base-guard/protocol logic. Best-effort: a transport hiccup falls through
60
+ // to the normal path rather than blocking a genuine merge.
61
+ const pre = await fetchPrState(repo, prNumber, token).catch(() => null);
62
+ if (pre?.merged) {
63
+ await app.data.table("merges", "id").insert({
64
+ pr_key: prKey,
65
+ outcome: "merged",
66
+ method: "already-merged",
67
+ detail: "PR was already merged on GitHub (landed out-of-band)",
68
+ at: now,
69
+ });
70
+ return { mergeStatus: "merged" };
71
+ }
72
+
54
73
  // Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
55
74
  // to the default branch — the merge would land into a dead branch and never reach `main`.
56
75
  // GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted