@nanobpm/nano-workforce 0.210.0 → 0.210.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/abandon.test.ts +31 -0
- package/app/abandon.ts +8 -2
- package/app/behindBaseRemedy.test.ts +1 -1
- package/app/conformance.test.ts +17 -0
- package/app/failingChecksSupersede.test.ts +288 -1
- package/app/github.test.ts +200 -10
- package/app/github.ts +365 -59
- package/app/loopTermination.test.ts +4 -0
- package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
- package/app/migration139.test.ts +115 -0
- package/app/prTrackingReadModel.test.ts +175 -0
- package/app/prTrackingReadModel.ts +152 -0
- package/app/readiness.test.ts +90 -1
- package/app/readiness.ts +81 -3
- package/app/retro.test.ts +11 -0
- package/app/service.ts +11 -3
- package/app/terminalReaderBehaviour.test.ts +44 -0
- package/build-info.json +4 -4
- package/db/migrations/139_pull_requests_backfill_merged_not_abandoned.sql +40 -0
- package/main.ts +6 -0
- package/package.json +1 -1
- package/test/trackingViews.ts +16 -12
- package/workers/landed-probe/worker.test.ts +1 -1
- package/workers/merge-stall-probe/worker.test.ts +2 -2
- package/workers/reconverge/worker.test.ts +137 -8
- package/workers/reconverge/worker.ts +53 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.210.2](https://github.com/nanobpm/nano-workforce/compare/v0.210.1...v0.210.2) (2026-11-14)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **tracking:** merged PRs no longer derive 'abandoned' when their run was terminated ([#960](https://github.com/nanobpm/nano-workforce/issues/960)) ([56f0ff4](https://github.com/nanobpm/nano-workforce/commit/56f0ff4c4a3a0d8a3cf68745b1f7b9bf539987da)), closes [nano-bpm#1579](https://github.com/nanobpm/nano-bpm/issues/1579) [#959](https://github.com/nanobpm/nano-workforce/issues/959)
|
|
6
|
+
|
|
7
|
+
## [0.210.1](https://github.com/nanobpm/nano-workforce/compare/v0.210.0...v0.210.1) (2026-11-12)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **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)
|
|
12
|
+
|
|
1
13
|
## [0.210.0](https://github.com/nanobpm/nano-workforce/compare/v0.209.1...v0.210.0) (2026-11-12)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/app/abandon.test.ts
CHANGED
|
@@ -134,6 +134,37 @@ test("abandonStatusForToken derives abandoned from the row status", async () =>
|
|
|
134
134
|
assertEquals(await abandonStatusForToken(data, "nope"), undefined);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test("abandonStatusForToken aborts a MERGED PR whose owning run was terminated (owner_terminated)", async () => {
|
|
138
|
+
// derived_status reports a landed PR as `merged` even when its run was terminated (#959), so the
|
|
139
|
+
// cancellation signal rides the separate owner_terminated column — terminating a redundant
|
|
140
|
+
// post-merge round must still tell its agent to stop.
|
|
141
|
+
const data = memData();
|
|
142
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
143
|
+
pr_key: "o/r#4",
|
|
144
|
+
abandon_token: "landed-term",
|
|
145
|
+
status: "merged",
|
|
146
|
+
merged_at: "t",
|
|
147
|
+
owner_terminated: 1,
|
|
148
|
+
});
|
|
149
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
150
|
+
pr_key: "o/r#5",
|
|
151
|
+
abandon_token: "landed-live",
|
|
152
|
+
status: "merged",
|
|
153
|
+
merged_at: "t",
|
|
154
|
+
owner_terminated: null,
|
|
155
|
+
});
|
|
156
|
+
assertEquals(await abandonStatusForToken(data, "landed-term"), {
|
|
157
|
+
prKey: "o/r#4",
|
|
158
|
+
status: "merged",
|
|
159
|
+
abandoned: true,
|
|
160
|
+
});
|
|
161
|
+
assertEquals(await abandonStatusForToken(data, "landed-live"), {
|
|
162
|
+
prKey: "o/r#5",
|
|
163
|
+
status: "merged",
|
|
164
|
+
abandoned: false,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
137
168
|
test("abandonStatusForToken fails CLOSED on a non-string derived status", async () => {
|
|
138
169
|
// A malformed/missing derived_status must never be reported as `abandoned:false`
|
|
139
170
|
// (that would let a cancelled run proceed with irreversible side effects). It throws
|
package/app/abandon.ts
CHANGED
|
@@ -24,12 +24,15 @@
|
|
|
24
24
|
import type { DataLayer } from "@nanobpm/urban";
|
|
25
25
|
import { publicBaseUrl } from "./blackboard.ts";
|
|
26
26
|
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
27
|
+
import { OWNER_TERMINATED_COLUMN } from "./prTrackingReadModel.ts";
|
|
27
28
|
|
|
28
29
|
/** The one derived-status value meaning a PR is terminally abandoned — the run must not be worked on
|
|
29
30
|
* further. Two disjoint producers surface a row here, and both are non-completion terminals that must
|
|
30
31
|
* stop a servicing agent:
|
|
31
32
|
* 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive terminates
|
|
32
|
-
* the instance; the `instanceTracking` `onTerminated.set` edge derives `abandoned` on read)
|
|
33
|
+
* the instance; the `instanceTracking` `onTerminated.set` edge derives `abandoned` on read) —
|
|
34
|
+
* but ONLY when the PR did NOT land: the merge-aware derivation (app/prTrackingReadModel.ts,
|
|
35
|
+
* issue #959) folds a terminated-but-MERGED PR to `merged`, never `abandoned`, and
|
|
33
36
|
* 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
|
|
34
37
|
* merging** (#352) — for both `pull_requests` and its `plan_tasks` — by writing the base row.
|
|
35
38
|
* Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
|
|
@@ -116,7 +119,10 @@ export async function abandonStatusForToken(
|
|
|
116
119
|
);
|
|
117
120
|
}
|
|
118
121
|
const status = rawStatus;
|
|
119
|
-
|
|
122
|
+
// `derived_status` reports a landed PR as `merged` even when its run was terminated (issue #959),
|
|
123
|
+
// so termination is read from its own column: a terminated owner ALWAYS aborts the agent.
|
|
124
|
+
const ownerTerminated = Boolean(row[OWNER_TERMINATED_COLUMN]);
|
|
125
|
+
return { prKey: row.pr_key, status, abandoned: isAbandoned(status) || ownerTerminated };
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/** The instruction block appended (verbatim, via `appendPrompt`) to each side-effecting agent's
|
|
@@ -16,7 +16,7 @@ function st(over: Partial<PrState> = {}): PrState {
|
|
|
16
16
|
failingCheckNames: ["build"],
|
|
17
17
|
presentCheckNames: ["build"],
|
|
18
18
|
pendingCheckNames: [],
|
|
19
|
-
|
|
19
|
+
checkGateConclusions: { build: ["FAILURE"] },
|
|
20
20
|
totalChecks: 1,
|
|
21
21
|
isDraft: false,
|
|
22
22
|
headRefOid: "head1",
|
package/app/conformance.test.ts
CHANGED
|
@@ -126,6 +126,23 @@ test("hasDeliveredImplementation: true iff at least one PR landed", async () =>
|
|
|
126
126
|
assert(hasDeliveredImplementation(await gatherConformance(data, PLAN)));
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
// Issue #959: a PR that MERGED but whose loop was terminated afterwards (operator cancel, out-of-band
|
|
130
|
+
// merge reconcile, restart cleanup) derived `abandoned` off the `onTerminated` edge — and conformance
|
|
131
|
+
// then refused to count it as landed. The merge-aware derivation folds it to `merged` (merged_at set),
|
|
132
|
+
// so a merged-then-terminated PR IS landed. Model the terminated divergence by seeding the base row at
|
|
133
|
+
// its frozen transient (`converging`) WITH `merged_at` set: the tracking view must report `merged`.
|
|
134
|
+
test("hasDeliveredImplementation: a merged-then-terminated PR (frozen base + merged_at) counts as landed (#959)", async () => {
|
|
135
|
+
const { data, stores } = memData();
|
|
136
|
+
seedPlan(stores);
|
|
137
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
138
|
+
// Base status frozen at the in-flight transient, but the PR landed (merged_at set) before termination.
|
|
139
|
+
stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "converging", merged_at: "2026-11-01T00:00:00Z" }];
|
|
140
|
+
assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), true);
|
|
141
|
+
const d = await gatherConformance(data, PLAN);
|
|
142
|
+
assertEquals(hasDeliveredImplementation(d), true);
|
|
143
|
+
assertEquals(d.slices.find((s) => s.taskId === "t1")?.landed, true);
|
|
144
|
+
});
|
|
145
|
+
|
|
129
146
|
test("hasDeliveredImplementationForPlan: matches the digest without a blackboard scan", async () => {
|
|
130
147
|
const { data, stores } = memData();
|
|
131
148
|
seedPlan(stores);
|
|
@@ -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
|
+
});
|