@nanobpm/nano-workforce 0.210.0 → 0.210.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,9 @@
1
+ ## [0.210.1](https://github.com/nanobpm/nano-workforce/compare/v0.210.0...v0.210.1) (2026-11-12)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **merge:** same-named check-runs from different events are different gates ([#957](https://github.com/nanobpm/nano-workforce/issues/957)) ([e2c4e96](https://github.com/nanobpm/nano-workforce/commit/e2c4e966b566ce8b4bdc19e84bceb130909a981c)), closes [#348](https://github.com/nanobpm/nano-workforce/issues/348) [camunda/console-api-js#17](https://github.com/camunda/console-api-js/issues/17) [#348](https://github.com/nanobpm/nano-workforce/issues/348) [#955](https://github.com/nanobpm/nano-workforce/issues/955) [#955](https://github.com/nanobpm/nano-workforce/issues/955) [#951](https://github.com/nanobpm/nano-workforce/issues/951) [#955](https://github.com/nanobpm/nano-workforce/issues/955)
6
+
1
7
  ## [0.210.0](https://github.com/nanobpm/nano-workforce/compare/v0.209.1...v0.210.0) (2026-11-12)
2
8
 
3
9
  ### Features
@@ -16,7 +16,7 @@ function st(over: Partial<PrState> = {}): PrState {
16
16
  failingCheckNames: ["build"],
17
17
  presentCheckNames: ["build"],
18
18
  pendingCheckNames: [],
19
- checkConclusions: { build: "FAILURE" },
19
+ checkGateConclusions: { build: ["FAILURE"] },
20
20
  totalChecks: 1,
21
21
  isDraft: false,
22
22
  headRefOid: "head1",
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { test } from "node:test";
14
14
  import { assert, assertEquals } from "#test-assert";
15
- import { allCheckNames, failingCheckNames, latestRunPerCheck } from "./github.ts";
15
+ import { allCheckNames, applyRollupEvents, checkConclusions, checkGateConclusions, failingCheckNames, latestRunPerCheck } from "./github.ts";
16
16
 
17
17
  test("a CANCELLED run superseded by a newer green run on the same head does not count as failing", () => {
18
18
  const rollup = [
@@ -91,3 +91,290 @@ test("legacy StatusContext (state + context) supersession is handled by createdA
91
91
  ];
92
92
  assertEquals(failingCheckNames(rollup), [], "a superseded legacy status context is not a failing gate");
93
93
  });
94
+
95
+ // ── Issue #955: same display name, DIFFERENT triggering events — not the same gate ──────────────
96
+ //
97
+ // One workflow file with both `pull_request` and `push` jobs named `build` puts TWO runs named
98
+ // `build` on the same head SHA. They are different gates: the ruleset / branch-protection required
99
+ // context gates the `pull_request` run, so a red PR `build` must never be collapsed away by a newer
100
+ // green push `build` (camunda/console-api-js#17: the collapse hid the red required check, the merge
101
+ // loop classified `waiting`, and `senior:fix-ci` never ran).
102
+
103
+ test("#955: a red pull_request `build` is not collapsed away by a newer green push `build`", () => {
104
+ const rollup = [
105
+ // The required gate: PR-event run, failed (biome check on CHANGELOG.md), completed 00:41:34Z.
106
+ { name: "build", event: "pull_request", conclusion: "FAILURE", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
107
+ // A different gate sharing the display name: push-event run, green, completed 4s later.
108
+ { name: "build", event: "push", conclusion: "SUCCESS", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
109
+ ];
110
+ assertEquals(
111
+ failingCheckNames(rollup),
112
+ ["build"],
113
+ "the red required pull_request run must survive the collapse — a green push job must not satisfy it",
114
+ );
115
+ assertEquals(
116
+ checkConclusions(rollup).build,
117
+ "FAILURE",
118
+ "the gate's conclusion is the red pull_request run's, so requiredChecksVerdict sees the failure",
119
+ );
120
+ });
121
+
122
+ test("#955: the #348 collapse still holds WITHIN one event — a cancelled rerun of the same job collapses", () => {
123
+ const rollup = [
124
+ { name: "build", event: "pull_request", conclusion: "CANCELLED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:31:00Z" },
125
+ { name: "build", event: "pull_request", conclusion: "SUCCESS", startedAt: "2026-11-12T00:32:00Z", completedAt: "2026-11-12T00:35:00Z" },
126
+ ];
127
+ assertEquals(failingCheckNames(rollup), [], "a CANCELLED rerun of the SAME workflow/event still collapses to the newest run");
128
+ assertEquals(checkConclusions(rollup).build, "SUCCESS");
129
+ });
130
+
131
+ test("#955: a green pull_request run does not mask a red push run of the same name (symmetric)", () => {
132
+ const rollup = [
133
+ { name: "build", event: "pull_request", conclusion: "SUCCESS", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
134
+ { name: "build", event: "push", conclusion: "FAILURE", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
135
+ ];
136
+ assertEquals(failingCheckNames(rollup), ["build"], "any non-superseded failed run of the name fails the gate");
137
+ });
138
+
139
+ test("#955: distinct events keep both runs in latestRunPerCheck (one per gate, not one per name)", () => {
140
+ const rollup = [
141
+ { name: "build", event: "pull_request", conclusion: "FAILURE", completedAt: "2026-11-12T00:41:34Z" },
142
+ { name: "build", event: "push", conclusion: "SUCCESS", completedAt: "2026-11-12T00:41:38Z" },
143
+ ];
144
+ const latest = latestRunPerCheck(rollup);
145
+ assertEquals(latest.length, 2, "two different gates sharing a name must both survive");
146
+ });
147
+
148
+ test("#955: runs without event identity keep the pre-#955 name-only collapse (legacy shapes unchanged)", () => {
149
+ // A rollup whose entries carry no `event` (older gh, a StatusContext-only head) collapses exactly
150
+ // as before: same name → newest run wins.
151
+ const rollup = [
152
+ { name: "build", conclusion: "FAILURE", completedAt: "2026-11-12T00:41:34Z" },
153
+ { name: "build", conclusion: "SUCCESS", completedAt: "2026-11-12T00:41:38Z" },
154
+ ];
155
+ assertEquals(failingCheckNames(rollup), [], "no event identity → the name-only #348 collapse is unchanged");
156
+ assertEquals(latestRunPerCheck(rollup).length, 1);
157
+ });
158
+
159
+ test("#955: allCheckNames still reports the display name once when events differ", () => {
160
+ const rollup = [
161
+ { name: "build", event: "pull_request", conclusion: "FAILURE", completedAt: "2026-11-12T00:41:34Z" },
162
+ { name: "build", event: "push", conclusion: "SUCCESS", completedAt: "2026-11-12T00:41:38Z" },
163
+ ];
164
+ assertEquals(allCheckNames(rollup), ["build"], "presence is by required-context name, not per (name, event) pair");
165
+ });
166
+
167
+ // ── PR #957 review: the gate key must be an unambiguous tuple ────────────────────────────────────
168
+ //
169
+ // A bare `name + event` concatenation lets two DIFFERENT identities collide: `name:"build",
170
+ // event:"push"` and an event-less `context:"buildpush"` both produce `"buildpush"`, so a newer green
171
+ // entry could discard an unrelated red check (and the known-event bucket could merge with the
172
+ // unknown-event one). The key is length-prefixed (`${name.length}:${name}${event}`) so no
173
+ // `(name, event)` pair aliases a different pair or a name-only key.
174
+
175
+ test("#957: an eventful `build`+`push` never collides with an event-less `buildpush` context", () => {
176
+ const rollup = [
177
+ // The event-less legacy StatusContext whose context string happens to equal the concatenation.
178
+ { context: "buildpush", state: "FAILURE", createdAt: "2026-11-12T00:41:34Z" },
179
+ // A different gate: a CheckRun named `build` triggered by `push`, green, newer.
180
+ { name: "build", event: "push", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:38Z" },
181
+ ];
182
+ const latest = latestRunPerCheck(rollup);
183
+ assertEquals(latest.length, 2, "distinct identities must not collapse into one gate");
184
+ assertEquals(
185
+ failingCheckNames(rollup),
186
+ ["buildpush"],
187
+ "the event-less red `buildpush` survives — the green `build`+`push` run must not discard it",
188
+ );
189
+ });
190
+
191
+ test("#957: a known-event gate never merges with the unknown-event bucket of the same name", () => {
192
+ // Same display name, one run event-ful, one event-less: these are different gates, so both survive.
193
+ const rollup = [
194
+ { name: "build", conclusion: "FAILURE", completedAt: "2026-11-12T00:41:34Z" }, // event-less (legacy)
195
+ { name: "build", event: "push", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:38Z" },
196
+ ];
197
+ assertEquals(latestRunPerCheck(rollup).length, 2, "known-event and unknown-event runs of one name stay separate");
198
+ assertEquals(failingCheckNames(rollup), ["build"], "the event-less red run is not shadowed by the eventful green one");
199
+ });
200
+
201
+ // ── PR #957 review: the real transport must populate `event` ─────────────────────────────────────
202
+ //
203
+ // The `gh pr view --json statusCheckRollup` exporter omits `event` (cli/cli `api/export_pr.go`), so
204
+ // `fetchPrState` enriches the rollup from GraphQL `CheckRun.checkSuite.workflowRun.event` before
205
+ // grouping. `applyRollupEvents` is the pure mapping; these cover the transport normalisation path.
206
+
207
+ test("#957: applyRollupEvents stamps each CheckRun's triggering event so the collapse separates events", () => {
208
+ // What `gh pr view --json statusCheckRollup` actually returns: NO `event` field on either run.
209
+ const rollup = [
210
+ { name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
211
+ { name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
212
+ ];
213
+ // Without enrichment the name-only fallback collapses the red run away (the #955 hole).
214
+ assertEquals(failingCheckNames(rollup), [], "pre-enrichment: the newer green run hides the red one");
215
+ // The GraphQL `statusCheckRollup.contexts` nodes for the same head, joined by (name, startedAt).
216
+ const nodes = [
217
+ { __typename: "CheckRun", databaseId: 1001, name: "build", startedAt: "2026-11-12T00:30:00Z", checkSuite: { workflowRun: { event: "pull_request" } } },
218
+ { __typename: "CheckRun", databaseId: 1002, name: "build", startedAt: "2026-11-12T00:41:30Z", checkSuite: { workflowRun: { event: "push" } } },
219
+ ];
220
+ applyRollupEvents(rollup, nodes);
221
+ assertEquals(rollup[0].event, "pull_request");
222
+ assertEquals(rollup[0].databaseId, 1001);
223
+ assertEquals(rollup[1].event, "push");
224
+ assertEquals(rollup[1].databaseId, 1002);
225
+ assertEquals(
226
+ failingCheckNames(rollup),
227
+ ["build"],
228
+ "post-enrichment: the red pull_request run survives — #955 is actually fixed on the real transport",
229
+ );
230
+ });
231
+
232
+ test("#957: applyRollupEvents leaves a StatusContext / misaligned / unreadable node event-less", () => {
233
+ const rollup = [
234
+ { context: "ci/legacy", state: "SUCCESS", createdAt: "2026-11-12T00:41:34Z" }, // a StatusContext
235
+ { name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
236
+ { name: "test", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:31Z", completedAt: "2026-11-12T00:41:39Z" },
237
+ ];
238
+ const nodes = [
239
+ { __typename: "StatusContext", context: "ci/legacy" }, // no event on this type
240
+ { __typename: "CheckRun", name: "DIFFERENT", startedAt: "2026-11-12T00:41:30Z", checkSuite: { workflowRun: { event: "push" } } }, // misaligned name
241
+ { __typename: "CheckRun", name: "test", startedAt: "2026-11-12T00:41:31Z", checkSuite: { workflowRun: { event: null } } }, // unreadable run
242
+ ];
243
+ applyRollupEvents(rollup, nodes);
244
+ assertEquals(rollup[0].event, undefined, "a StatusContext never gains an event");
245
+ assertEquals(rollup[1].event, undefined, "a misaligned node is not stamped blindly");
246
+ assertEquals(rollup[2].event, undefined, "a null/absent event leaves the entry event-less");
247
+ });
248
+
249
+ // ── PR #957 review round 3: the join is by run identity, never array position ────────────────────
250
+ //
251
+ // The `gh pr view` read and the GraphQL contexts read are two SEPARATE GitHub calls that can observe
252
+ // different runs or heads, so index `i` in one is not reliably run `i` in the other. A positional
253
+ // join can stamp one run's event onto another (the name guard cannot catch it for duplicate `build`
254
+ // names), re-creating the cross-event collapse #955 fixed. The join keys on `(name, startedAt)` and
255
+ // stamps only an exact 1:1 bucket match.
256
+
257
+ test("#957: applyRollupEvents joins by run identity, not position — a reordered read stamps the right runs", () => {
258
+ // The GraphQL read returns the two same-named runs in the OPPOSITE order from the CLI rollup.
259
+ const rollup = [
260
+ { name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
261
+ { name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
262
+ ];
263
+ const nodes = [
264
+ // Reversed order vs the rollup: a positional join would stamp push onto the red run.
265
+ { __typename: "CheckRun", databaseId: 1002, name: "build", startedAt: "2026-11-12T00:41:30Z", checkSuite: { workflowRun: { event: "push" } } },
266
+ { __typename: "CheckRun", databaseId: 1001, name: "build", startedAt: "2026-11-12T00:30:00Z", checkSuite: { workflowRun: { event: "pull_request" } } },
267
+ ];
268
+ applyRollupEvents(rollup, nodes);
269
+ assertEquals(rollup[0].event, "pull_request", "the red run keeps ITS event despite the reorder");
270
+ assertEquals(rollup[1].event, "push", "the green run keeps ITS event despite the reorder");
271
+ assertEquals(failingCheckNames(rollup), ["build"], "no cross-run stamp → the red pull_request run survives");
272
+ });
273
+
274
+ test("#957: applyRollupEvents stamps nothing when the two reads observed different run sets", () => {
275
+ // A rerun landed between the `gh pr view` read and the GraphQL read: the GraphQL side has an
276
+ // EXTRA `build` run at the SAME start instant (a cancelled/re-run pair re-queued together), so the
277
+ // node bucket (2) out-sizes the rollup bucket (1) and stamping anything would risk a cross-run stamp.
278
+ const rollup = [
279
+ { name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
280
+ ];
281
+ const nodes = [
282
+ { __typename: "CheckRun", databaseId: 1001, name: "build", startedAt: "2026-11-12T00:30:00Z", checkSuite: { workflowRun: { event: "pull_request" } } },
283
+ { __typename: "CheckRun", databaseId: 1003, name: "build", startedAt: "2026-11-12T00:30:00Z", checkSuite: { workflowRun: { event: "push" } } },
284
+ ];
285
+ applyRollupEvents(rollup, nodes);
286
+ assertEquals(rollup[0].event, undefined, "a bucket-size mismatch (the reads disagree) stamps nothing");
287
+ });
288
+
289
+ // ── PR #957 review round 3: the gate key is encoded for EVERY entry, event-less included ─────────
290
+ //
291
+ // Encoding only the known-event bucket leaves the two key spaces overlapping: `name:"build",
292
+ // event:"push"` produces `5:buildpush`, and an event-less `context:"5:buildpush"` ALSO produces
293
+ // `5:buildpush`, so a newer green entry could still discard an unrelated red check. Every entry —
294
+ // event-less ones included — is encoded as the same length-prefixed tuple.
295
+
296
+ test("#957: an eventful `build`+`push` never collides with an event-less `5:buildpush` context", () => {
297
+ const rollup = [
298
+ // An event-less legacy StatusContext whose context string equals the ENCODED tuple of the other gate.
299
+ { context: "5:buildpush", state: "FAILURE", createdAt: "2026-11-12T00:41:34Z" },
300
+ // A different gate: a CheckRun named `build` triggered by `push`, green, newer.
301
+ { name: "build", event: "push", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:38Z" },
302
+ ];
303
+ const latest = latestRunPerCheck(rollup);
304
+ assertEquals(latest.length, 2, "the encoded event-less key never aliases an eventful gate");
305
+ assertEquals(
306
+ failingCheckNames(rollup),
307
+ ["5:buildpush"],
308
+ "the red event-less `5:buildpush` survives — encoding every entry closes the cross-bucket hole",
309
+ );
310
+ });
311
+
312
+ // ── PR #957 review round 3: a prototype-free dictionary for arbitrary check names ────────────────
313
+ //
314
+ // A check can legitimately be named `constructor`, `toString`, or `__proto__`; on a plain `{}` those
315
+ // resolve to an INHERITED property, so `out[name] ?? []` returns a function/object and `.push`
316
+ // throws — aborting `fetchPrState` / readiness parsing for that PR. The dictionary is
317
+ // prototype-free (`Object.create(null)`), so every check name is a safe own-key.
318
+
319
+ test("#957: checkGateConclusions survives a check named `constructor` / `toString` / `__proto__`", () => {
320
+ const rollup = [
321
+ { name: "constructor", conclusion: "FAILURE", status: "COMPLETED", completedAt: "2026-11-12T00:41:34Z" },
322
+ { name: "toString", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:35Z" },
323
+ { name: "__proto__", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:36Z" },
324
+ ];
325
+ const gates = checkGateConclusions(rollup);
326
+ assertEquals(gates.constructor, ["FAILURE"], "an inherited-property name is a safe own-key");
327
+ assertEquals(gates.toString, ["SUCCESS"]);
328
+ assertEquals(gates.__proto__, ["SUCCESS"]);
329
+ assertEquals(failingCheckNames(rollup), ["constructor"], "and the failure is still reported, not crashed");
330
+ });
331
+
332
+ // ── PR #957 review round 4: never assign events by position inside a multi-run bucket ────────────
333
+
334
+ test("#957: same (name, startedAt) runs reordered between reads are joined by detailsUrl, not position", () => {
335
+ const t = "2026-11-12T00:30:00Z";
336
+ const rollup = [
337
+ { name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: t, completedAt: "2026-11-12T00:41:34Z", detailsUrl: "https://github.com/o/r/actions/runs/1/job/11" },
338
+ { name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: t, completedAt: "2026-11-12T00:41:38Z", detailsUrl: "https://github.com/o/r/actions/runs/2/job/22" },
339
+ ];
340
+ const nodes = [
341
+ { __typename: "CheckRun", databaseId: 22, name: "build", startedAt: t, detailsUrl: "https://github.com/o/r/actions/runs/2/job/22", checkSuite: { workflowRun: { event: "push" } } },
342
+ { __typename: "CheckRun", databaseId: 11, name: "build", startedAt: t, detailsUrl: "https://github.com/o/r/actions/runs/1/job/11", checkSuite: { workflowRun: { event: "pull_request" } } },
343
+ ];
344
+ applyRollupEvents(rollup, nodes);
345
+ assertEquals(rollup[0].event, "pull_request");
346
+ assertEquals(rollup[0].databaseId, 11);
347
+ assertEquals(rollup[1].event, "push");
348
+ assertEquals(rollup[1].databaseId, 22);
349
+ assertEquals(failingCheckNames(rollup), ["build"]);
350
+ });
351
+
352
+ test("#957: a mixed-event multi-run bucket without a joinable URL stamps nothing (no positional swap)", () => {
353
+ const t = "2026-11-12T00:30:00Z";
354
+ const rollup = [
355
+ { name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: t },
356
+ { name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: t },
357
+ ];
358
+ const nodes = [
359
+ { __typename: "CheckRun", name: "build", startedAt: t, checkSuite: { workflowRun: { event: "push" } } },
360
+ { __typename: "CheckRun", name: "build", startedAt: t, checkSuite: { workflowRun: { event: "pull_request" } } },
361
+ ];
362
+ applyRollupEvents(rollup, nodes);
363
+ assertEquals(rollup[0].event, undefined);
364
+ assertEquals(rollup[1].event, undefined);
365
+ });
366
+
367
+ test("#957: a same-event multi-run bucket is stamped order-independently", () => {
368
+ const t = "2026-11-12T00:30:00Z";
369
+ const rollup = [
370
+ { name: "build", conclusion: "CANCELLED", status: "COMPLETED", startedAt: t },
371
+ { name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: t },
372
+ ];
373
+ const nodes = [
374
+ { __typename: "CheckRun", name: "build", startedAt: t, checkSuite: { workflowRun: { event: "pull_request" } } },
375
+ { __typename: "CheckRun", name: "build", startedAt: t, checkSuite: { workflowRun: { event: "pull_request" } } },
376
+ ];
377
+ applyRollupEvents(rollup, nodes);
378
+ assertEquals(rollup[0].event, "pull_request");
379
+ assertEquals(rollup[1].event, "pull_request");
380
+ });
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assert, assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, checkConclusions, hasCopilotWorkStarted, classifyBlockedCause, copilotWorkStartedSince, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, THREAD_PROBE_FAILED, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
6
+ import { BaseBranchMustExistError, checkConclusions, checkGateConclusions, hasCopilotWorkStarted, classifyBlockedCause, copilotWorkStartedSince, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, failingCheckNames, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, THREAD_PROBE_FAILED, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
7
7
  import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
8
8
 
9
9
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
@@ -551,7 +551,7 @@ function prState(over: Partial<PrState>): PrState {
551
551
  failingCheckNames: [],
552
552
  presentCheckNames: [],
553
553
  pendingCheckNames: [],
554
- checkConclusions: {},
554
+ checkGateConclusions: {},
555
555
  totalChecks: 0,
556
556
  isDraft: false,
557
557
  headRefOid: null,
@@ -601,7 +601,7 @@ function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclu
601
601
  totalChecks: 0,
602
602
  presentCheckNames: [],
603
603
  pendingCheckNames: [],
604
- checkConclusions: {},
604
+ checkGateConclusions: {},
605
605
  isDraft: false,
606
606
  headRefOid: "abc123",
607
607
  mergeQueueEntry: null,
@@ -613,17 +613,20 @@ function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclu
613
613
  const present: string[] = [];
614
614
  const pending: string[] = [];
615
615
  const failing: string[] = [];
616
- const conclusions: Record<string, string> = {};
616
+ const conclusions: Record<string, string[]> = {};
617
617
  for (const c of rollup) {
618
618
  const v = c.conclusion.toUpperCase();
619
619
  present.push(c.name);
620
- conclusions[c.name] = pendingStates.has(v) ? "" : v;
620
+ // One entry per rollup run of the name — mirrors `checkGateConclusions` (a per-gate list).
621
+ const gates = conclusions[c.name] ?? [];
622
+ gates.push(pendingStates.has(v) ? "" : v);
623
+ conclusions[c.name] = gates;
621
624
  if (pendingStates.has(v)) pending.push(c.name);
622
625
  else if (bad.has(v)) failing.push(c.name);
623
626
  }
624
627
  base.presentCheckNames = present;
625
628
  base.pendingCheckNames = pending;
626
- base.checkConclusions = conclusions;
629
+ base.checkGateConclusions = conclusions;
627
630
  base.failingCheckNames = failing;
628
631
  base.failingChecks = failing.length;
629
632
  base.totalChecks = rollup.length;
@@ -812,13 +815,185 @@ for (const c of TOKEN_MODE) {
812
815
  totalChecks: -1,
813
816
  presentCheckNames: [],
814
817
  pendingCheckNames: [],
815
- checkConclusions: {},
818
+ checkGateConclusions: {},
816
819
  });
817
820
  const protocol = protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true });
818
821
  assertEquals(classifyMergeability(s, protocol), c.want);
819
822
  });
820
823
  }
821
824
 
825
+ // ── Same-named checks from different triggering events are different gates (issue #955) ─────────
826
+ //
827
+ // One workflow file with both `pull_request` and `push` jobs named `build` puts two runs named
828
+ // `build` on the same head SHA. Grouping the rollup by display name alone kept the NEWER run, so a
829
+ // red required `pull_request` `build` was collapsed away by a green `push` `build` 4s later —
830
+ // `failingChecks` read 0, `classifyBlockedCause` could not see the failed required check, the
831
+ // poller classified `waiting`, and `senior:fix-ci` never ran (camunda/console-api-js#17). These
832
+ // tests walk the exact observed rollup through the derivation helpers into the classifier — no
833
+ // merge-protocol file required.
834
+
835
+ test("#955 classifier walk: red PR `build` + newer green push `build` + pending CLA, BLOCKED -> blocked", () => {
836
+ // The exact head `641c7ee9` rollup shape from camunda/console-api-js#17.
837
+ const rollup = [
838
+ { name: "build", event: "pull_request", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
839
+ { name: "build", event: "push", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
840
+ { context: "license/cla", state: "PENDING", createdAt: "2026-11-12T00:30:05Z" },
841
+ ];
842
+ const names = failingCheckNames(rollup);
843
+ assertEquals(names, ["build"], "the red required pull_request run survives the collapse and names the gate");
844
+ const s = prState({
845
+ mergeStateStatus: "BLOCKED",
846
+ failingChecks: names.length,
847
+ failingCheckNames: names,
848
+ unresolvedThreads: 0,
849
+ });
850
+ // No merge-protocol file on the repo — the default protocol declares no requiredChecks.
851
+ assertEquals(classifyBlockedCause(s), "checks");
852
+ assertEquals(classifyMergeability(s), "blocked", "the merge loop must route to fix-ci, not park on wait-mergeable");
853
+ assertEquals(classifyMergeability(s, DEFAULT_MERGE_PROTOCOL), "blocked");
854
+ });
855
+
856
+ test("#955 classifier walk: the same rollup ALSO blocks a ruleset-declared required `build`", () => {
857
+ // Even with #901's ruleset-sourced requiredChecks, the collapsed-away red run made
858
+ // `requiredChecksVerdict` return "pass". With the fix the required `build` reads FAILURE.
859
+ const rollup = [
860
+ { name: "build", event: "pull_request", conclusion: "FAILURE", status: "COMPLETED", completedAt: "2026-11-12T00:41:34Z" },
861
+ { name: "build", event: "push", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:38Z" },
862
+ ];
863
+ const conclusions = checkGateConclusions(rollup);
864
+ assertEquals(conclusions.build, ["FAILURE", "SUCCESS"], "each retained gate keeps its own conclusion");
865
+ const s = prState({
866
+ mergeStateStatus: "BLOCKED",
867
+ failingChecks: 1,
868
+ failingCheckNames: ["build"],
869
+ presentCheckNames: ["build"],
870
+ checkGateConclusions: conclusions,
871
+ unresolvedThreads: 0,
872
+ });
873
+ assertEquals(classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })), "blocked");
874
+ });
875
+
876
+ test("#955: a genuinely non-required red check of a colliding name does not newly block a CLEAN PR", () => {
877
+ // UNSTABLE + only an OPTIONAL red check stays "ready" — the fix must not turn every cross-event
878
+ // red run into a merge block when GitHub itself says only non-required checks failed.
879
+ const rollup = [
880
+ { name: "build", event: "pull_request", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:34Z" },
881
+ { name: "build", event: "push", conclusion: "FAILURE", status: "COMPLETED", completedAt: "2026-11-12T00:41:38Z" },
882
+ ];
883
+ const names = failingCheckNames(rollup);
884
+ assertEquals(names, ["build"], "the optional red push run is still reported as failing");
885
+ const s = prState({
886
+ mergeStateStatus: "UNSTABLE",
887
+ failingChecks: names.length,
888
+ failingCheckNames: names,
889
+ unresolvedThreads: -1,
890
+ });
891
+ assertEquals(classifyMergeability(s), "ready", "UNSTABLE means only non-required checks failing — still mergeable");
892
+ });
893
+
894
+ test("#955: token mode (failingChecks === -1) is unchanged", () => {
895
+ const s = prState({
896
+ mergeStateStatus: "BLOCKED",
897
+ failingChecks: -1,
898
+ totalChecks: -1,
899
+ presentCheckNames: [],
900
+ pendingCheckNames: [],
901
+ checkGateConclusions: {},
902
+ unresolvedThreads: 0,
903
+ });
904
+ assertEquals(classifyMergeability(s), "waiting", "unenumerable checks stay conservative — never newly blocked");
905
+ });
906
+
907
+ // ── PR #957 review: per-gate conclusions drive the required-check verdict ───────────────────────
908
+ //
909
+ // Same-named runs from different events are different gates, so a required name can carry SEVERAL
910
+ // terminal conclusions. The verdict must (a) route a settled rejection to fix-ci even while a
911
+ // same-named sibling gate is still pending, and (b) require EVERY retained gate to satisfy
912
+ // `acceptedConclusions` — a single accepted gate must not whitewash a rejected sibling.
913
+
914
+ test("#957: a settled FAILURE required gate routes to fix-ci even while a same-named sibling is pending", () => {
915
+ // A red PR `build` plus an in-progress push `build`: pending must not mask the settled failure.
916
+ const s = prState({
917
+ mergeStateStatus: "BLOCKED",
918
+ failingChecks: 1,
919
+ failingCheckNames: ["build"],
920
+ presentCheckNames: ["build"],
921
+ pendingCheckNames: ["build"], // the push gate is still running
922
+ checkGateConclusions: { build: ["FAILURE", ""] }, // PR gate failed, push gate in-flight
923
+ unresolvedThreads: 0,
924
+ });
925
+ assertEquals(
926
+ classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })),
927
+ "blocked",
928
+ "a settled rejected gate owns remediation now — a pending sibling must not delay fix-ci",
929
+ );
930
+ });
931
+
932
+ test("#957: a SUCCESS gate does not satisfy a success-only required check while a NEUTRAL sibling gate exists", () => {
933
+ // Every retained gate must be accepted: a green PR `build` + a NEUTRAL push `build` is rejected.
934
+ const s = prState({
935
+ mergeStateStatus: "CLEAN",
936
+ failingChecks: 0,
937
+ presentCheckNames: ["build"],
938
+ pendingCheckNames: [],
939
+ checkGateConclusions: { build: ["SUCCESS", "NEUTRAL"] },
940
+ unresolvedThreads: 0,
941
+ });
942
+ assertEquals(
943
+ classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })),
944
+ "blocked",
945
+ "a same-named NEUTRAL gate rejects a success-only required check even alongside a SUCCESS gate",
946
+ );
947
+ });
948
+
949
+ test("#957: a SUCCESS gate does not satisfy a success-only required check while a SKIPPED sibling gate exists", () => {
950
+ const s = prState({
951
+ mergeStateStatus: "CLEAN",
952
+ failingChecks: 0,
953
+ presentCheckNames: ["build"],
954
+ pendingCheckNames: [],
955
+ checkGateConclusions: { build: ["SUCCESS", "SKIPPED"] },
956
+ unresolvedThreads: 0,
957
+ });
958
+ assertEquals(
959
+ classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })),
960
+ "blocked",
961
+ "a same-named SKIPPED gate rejects a success-only required check even alongside a SUCCESS gate",
962
+ );
963
+ });
964
+
965
+ test("#957: every gate accepted passes; a NEUTRAL sibling is fine when the protocol accepts neutral", () => {
966
+ const s = prState({
967
+ mergeStateStatus: "CLEAN",
968
+ failingChecks: 0,
969
+ presentCheckNames: ["build"],
970
+ pendingCheckNames: [],
971
+ checkGateConclusions: { build: ["SUCCESS", "NEUTRAL"] },
972
+ unresolvedThreads: 0,
973
+ });
974
+ assertEquals(
975
+ classifyMergeability(s, protocolWith({ requiredChecks: [{ name: "build", acceptedConclusions: ["success", "neutral"] }] })),
976
+ "ready",
977
+ "when every retained gate's conclusion is accepted the required check is satisfied",
978
+ );
979
+ });
980
+
981
+ test("#957: a still-pending only gate (no settled conclusion) waits, never passes", () => {
982
+ const s = prState({
983
+ mergeStateStatus: "CLEAN",
984
+ failingChecks: 0,
985
+ presentCheckNames: ["build"],
986
+ pendingCheckNames: ["build"],
987
+ checkGateConclusions: { build: [""] },
988
+ unresolvedThreads: 0,
989
+ });
990
+ assertEquals(
991
+ classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })),
992
+ "waiting",
993
+ "a required check with no settled gate is not-yet-concluded → waiting, not pass",
994
+ );
995
+ });
996
+
822
997
  // ── Draft PRs are never landable (issue #454) ────────────────────────────────────────────────────
823
998
  //
824
999
  // A draft PR with green checks reports `mergeStateStatus: CLEAN`, so the old `mergeStateStatus`
@@ -1076,7 +1251,7 @@ test("classifyMergeability: a known merge conflict is never suppressed by the th
1076
1251
  mergeStateStatus: "DIRTY",
1077
1252
  failingChecks: 1,
1078
1253
  presentCheckNames: ["build"],
1079
- checkConclusions: { build: "FAILURE" },
1254
+ checkGateConclusions: { build: ["FAILURE"] },
1080
1255
  unresolvedThreads: 2,
1081
1256
  }),
1082
1257
  { requiredChecks: [{ name: "build", acceptedConclusions: ["SUCCESS"] }] },
@@ -1100,7 +1275,7 @@ test("classifyMergeability: the declared required-check verdict outranks the set
1100
1275
  mergeStateStatus: "CLEAN",
1101
1276
  failingChecks: 0,
1102
1277
  presentCheckNames: ["build"],
1103
- checkConclusions: { build: "NEUTRAL" },
1278
+ checkGateConclusions: { build: ["NEUTRAL"] },
1104
1279
  unresolvedThreads: 2,
1105
1280
  }),
1106
1281
  successOnly,
@@ -1125,7 +1300,7 @@ test("classifyMergeability: the declared required-check verdict outranks the set
1125
1300
  mergeStateStatus: "CLEAN",
1126
1301
  failingChecks: 0,
1127
1302
  presentCheckNames: ["build"],
1128
- checkConclusions: { build: "SUCCESS" },
1303
+ checkGateConclusions: { build: ["SUCCESS"] },
1129
1304
  unresolvedThreads: 2,
1130
1305
  }),
1131
1306
  successOnly,
@@ -1640,3 +1815,18 @@ test("hasCopilotWorkStarted (token): an aborted (timed-out) read is UNKNOWN (nul
1640
1815
  );
1641
1816
  assertEquals(got, null);
1642
1817
  });
1818
+
1819
+ test("CHECK_EVENTS_QUERY is brace-balanced (a malformed query leaves the rollup event-less, PR #957)", async () => {
1820
+ const { CHECK_EVENTS_QUERY } = await import("./github.ts");
1821
+ let depth = 0;
1822
+ for (const ch of CHECK_EVENTS_QUERY) {
1823
+ if (ch === "{") depth++;
1824
+ if (ch === "}") depth--;
1825
+ if (depth < 0) throw new Error("closing brace without a matching opening brace");
1826
+ }
1827
+ assertEquals(depth, 0);
1828
+ for (const p of ["(", "["]) {
1829
+ const close = p === "(" ? ")" : "]";
1830
+ assertEquals(CHECK_EVENTS_QUERY.split(p).length, CHECK_EVENTS_QUERY.split(close).length);
1831
+ }
1832
+ });