@nanobpm/nano-workforce 0.173.0 → 0.174.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.174.1](https://github.com/nanobpm/nano-workforce/compare/v0.174.0...v0.174.1) (2026-09-02)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **reconcile:** fold vanished engine instances to orphaned, unwedging inflight runs ([#706](https://github.com/nanobpm/nano-workforce/issues/706)) ([68461da](https://github.com/nanobpm/nano-workforce/commit/68461dae90fba5ab6caaa8eef595e63f3995dc88)), closes [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#630](https://github.com/nanobpm/nano-workforce/issues/630) [#627](https://github.com/nanobpm/nano-workforce/issues/627)
6
+
7
+ ## [0.174.0](https://github.com/nanobpm/nano-workforce/compare/v0.173.0...v0.174.0) (2026-09-02)
8
+
9
+ ### Features
10
+
11
+ * **merge-loop:** auto-recover PRs evicted from the merge queue by CI failure, not just conflicts ([#703](https://github.com/nanobpm/nano-workforce/issues/703)) ([45a3051](https://github.com/nanobpm/nano-workforce/commit/45a3051e37767195c75940347af7197f999c3d30)), closes [#556](https://github.com/nanobpm/nano-workforce/issues/556) [#702](https://github.com/nanobpm/nano-workforce/issues/702)
12
+
1
13
  ## [0.173.0](https://github.com/nanobpm/nano-workforce/compare/v0.172.1...v0.173.0) (2026-09-01)
2
14
 
3
15
  ### Features
@@ -446,6 +446,7 @@ function prState(over: Partial<PrState>): PrState {
446
446
  totalChecks: 0,
447
447
  isDraft: false,
448
448
  headRefOid: null,
449
+ mergeQueueEntry: null,
449
450
  ...over,
450
451
  };
451
452
  }
@@ -493,6 +494,7 @@ function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclu
493
494
  checkConclusions: {},
494
495
  isDraft: false,
495
496
  headRefOid: "abc123",
497
+ mergeQueueEntry: null,
496
498
  };
497
499
  if (rollup) {
498
500
  const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
package/app/github.ts CHANGED
@@ -522,6 +522,20 @@ export interface PrState {
522
522
  isDraft: boolean;
523
523
  /** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
524
524
  headRefOid: string | null;
525
+ /** GROUND-TRUTH native-merge-queue membership, populated only when `fetchPrState` is called with
526
+ * `{ withMergeQueue: true }` (the block-4 queued-PR reconciliation) — otherwise `null`. It lets the
527
+ * poller OBSERVE a queue eviction instead of inferring "still queued" from `mergeStateStatus`
528
+ * alone (which cannot see a CI-on-`merge_group` eviction — the head reverts to BLOCKED/UNSTABLE/
529
+ * CLEAN, never DIRTY). Tri-state:
530
+ * • `true` — the PR is currently enrolled in the repo's native GitHub merge queue.
531
+ * • `false` — the base branch HAS a native merge queue but the PR is NO LONGER in it (a genuine
532
+ * eviction: CI failed on the speculative `merge_group` commit, the base moved, or a manual
533
+ * dequeue). `queuedVerdict` turns this into `evicted` → `arm-merge` re-drives the mergeable gate.
534
+ * • `null` — indeterminate: not probed, no usable transport, a transport error, OR the base
535
+ * branch has no native merge queue at all (a Mergify/plain-merge repo — see #556). A
536
+ * perpetually-null entry on such a repo must NOT read as an eviction, so the classifier stays
537
+ * conservative and leaves the `landedWaitTimeout` human backstop to cover a never-lands wedge. */
538
+ mergeQueueEntry: boolean | null;
525
539
  }
526
540
 
527
541
  /** Map GitHub's REST `mergeable_state` (lower-case) onto the GraphQL `mergeStateStatus`
@@ -686,10 +700,96 @@ export function isNotAPullRequestError(err: unknown): boolean {
686
700
  return /could not resolve to a pullrequest/i.test(msg) || /\bgithub 404\b/i.test(msg);
687
701
  }
688
702
 
703
+ /** GraphQL response for the merge-queue membership probe. Both transports (`gh api graphql` and the
704
+ * raw GraphQL endpoint) wrap the payload in a top-level `data`. */
705
+ interface MergeQueueMembershipResponse {
706
+ data?: {
707
+ repository?: {
708
+ mergeQueue?: { id?: string } | null;
709
+ pullRequest?: { mergeQueueEntry?: { id?: string } | null } | null;
710
+ } | null;
711
+ } | null;
712
+ }
713
+
714
+ /** Read GROUND-TRUTH native-merge-queue membership for a PR the merge loop enqueued (parked at
715
+ * `wait-landed`), so an eviction is OBSERVED rather than inferred from `mergeStateStatus` (which
716
+ * cannot see a CI-on-`merge_group` eviction — the head reverts to BLOCKED/UNSTABLE/CLEAN, never
717
+ * DIRTY). Returns:
718
+ * • `true` — the PR is currently enrolled in the base branch's native GitHub merge queue.
719
+ * • `false` — the base branch HAS a native merge queue but the PR is NO LONGER in it (a genuine
720
+ * eviction: CI failed on the speculative `merge_group` commit, the base moved, or a manual
721
+ * dequeue).
722
+ * • `null` — indeterminate: no usable transport / a transport error, OR the base branch has no
723
+ * native merge queue at all (a Mergify/plain-merge repo whose "queued" classification came from
724
+ * an ambiguous signal — #556). We gate on `repository.mergeQueue` existing first so a
725
+ * perpetually-null `mergeQueueEntry` on such a repo is never mistaken for an eviction; the
726
+ * `landedWaitTimeout` human backstop covers the genuinely-never-lands case there.
727
+ * Never throws — any failure degrades to `null` so the poller keeps waiting rather than falsely
728
+ * evicting a still-legitimately-queuing PR. */
729
+ export async function fetchMergeQueueMembership(
730
+ repo: string,
731
+ number: number | string,
732
+ baseBranch: string,
733
+ token: string,
734
+ ): Promise<boolean | null> {
735
+ if (!baseBranch) return null; // can't scope `mergeQueue(branch:)` without the base ref → stay conservative
736
+ const [owner, name] = repo.split("/");
737
+ const query =
738
+ "query($o:String!,$r:String!,$n:Int!,$b:String!){repository(owner:$o,name:$r){" +
739
+ "mergeQueue(branch:$b){id}pullRequest(number:$n){mergeQueueEntry{id}}}}";
740
+ const mode = githubTransport();
741
+ const useGhHere = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
742
+ if (!useGhHere && !token) return null;
743
+ try {
744
+ let payload: MergeQueueMembershipResponse;
745
+ if (useGhHere) {
746
+ const out = await runGh([
747
+ "api",
748
+ "graphql",
749
+ "-f",
750
+ `query=${query}`,
751
+ "-f",
752
+ `o=${owner}`,
753
+ "-f",
754
+ `r=${name}`,
755
+ "-F",
756
+ `n=${number}`,
757
+ "-f",
758
+ `b=${baseBranch}`,
759
+ ]);
760
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
761
+ payload = JSON.parse(out) as MergeQueueMembershipResponse;
762
+ } else {
763
+ const r = await fetch("https://api.github.com/graphql", {
764
+ method: "POST",
765
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
766
+ body: JSON.stringify({ query, variables: { o: owner, r: name, n: Number(number), b: baseBranch } }),
767
+ });
768
+ if (!r.ok) return null;
769
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
770
+ payload = (await r.json()) as MergeQueueMembershipResponse;
771
+ }
772
+ const repository = payload.data?.repository;
773
+ if (!repository) return null; // unreadable / GraphQL error → indeterminate
774
+ // No native merge queue on this base branch → an eviction is unobservable here (Mergify/plain).
775
+ if (!repository.mergeQueue) return null;
776
+ // A missing `pullRequest` (partial GraphQL `data` alongside `errors`, or an unreadable PR) is
777
+ // NOT an eviction — treat it as indeterminate so a transport hiccup can't thrash `arm-merge`.
778
+ const pr = repository.pullRequest;
779
+ if (pr == null) return null;
780
+ // Native queue exists and the PR is readable: enrolled iff it still carries a live queue entry.
781
+ return pr.mergeQueueEntry != null;
782
+ } catch {
783
+ // A transport/parse failure must not falsely evict — stay conservative.
784
+ return null;
785
+ }
786
+ }
787
+
689
788
  export async function fetchPrState(
690
789
  repo: string,
691
790
  number: number | string,
692
791
  token: string,
792
+ opts?: { withMergeQueue?: boolean },
693
793
  ): Promise<PrState | null> {
694
794
  if (await useGh()) {
695
795
  const out = await runGh([
@@ -699,7 +799,7 @@ export async function fetchPrState(
699
799
  "--repo",
700
800
  repo,
701
801
  "--json",
702
- "state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid",
802
+ "state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,baseRefName",
703
803
  ]);
704
804
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
705
805
  const j = JSON.parse(out) as {
@@ -709,10 +809,16 @@ export async function fetchPrState(
709
809
  statusCheckRollup?: RollupEntry[];
710
810
  isDraft?: boolean;
711
811
  headRefOid?: string | null;
812
+ baseRefName?: string | null;
712
813
  };
713
814
  const rollup = j.statusCheckRollup ?? [];
714
815
  const names = failingCheckNames(rollup);
715
816
  const merged = j.state === "MERGED" || !!j.mergedAt;
817
+ // Probe native-queue membership only for the queued-PR reconciliation (block 4) — it is an extra
818
+ // GraphQL round-trip, so every other caller leaves `mergeQueueEntry` null (unprobed).
819
+ const mergeQueueEntry = opts?.withMergeQueue
820
+ ? await fetchMergeQueueMembership(repo, number, j.baseRefName ?? "", token)
821
+ : null;
716
822
  return {
717
823
  merged,
718
824
  state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
@@ -725,6 +831,7 @@ export async function fetchPrState(
725
831
  checkConclusions: checkConclusions(rollup),
726
832
  isDraft: !!j.isDraft,
727
833
  headRefOid: j.headRefOid ?? null,
834
+ mergeQueueEntry,
728
835
  };
729
836
  }
730
837
  if (!token) return null;
@@ -740,8 +847,12 @@ export async function fetchPrState(
740
847
  mergeable_state?: string;
741
848
  draft?: boolean;
742
849
  head?: { sha?: string | null };
850
+ base?: { ref?: string | null };
743
851
  };
744
852
  const restMerged = !!j.merged || !!j.merged_at;
853
+ const mergeQueueEntry = opts?.withMergeQueue
854
+ ? await fetchMergeQueueMembership(repo, number, j.base?.ref ?? "", token)
855
+ : null;
745
856
  return {
746
857
  // The single-PR GET returns a `merged` boolean (unlike the list endpoint); we also honour
747
858
  // `merged_at` so this mirrors the gh branch's `state === "MERGED" || mergedAt` rule.
@@ -758,6 +869,7 @@ export async function fetchPrState(
758
869
  checkConclusions: {}, // …no per-check conclusions → protocol-aware gate falls through in token mode
759
870
  isDraft: !!j.draft,
760
871
  headRefOid: j.head?.sha ?? null,
872
+ mergeQueueEntry, // native-queue membership (GraphQL), probed only for block-4 reconciliation
761
873
  };
762
874
  }
763
875
 
@@ -0,0 +1,223 @@
1
+ // #702: the merge-loop must auto-recover a PR EVICTED from the GitHub merge queue for ANY reason —
2
+ // not only a merge conflict (`DIRTY`). A PR dropped because required checks FAILED on the
3
+ // speculative `merge_group` commit (the ALLGREEN-batch invalidation) is NOT `DIRTY` — its head
4
+ // reverts to BLOCKED/UNSTABLE/CLEAN — so the old `mergeStateStatus`-only classifier kept it parked
5
+ // at `wait-landed` until the PT1H `landedWaitTimeout` escalated to a human. The poller now reads
6
+ // GROUND-TRUTH native-queue membership (GraphQL `mergeQueueEntry`) so a clean eviction publishes
7
+ // `merge-evicted` (→ `arm-merge` → the mergeable gate re-drives `fix-ci`/`rebase`).
8
+ //
9
+ // These are poller-level tests: they drive `pollMerges` over a `queued` PR row against a
10
+ // token-transport GitHub stub that serves BOTH the REST PR view and the GraphQL merge-queue probe.
11
+ import { test } from "node:test";
12
+ import { assertEquals } from "#test-assert";
13
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
14
+ import { fetchMergeQueueMembership } from "./github.ts";
15
+ import { pollMerges } from "./service.ts";
16
+
17
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
18
+ const stores: Record<string, any[]> = {};
19
+ function tbl(name: string, pk = "id") {
20
+ const rows = (stores[name] ??= [] as any[]);
21
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
22
+ return {
23
+ async all() {
24
+ return rows.slice();
25
+ },
26
+ async get(id: any) {
27
+ return rows.find((r) => r[pk] === id);
28
+ },
29
+ async find(where: any = {}) {
30
+ return rows.filter((r) => match(r, where));
31
+ },
32
+ async insert(row: any) {
33
+ rows.push({ ...row });
34
+ return row[pk];
35
+ },
36
+ async update(id: any, patch: any) {
37
+ const r = rows.find((row) => row[pk] === id);
38
+ if (r) Object.assign(r, patch);
39
+ },
40
+ async delete(id: any) {
41
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
42
+ },
43
+ };
44
+ }
45
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
46
+ return { data, stores };
47
+ }
48
+
49
+ function recordingEngine(): { engine: EngineClient; messages: any[] } {
50
+ const messages: any[] = [];
51
+ const engine = {
52
+ async publishMessage(msg: any) {
53
+ messages.push(msg);
54
+ },
55
+ } as any as EngineClient;
56
+ return { engine, messages };
57
+ }
58
+
59
+ // A token-transport GitHub stub serving BOTH the REST `GET …/pulls/{n}` (the PR's merge state) and
60
+ // the GraphQL merge-queue membership probe. `mergeableState` is the head's `mergeable_state`;
61
+ // `hasQueue` says the base branch has a native merge queue; `enrolled` says the PR still holds a
62
+ // live `mergeQueueEntry`.
63
+ interface Fixture {
64
+ mergeableState: string; // e.g. "blocked" | "unstable" | "clean" | "dirty"
65
+ hasQueue: boolean;
66
+ enrolled: boolean;
67
+ }
68
+ function githubFetch(fx: Fixture) {
69
+ return (url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
70
+ const u = new URL(String(url));
71
+ const json = (obj: unknown, status = 200) =>
72
+ Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
73
+ if (u.pathname === "/graphql") {
74
+ return json({
75
+ data: {
76
+ repository: {
77
+ mergeQueue: fx.hasQueue ? { id: "MQ_1" } : null,
78
+ pullRequest: { mergeQueueEntry: fx.enrolled ? { id: "MQE_1" } : null },
79
+ },
80
+ },
81
+ });
82
+ }
83
+ const m = u.pathname.match(/\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/);
84
+ if (m) {
85
+ return json({
86
+ merged: false,
87
+ merged_at: null,
88
+ state: "open",
89
+ mergeable_state: fx.mergeableState,
90
+ draft: false,
91
+ head: { sha: "deadbeef" },
92
+ base: { ref: "main" },
93
+ });
94
+ }
95
+ return Promise.resolve(new Response(`unexpected ${u.pathname}`, { status: 500 }));
96
+ };
97
+ }
98
+
99
+ async function withGithub<T>(fx: Fixture, fn: () => Promise<T>): Promise<T> {
100
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
101
+ const prevFetch = globalThis.fetch;
102
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
103
+ globalThis.fetch = githubFetch(fx) as typeof fetch;
104
+ try {
105
+ return await fn();
106
+ } finally {
107
+ globalThis.fetch = prevFetch;
108
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
109
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
110
+ }
111
+ }
112
+
113
+ function queuedRow(prKey: string, number: number) {
114
+ const ts = "2026-08-20T00:00:00Z";
115
+ return {
116
+ pr_key: prKey,
117
+ repo: "o/r",
118
+ number,
119
+ url: `https://github.com/o/r/pull/${number}`,
120
+ title: "t",
121
+ status: "queued",
122
+ current_round: 0,
123
+ process_key: null,
124
+ waiting_since: null,
125
+ last_review_id: null,
126
+ outcome: null,
127
+ created_at: ts,
128
+ updated_at: ts,
129
+ converged_at: null,
130
+ merged_at: null,
131
+ };
132
+ }
133
+
134
+ test("#702 poller: a queued PR dropped from the queue (not DIRTY) publishes merge-evicted", async () => {
135
+ // The exact CI-on-merge_group eviction: head is BLOCKED (not DIRTY), native queue exists, but the
136
+ // PR is no longer enrolled. The old DIRTY-only classifier stayed silent here.
137
+ const { data, stores } = memData();
138
+ const { engine, messages } = recordingEngine();
139
+ stores["pull_requests"] = [queuedRow("o/r#100", 100)];
140
+
141
+ await withGithub({ mergeableState: "blocked", hasQueue: true, enrolled: false }, () =>
142
+ pollMerges(data, engine, "tok"),
143
+ );
144
+
145
+ assertEquals(messages.length, 1, "an evicted queued PR must publish exactly one escape message");
146
+ assertEquals(messages[0].name, "merge-evicted");
147
+ assertEquals(messages[0].correlationKey, "o/r#100");
148
+ // Flipped onto the transient `merging` status so a slow next pass can't double-signal.
149
+ assertEquals(stores["pull_requests"][0].status, "merging");
150
+ });
151
+
152
+ test("#702 regression: a queued PR still ENROLLED (BLOCKED pending queue check) stays parked", async () => {
153
+ const { data, stores } = memData();
154
+ const { engine, messages } = recordingEngine();
155
+ stores["pull_requests"] = [queuedRow("o/r#100", 100)];
156
+
157
+ await withGithub({ mergeableState: "blocked", hasQueue: true, enrolled: true }, () =>
158
+ pollMerges(data, engine, "tok"),
159
+ );
160
+
161
+ assertEquals(messages.length, 0, "a still-enrolled queuing PR must not be falsely evicted");
162
+ assertEquals(stores["pull_requests"][0].status, "queued");
163
+ });
164
+
165
+ test("#702: a repo with NO native merge queue (Mergify/plain) never falsely evicts a queued PR", async () => {
166
+ // `mergeQueueEntry` is perpetually null there — the #556 `landedWaitTimeout` backstop, not a false
167
+ // eviction, must cover a never-lands wedge. So the poller stays silent and the PR stays queued.
168
+ const { data, stores } = memData();
169
+ const { engine, messages } = recordingEngine();
170
+ stores["pull_requests"] = [queuedRow("o/r#100", 100)];
171
+
172
+ await withGithub({ mergeableState: "blocked", hasQueue: false, enrolled: false }, () =>
173
+ pollMerges(data, engine, "tok"),
174
+ );
175
+
176
+ assertEquals(messages.length, 0, "no native queue → indeterminate membership → keep waiting");
177
+ assertEquals(stores["pull_requests"][0].status, "queued");
178
+ });
179
+
180
+ // #703 (suppressed-advisory follow-up): `fetchMergeQueueMembership` must stay conservative when the
181
+ // GraphQL payload carries a native `mergeQueue` but the `pullRequest` node is missing (partial
182
+ // `data` alongside `errors`, or an unreadable PR). A null `pullRequest` is INDETERMINATE (`null`),
183
+ // never a definitive eviction (`false`) — otherwise a transport hiccup would thrash `arm-merge`.
184
+ async function withProbeFetch<T>(body: unknown, fn: () => Promise<T>): Promise<T> {
185
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
186
+ const prevFetch = globalThis.fetch;
187
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
188
+ globalThis.fetch = ((): Promise<Response> =>
189
+ Promise.resolve(
190
+ new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }),
191
+ )) as typeof fetch;
192
+ try {
193
+ return await fn();
194
+ } finally {
195
+ globalThis.fetch = prevFetch;
196
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
197
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
198
+ }
199
+ }
200
+
201
+ test("#703: a native queue with a MISSING pullRequest node reads indeterminate (null), not evicted", async () => {
202
+ const membership = await withProbeFetch(
203
+ { data: { repository: { mergeQueue: { id: "MQ_1" }, pullRequest: null } } },
204
+ () => fetchMergeQueueMembership("o/r", 100, "main", "tok"),
205
+ );
206
+ assertEquals(membership, null, "missing pullRequest must be indeterminate, never a false eviction");
207
+ });
208
+
209
+ test("#703: a native queue with a live pullRequest entry reads enrolled (true)", async () => {
210
+ const membership = await withProbeFetch(
211
+ { data: { repository: { mergeQueue: { id: "MQ_1" }, pullRequest: { mergeQueueEntry: { id: "MQE_1" } } } } },
212
+ () => fetchMergeQueueMembership("o/r", 100, "main", "tok"),
213
+ );
214
+ assertEquals(membership, true);
215
+ });
216
+
217
+ test("#703: a native queue with a present PR but no entry reads a genuine eviction (false)", async () => {
218
+ const membership = await withProbeFetch(
219
+ { data: { repository: { mergeQueue: { id: "MQ_1" }, pullRequest: { mergeQueueEntry: null } } } },
220
+ () => fetchMergeQueueMembership("o/r", 100, "main", "tok"),
221
+ );
222
+ assertEquals(membership, false);
223
+ });
@@ -2,7 +2,11 @@
2
2
  // decision drives what the poller does next from the PR's live GitHub state. The regression it
3
3
  // guards: a PR that develops a merge CONFLICT after being enqueued (#727/instance 729) must be
4
4
  // EVICTED back to the mergeable gate, not left waiting forever — while a PR still legitimately in
5
- // the queue (reported BLOCKED/UNSTABLE by GitHub) must keep waiting, never be falsely evicted.
5
+ // the queue (reported BLOCKED/UNSTABLE by GitHub) must keep waiting, never be falsely evicted. It
6
+ // also guards #702: a PR EVICTED because required checks failed on the speculative `merge_group`
7
+ // commit is NOT `DIRTY`, so a ground-truth `mergeQueueEntry === false` must classify it `evicted`
8
+ // (the old `DIRTY`-only classifier left it waiting out the full `landedWaitTimeout`, then escalated
9
+ // to a human, instead of auto-re-driving `fix-ci`).
6
10
  import { test } from "node:test";
7
11
  import { assertEquals } from "#test-assert";
8
12
  import type { PrState } from "./github.ts";
@@ -19,6 +23,7 @@ function st(over: Partial<PrState>): PrState {
19
23
  totalChecks: 0,
20
24
  isDraft: false,
21
25
  headRefOid: null,
26
+ mergeQueueEntry: null,
22
27
  ...over,
23
28
  };
24
29
  }
@@ -34,8 +39,39 @@ test("a DIRTY (conflicting) PR is evicted — this is the #727 wedge", () => {
34
39
  });
35
40
 
36
41
  test("a PR still legitimately in the queue keeps waiting (never falsely evicted)", () => {
37
- // Queuing PRs commonly report these; none is a conflict, so none may evict.
42
+ // Queuing PRs commonly report these; none is a conflict, and with an unprobed/indeterminate
43
+ // membership (`mergeQueueEntry: null`) none may evict.
38
44
  for (const s of ["CLEAN", "BLOCKED", "UNSTABLE", "BEHIND", "HAS_HOOKS", "UNKNOWN", "DRAFT"]) {
39
45
  assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s })), "waiting", s);
40
46
  }
41
47
  });
48
+
49
+ // ── #702: a merge-queue eviction caused by a red `merge_group` build leaves the head NOT `DIRTY`
50
+ // (it reverts to BLOCKED/UNSTABLE/CLEAN). Inferring from `mergeStateStatus` alone kept such a PR
51
+ // "waiting" until the PT1H `landedWaitTimeout` escalated to a human, instead of auto-re-driving
52
+ // `fix-ci`. Ground-truth `mergeQueueEntry === false` now classifies it `evicted`.
53
+
54
+ test("#702: a queued PR dropped from the queue (mergeQueueEntry=false) evicts even when not DIRTY", () => {
55
+ // The exact CI-on-merge_group eviction shapes: no conflict, but no longer enrolled.
56
+ for (const s of ["BLOCKED", "UNSTABLE", "CLEAN", "BEHIND", "HAS_HOOKS", "UNKNOWN"]) {
57
+ assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s, mergeQueueEntry: false })), "evicted", s);
58
+ }
59
+ });
60
+
61
+ test("#702 regression: a PR still ENROLLED (mergeQueueEntry=true) but BLOCKED keeps waiting", () => {
62
+ // A pending queue check reports BLOCKED/UNSTABLE while genuinely still in the queue — must NOT
63
+ // evict, or every legitimately-queuing PR would thrash `arm-merge`.
64
+ for (const s of ["BLOCKED", "UNSTABLE", "CLEAN"]) {
65
+ assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s, mergeQueueEntry: true })), "waiting", s);
66
+ }
67
+ });
68
+
69
+ test("#702: indeterminate membership (mergeQueueEntry=null, e.g. Mergify/token GraphQL error) keeps waiting", () => {
70
+ // A repo with no native merge queue (or an unreadable probe) leaves the #556 `landedWaitTimeout`
71
+ // backstop to handle a never-lands wedge — we must not falsely evict on a perpetually-null entry.
72
+ for (const s of ["BLOCKED", "UNSTABLE", "CLEAN"]) {
73
+ assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: s, mergeQueueEntry: null })), "waiting", s);
74
+ }
75
+ // …but a real conflict still evicts regardless of an unprobed membership (token-mode path).
76
+ assertEquals(queuedVerdict(st({ merged: false, mergeStateStatus: "DIRTY", mergeQueueEntry: null })), "evicted");
77
+ });
@@ -624,6 +624,7 @@ function prObs(over: Partial<PrObservation> = {}): PrObservation {
624
624
  headRefOid: "abc123",
625
625
  mergedSha: null,
626
626
  pendingChecks: 0,
627
+ mergeQueueEntry: null,
627
628
  ...over,
628
629
  };
629
630
  }
package/app/readiness.ts CHANGED
@@ -704,6 +704,7 @@ export function parsePrView(payload: unknown): PrObservation {
704
704
  checkConclusions: checkConclusions(rollup),
705
705
  isDraft: j.isDraft === true,
706
706
  headRefOid: str(j.headRefOid).trim() || null,
707
+ mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
707
708
  mergedSha,
708
709
  pendingChecks: pending.length,
709
710
  };