@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 +6 -0
- package/app/behindBaseRemedy.test.ts +1 -1
- package/app/failingChecksSupersede.test.ts +288 -1
- package/app/github.test.ts +200 -10
- package/app/github.ts +365 -59
- package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
- package/app/readiness.test.ts +90 -1
- package/app/readiness.ts +81 -3
- package/build-info.json +4 -4
- package/package.json +1 -1
- package/workers/landed-probe/worker.test.ts +1 -1
- package/workers/merge-stall-probe/worker.test.ts +2 -2
package/app/github.ts
CHANGED
|
@@ -968,12 +968,16 @@ export interface PrState {
|
|
|
968
968
|
* REST fallback can't enumerate checks). Lets `classifyMergeability` hold a merge when a
|
|
969
969
|
* declared-required check has not yet concluded, without re-deriving conclusions by hand. */
|
|
970
970
|
pendingCheckNames: string[];
|
|
971
|
-
/** The
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
971
|
+
/** The terminal conclusion of EVERY retained gate of each head check (name → one uppercase
|
|
972
|
+
* conclusion per non-superseded gate, e.g. `["SUCCESS"]` or `["FAILURE","SUCCESS"]` when
|
|
973
|
+
* same-named runs from different events are different gates, #955), derived over
|
|
974
|
+
* `latestRunPerCheck` so a `CANCELLED` run superseded by a newer green run on the same head
|
|
975
|
+
* reports the green result (#348). A still-pending gate contributes `""` (it has no conclusion
|
|
976
|
+
* yet — use `pendingCheckNames`). Empty in token mode. Keeping each gate's conclusion (rather than
|
|
977
|
+
* one reduced value per name) lets `classifyMergeability` honour a required check's
|
|
978
|
+
* `acceptedConclusions` precisely: EVERY retained gate must be accepted, and a settled rejection
|
|
979
|
+
* routes to fix-ci even while a same-named sibling gate is still pending. */
|
|
980
|
+
checkGateConclusions: Record<string, string[]>;
|
|
977
981
|
/** Whether the PR is a draft (a fresh head run is produced by marking it ready, not reopen). */
|
|
978
982
|
isDraft: boolean;
|
|
979
983
|
/** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
|
|
@@ -1022,12 +1026,30 @@ interface RollupEntry {
|
|
|
1022
1026
|
name?: string;
|
|
1023
1027
|
context?: string;
|
|
1024
1028
|
workflowName?: string;
|
|
1029
|
+
/** The GitHub event that triggered the run (e.g. `pull_request` / `push`), read from GraphQL
|
|
1030
|
+
* `CheckRun.checkSuite.workflowRun.event` (there is no `CheckRun.event` field; the `gh pr view
|
|
1031
|
+
* --json statusCheckRollup` exporter omits it, so `fetchPrState` enriches it — see
|
|
1032
|
+
* {@link enrichRollupEvents}). Two runs sharing a display name but triggered by different events
|
|
1033
|
+
* are DIFFERENT gates — a ruleset / branch-protection required context gates the `pull_request`
|
|
1034
|
+
* run, so a green `push` run must never collapse away a red `pull_request` run of the same name
|
|
1035
|
+
* (issue #955). Absent on legacy StatusContext entries and older payloads; runs without it keep
|
|
1036
|
+
* the name-only identity. */
|
|
1037
|
+
event?: string;
|
|
1025
1038
|
/** CheckRun timestamps (GraphQL `statusCheckRollup`). A superseded run and the newer run that
|
|
1026
1039
|
* replaced it carry the same check name but different times, so they order the runs of one check.
|
|
1027
1040
|
* StatusContext carries `createdAt` instead. All are ISO-8601 or absent. */
|
|
1028
1041
|
startedAt?: string;
|
|
1029
1042
|
completedAt?: string;
|
|
1030
1043
|
createdAt?: string;
|
|
1044
|
+
/** The CheckRun's details URL (`gh pr view --json statusCheckRollup` exports it, and GraphQL
|
|
1045
|
+
* `CheckRun.detailsUrl` returns it). It embeds the run/job id, so it is an IMMUTABLE identity
|
|
1046
|
+
* present in BOTH reads — the primary join key of {@link applyRollupEvents}. */
|
|
1047
|
+
detailsUrl?: string;
|
|
1048
|
+
/** The check run's IMMUTABLE GitHub identity (REST/GraphQL `databaseId`). Stamped by
|
|
1049
|
+
* {@link enrichRollupEvents} when it joins the event read onto the rollup, so the join is by
|
|
1050
|
+
* identity — never by array position, which can stamp one run's event onto another when the two
|
|
1051
|
+
* reads observe different runs (PR #957 review). Absent on legacy/un-enriched payloads. */
|
|
1052
|
+
databaseId?: number;
|
|
1031
1053
|
}
|
|
1032
1054
|
|
|
1033
1055
|
/** The canonical identity of a check across its reruns: its name (CheckRun) or context
|
|
@@ -1038,6 +1060,32 @@ function checkKey(c: RollupEntry): string {
|
|
|
1038
1060
|
return c.name || c.context || c.workflowName || "check";
|
|
1039
1061
|
}
|
|
1040
1062
|
|
|
1063
|
+
/** The GATE identity a run is collapsed under (issue #955): the display identity ({@link checkKey})
|
|
1064
|
+
* plus, when known, the triggering event. Same name is NOT the same gate when the runs come from
|
|
1065
|
+
* different workflows or different triggering events — one workflow file with both `pull_request`
|
|
1066
|
+
* and `push` jobs named `build` puts two runs named `build` on the same head SHA, and the ruleset /
|
|
1067
|
+
* branch-protection required context gates the `pull_request` one. Grouping by name alone kept the
|
|
1068
|
+
* NEWER run, so a green `push` `build` collapsed away a red required `pull_request` `build` and the
|
|
1069
|
+
* merge loop never saw the failed required check (camunda/console-api-js#17). Runs whose event is
|
|
1070
|
+
* unknown (legacy StatusContext, older payloads) share one name-only gate, exactly as pre-#955, so
|
|
1071
|
+
* the #348 cancelled-rerun collapse is unchanged for them; a known event never merges with the
|
|
1072
|
+
* unknown-event bucket, so an eventful red run is never shadowed by an event-less green one.
|
|
1073
|
+
*
|
|
1074
|
+
* The key is an UNAMBIGUOUS tuple, not a bare concatenation: `${name.length}:${name}${event}`, with
|
|
1075
|
+
* the event LOWER-CASE-normalised and the empty event encoded as the SAME tuple (never a bare
|
|
1076
|
+
* name). Concatenating `name + event` lets two DIFFERENT identities collide — `name:"build",
|
|
1077
|
+
* event:"push"` and an event-less `context:"buildpush"` both produce `"buildpush"`. Encoding only
|
|
1078
|
+
* the known-event bucket leaves the same hole across buckets: `name:"build", event:"push"` yields
|
|
1079
|
+
* `5:buildpush` and an event-less `context:"5:buildpush"` ALSO yields `5:buildpush`, so a newer
|
|
1080
|
+
* green entry could still discard an unrelated red check (PR #957 review). Encoding EVERY entry —
|
|
1081
|
+
* event-less ones included — makes the name/event boundary explicit for all, so no `(name, event)`
|
|
1082
|
+
* pair can alias a different pair. */
|
|
1083
|
+
function gateKey(c: RollupEntry): string {
|
|
1084
|
+
const event = (c.event || "").trim().toLowerCase();
|
|
1085
|
+
const name = checkKey(c);
|
|
1086
|
+
return `${name.length}:${name}${event}`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1041
1089
|
/** A run's ordering timestamp (newest wins): its completion, else its start, else its creation.
|
|
1042
1090
|
* `0` when none is present (the shape carries no time) so a timed run always outranks an untimed
|
|
1043
1091
|
* one. */
|
|
@@ -1055,17 +1103,22 @@ function isCancelled(c: RollupEntry): boolean {
|
|
|
1055
1103
|
return (c.conclusion || c.state || "").toUpperCase() === "CANCELLED";
|
|
1056
1104
|
}
|
|
1057
1105
|
|
|
1058
|
-
/** Collapse a head's rollup to the **newest run per
|
|
1106
|
+
/** Collapse a head's rollup to the **newest run per gate**. GitHub's CI-concurrency cancellation
|
|
1059
1107
|
* (issue #348) leaves BOTH a superseded run (stamped `CANCELLED`) and the newer run that replaced
|
|
1060
1108
|
* it on the *same head SHA*, under the same check name. Counting the stale `CANCELLED` as a failure
|
|
1061
1109
|
* escalates a self-healing PR whose head is actually green. The rollup is already scoped to the head
|
|
1062
|
-
* commit, so grouping by
|
|
1063
|
-
* conclusion per `(headSha,
|
|
1064
|
-
* run, so a superseded cancellation never shadows the real result even when GitHub omits times.
|
|
1110
|
+
* commit, so grouping by gate and keeping the newest run per gate yields one ground-truth
|
|
1111
|
+
* conclusion per `(headSha, gate)`. Ties (equal/absent timestamps) prefer a non-`CANCELLED`
|
|
1112
|
+
* run, so a superseded cancellation never shadows the real result even when GitHub omits times.
|
|
1113
|
+
*
|
|
1114
|
+
* The gate is the check's display identity PLUS its triggering event when known (issue #955): two
|
|
1115
|
+
* runs named `build` from `pull_request` and `push` are different gates, so a newer green push run
|
|
1116
|
+
* no longer collapses away a red required PR run. Within one `(name, event)` gate the #348
|
|
1117
|
+
* cancelled-rerun collapse is preserved verbatim. */
|
|
1065
1118
|
export function latestRunPerCheck(rollup: RollupEntry[]): RollupEntry[] {
|
|
1066
1119
|
const newest = new Map<string, RollupEntry>();
|
|
1067
1120
|
for (const c of rollup) {
|
|
1068
|
-
const key =
|
|
1121
|
+
const key = gateKey(c);
|
|
1069
1122
|
const prev = newest.get(key);
|
|
1070
1123
|
if (prev === undefined) {
|
|
1071
1124
|
newest.set(key, c);
|
|
@@ -1086,14 +1139,22 @@ export function latestRunPerCheck(rollup: RollupEntry[]): RollupEntry[] {
|
|
|
1086
1139
|
* both the CheckRun shape (`conclusion` + `name`/`workflowName`) and the legacy StatusContext
|
|
1087
1140
|
* shape (`state` + `context`). The names are what the CI-fix agent is handed so it knows which
|
|
1088
1141
|
* gates to make green; `failingChecks` (the count) is derived from this list. Derivation is over the
|
|
1089
|
-
* **newest run per
|
|
1090
|
-
* run on the identical head SHA is not counted as a failing gate (issue #348).
|
|
1142
|
+
* **newest run per gate** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green
|
|
1143
|
+
* run on the identical head SHA is not counted as a failing gate (issue #348). A name is failed
|
|
1144
|
+
* when ANY non-superseded gate of that name is failed (issue #955): a green `push` run must not
|
|
1145
|
+
* satisfy a red `pull_request` run of the same name, so the names are deduped to the display name —
|
|
1146
|
+
* the required context (e.g. `build`) the fix-ci agent has to make green. */
|
|
1091
1147
|
export function failingCheckNames(rollup: RollupEntry[]): string[] {
|
|
1092
1148
|
const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
|
|
1093
1149
|
const names: string[] = [];
|
|
1150
|
+
const seen = new Set<string>();
|
|
1094
1151
|
for (const c of latestRunPerCheck(rollup)) {
|
|
1095
1152
|
const v = (c.conclusion || c.state || "").toUpperCase();
|
|
1096
|
-
|
|
1153
|
+
const name = checkKey(c);
|
|
1154
|
+
if (bad.has(v) && !seen.has(name)) {
|
|
1155
|
+
seen.add(name);
|
|
1156
|
+
names.push(name);
|
|
1157
|
+
}
|
|
1097
1158
|
}
|
|
1098
1159
|
return names;
|
|
1099
1160
|
}
|
|
@@ -1101,20 +1162,26 @@ export function failingCheckNames(rollup: RollupEntry[]): string[] {
|
|
|
1101
1162
|
/** Names of checks that are still in flight — queued or in progress, i.e. NOT yet complete and not a
|
|
1102
1163
|
* hard failure. Covers the CheckRun shape (`status` QUEUED/IN_PROGRESS/PENDING/WAITING/… anything but
|
|
1103
1164
|
* COMPLETED) and the legacy StatusContext shape (`state` PENDING/EXPECTED). Derived over the newest
|
|
1104
|
-
* run per
|
|
1105
|
-
* linger as pending
|
|
1106
|
-
*
|
|
1165
|
+
* run per gate (`latestRunPerCheck`) like {@link failingCheckNames}, so a superseded run doesn't
|
|
1166
|
+
* linger as pending; deduped to the display name so same-named runs from different events (issue
|
|
1167
|
+
* #955) list the gate once. A `checks-green` gate MUST count these so it never reports green while a
|
|
1168
|
+
* run has not yet concluded (a pending run has no failing conclusion, so it would otherwise slip
|
|
1169
|
+
* through). */
|
|
1107
1170
|
export function pendingCheckNames(rollup: RollupEntry[]): string[] {
|
|
1108
1171
|
const names: string[] = [];
|
|
1172
|
+
const seen = new Set<string>();
|
|
1109
1173
|
for (const c of latestRunPerCheck(rollup)) {
|
|
1110
1174
|
const status = (c.status || "").toUpperCase();
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1175
|
+
const name = checkKey(c);
|
|
1176
|
+
const pending =
|
|
1177
|
+
status !== ""
|
|
1178
|
+
? // CheckRun: anything other than COMPLETED is still running/queued.
|
|
1179
|
+
status !== "COMPLETED"
|
|
1180
|
+
: // Legacy StatusContext: PENDING/EXPECTED are not-yet-concluded.
|
|
1181
|
+
(c.state || "").toUpperCase() === "PENDING" || (c.state || "").toUpperCase() === "EXPECTED";
|
|
1182
|
+
if (pending && !seen.has(name)) {
|
|
1183
|
+
seen.add(name);
|
|
1184
|
+
names.push(name);
|
|
1118
1185
|
}
|
|
1119
1186
|
}
|
|
1120
1187
|
return names;
|
|
@@ -1124,35 +1191,84 @@ export function pendingCheckNames(rollup: RollupEntry[]): string[] {
|
|
|
1124
1191
|
* (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
|
|
1125
1192
|
* repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
|
|
1126
1193
|
* Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened.
|
|
1127
|
-
* Deduped to the newest run per
|
|
1194
|
+
* Deduped to the newest run per gate so a superseded rerun doesn't list a check name twice, and
|
|
1195
|
+
* deduped to the display name so same-named runs from different events (issue #955) report the
|
|
1196
|
+
* required context once — presence is a per-name question. */
|
|
1128
1197
|
export function allCheckNames(rollup: RollupEntry[]): string[] {
|
|
1129
1198
|
const names: string[] = [];
|
|
1199
|
+
const seen = new Set<string>();
|
|
1130
1200
|
for (const c of latestRunPerCheck(rollup)) {
|
|
1131
1201
|
const name = c.name || c.context || c.workflowName;
|
|
1132
|
-
if (name
|
|
1202
|
+
if (name && !seen.has(name)) {
|
|
1203
|
+
seen.add(name);
|
|
1204
|
+
names.push(name);
|
|
1205
|
+
}
|
|
1133
1206
|
}
|
|
1134
1207
|
return names;
|
|
1135
1208
|
}
|
|
1136
1209
|
|
|
1137
|
-
/** The ground-truth conclusion of
|
|
1138
|
-
* per
|
|
1139
|
-
* identical head SHA reports the green result, not the stale cancellation
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
* In-flight is normalised to `""` for BOTH shapes: a CheckRun
|
|
1143
|
-
*
|
|
1144
|
-
* status-context's `PENDING`/`EXPECTED`
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
|
|
1148
|
-
|
|
1210
|
+
/** The ground-truth terminal conclusion of EVERY retained gate of a check, keyed by check name,
|
|
1211
|
+
* derived over the **newest run per gate** (`latestRunPerCheck`) so a `CANCELLED` run superseded by
|
|
1212
|
+
* a newer green run on the identical head SHA reports the green result, not the stale cancellation
|
|
1213
|
+
* (issue #348). Each value is that gate run's `conclusion` (CheckRun) or `state` (legacy
|
|
1214
|
+
* StatusContext), upper-cased; a still-in-flight gate maps to `""` (it has no conclusion —
|
|
1215
|
+
* `pendingCheckNames` tracks those). In-flight is normalised to `""` for BOTH shapes: a CheckRun
|
|
1216
|
+
* whose `status` is not `COMPLETED`, and a legacy StatusContext whose `state` is
|
|
1217
|
+
* `PENDING`/`EXPECTED`, so a caller never mistakes a pending status-context's `PENDING`/`EXPECTED`
|
|
1218
|
+
* `state` for a terminal conclusion.
|
|
1219
|
+
*
|
|
1220
|
+
* Every retained gate KEEPS its own conclusion (issue #955): when same-named runs come from
|
|
1221
|
+
* DIFFERENT triggering events they are different gates, so the name maps to ONE ENTRY PER GATE —
|
|
1222
|
+
* e.g. a red `pull_request` `build` and a green `push` `build` yield `build → ["FAILURE",
|
|
1223
|
+
* "SUCCESS"]`. No cross-gate reduction happens here: preserving each gate's terminal conclusion is
|
|
1224
|
+
* what lets `requiredChecksVerdict` (a) route a settled FAILURE to fix-ci even while a same-named
|
|
1225
|
+
* sibling gate is still pending, and (b) require EVERY retained gate of a required name to satisfy
|
|
1226
|
+
* `acceptedConclusions` — a `SUCCESS` gate must not let a same-named `NEUTRAL`/`SKIPPED` gate pass a
|
|
1227
|
+
* success-only protocol, and a pending gate must not delay a settled rejection's remediation. */
|
|
1228
|
+
export function checkGateConclusions(rollup: RollupEntry[]): Record<string, string[]> {
|
|
1229
|
+
// A PROTOTYPE-FREE dictionary: a check can legitimately be named `constructor`, `toString`, or
|
|
1230
|
+
// `__proto__` (GitHub imposes no restriction), and on a plain `{}` those resolve to an INHERITED
|
|
1231
|
+
// property — `out[name] ?? []` would return a function/object, so `gates.push` throws and aborts
|
|
1232
|
+
// `fetchPrState` / readiness parsing for that PR (PR #957 review). `Object.create(null)` makes
|
|
1233
|
+
// every check name a safe own-key.
|
|
1234
|
+
const out: Record<string, string[]> = Object.create(null);
|
|
1149
1235
|
for (const c of latestRunPerCheck(rollup)) {
|
|
1150
1236
|
const status = (c.status || "").toUpperCase();
|
|
1151
1237
|
const state = (c.state || "").toUpperCase();
|
|
1152
1238
|
// A still-in-flight run has no terminal conclusion — normalise both shapes to "" (mirrors
|
|
1153
1239
|
// `pendingCheckNames`): CheckRun status != COMPLETED, or legacy StatusContext state PENDING/EXPECTED.
|
|
1154
1240
|
const inFlight = status !== "" ? status !== "COMPLETED" : state === "PENDING" || state === "EXPECTED";
|
|
1155
|
-
|
|
1241
|
+
const conclusion = inFlight ? "" : (c.conclusion || c.state || "").toUpperCase();
|
|
1242
|
+
const name = checkKey(c);
|
|
1243
|
+
const gates = out[name] ?? [];
|
|
1244
|
+
gates.push(conclusion);
|
|
1245
|
+
out[name] = gates;
|
|
1246
|
+
}
|
|
1247
|
+
return out;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** The ground-truth conclusion of each head check, keyed by check name — a SINGLE value per name,
|
|
1251
|
+
* reduced from {@link checkGateConclusions} failure-first: a hard failure (`FAILURE`, `TIMED_OUT`,
|
|
1252
|
+
* `CANCELLED`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `ERROR`) on ANY non-superseded gate wins over
|
|
1253
|
+
* another gate's green (a green `push` run must not satisfy a red required `pull_request` run of
|
|
1254
|
+
* the same name, issue #955), then a concluded result wins over a still-in-flight `""`, so a pending
|
|
1255
|
+
* gate never masks a settled one. This is the lossy single-conclusion projection kept for callers
|
|
1256
|
+
* that want one answer per name; the mergeability gate uses {@link checkGateConclusions} instead so
|
|
1257
|
+
* no retained gate's terminal conclusion is collapsed away before `acceptedConclusions` is honoured. */
|
|
1258
|
+
export function checkConclusions(rollup: RollupEntry[]): Record<string, string> {
|
|
1259
|
+
const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
|
|
1260
|
+
const out: Record<string, string> = {};
|
|
1261
|
+
for (const [name, gates] of Object.entries(checkGateConclusions(rollup))) {
|
|
1262
|
+
let best = "";
|
|
1263
|
+
for (const g of gates) {
|
|
1264
|
+
// A hard failure wins across gates; otherwise a settled conclusion beats an in-flight "".
|
|
1265
|
+
if (bad.has(g)) {
|
|
1266
|
+
best = g;
|
|
1267
|
+
break;
|
|
1268
|
+
}
|
|
1269
|
+
if (best === "" && g !== "") best = g;
|
|
1270
|
+
}
|
|
1271
|
+
out[name] = best;
|
|
1156
1272
|
}
|
|
1157
1273
|
return out;
|
|
1158
1274
|
}
|
|
@@ -1256,6 +1372,167 @@ export async function fetchMergeQueueMembership(
|
|
|
1256
1372
|
}
|
|
1257
1373
|
}
|
|
1258
1374
|
|
|
1375
|
+
/** GraphQL response for the per-check triggering-event enrichment. Each `CheckRun` carries its
|
|
1376
|
+
* immutable `databaseId` plus its `checkSuite.workflowRun.event` (e.g. `pull_request` / `push`); a
|
|
1377
|
+
* `StatusContext` (or a CheckRun whose suite/run is unreadable) contributes no event. `pageInfo`
|
|
1378
|
+
* drives the pagination loop (`contexts(first:100)` is per-page, so >100 contexts need the cursor). */
|
|
1379
|
+
interface CheckEventsResponse {
|
|
1380
|
+
data?: {
|
|
1381
|
+
repository?: {
|
|
1382
|
+
pullRequest?: {
|
|
1383
|
+
commits?: {
|
|
1384
|
+
nodes?: {
|
|
1385
|
+
commit?: {
|
|
1386
|
+
oid?: string;
|
|
1387
|
+
statusCheckRollup?: {
|
|
1388
|
+
contexts?: { nodes?: unknown[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null } | null;
|
|
1389
|
+
} | null;
|
|
1390
|
+
};
|
|
1391
|
+
}[];
|
|
1392
|
+
};
|
|
1393
|
+
} | null;
|
|
1394
|
+
} | null;
|
|
1395
|
+
} | null;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** Stamp each rollup entry's `event` from the GraphQL `statusCheckRollup.contexts` nodes, joining by
|
|
1399
|
+
* the run's IMMUTABLE identity — never by array position (PR #957 review): the `gh pr view` read and
|
|
1400
|
+
* this GraphQL read are two SEPARATE GitHub calls that can observe different runs or heads, so index
|
|
1401
|
+
* `i` in one is not reliably run `i` in the other, and a positional join can stamp one run's event
|
|
1402
|
+
* onto another (the name guard cannot catch it for duplicate `build` names, re-creating the very
|
|
1403
|
+
* cross-event collapse #955 fixed). The join key is the CheckRun `databaseId` — but the CLI's
|
|
1404
|
+
* `statusCheckRollup` exporter omits the id, so the join is two-step: (1) bucket BOTH sides by
|
|
1405
|
+
* `(name, startedAt)` — a run's start instant plus its display name identifies one run within a
|
|
1406
|
+
* head — then (2) stamp the node's `event` (and `databaseId`, so a later reader can re-join) onto
|
|
1407
|
+
* every rollup entry in the matching bucket. A bucket whose sizes disagree between the two reads is
|
|
1408
|
+
* left event-less (the reads observed different run sets — stamping anything would risk a
|
|
1409
|
+
* cross-run stamp), as is any entry with no matching bucket. Mutates `rollup` in place. Exported for
|
|
1410
|
+
* the transport regression test. */
|
|
1411
|
+
export function applyRollupEvents(rollup: RollupEntry[], nodes: unknown[]): void {
|
|
1412
|
+
/** The within-a-head identity of one run: its display name + start instant. Two reruns of one
|
|
1413
|
+
* check start at different instants; two different checks have different names — so the pair
|
|
1414
|
+
* singles out one run unless the two reads genuinely disagree (handled by the size check). */
|
|
1415
|
+
const bucketKey = (name: string, startedAt: string): string => `${name.length}:${name}${startedAt}`;
|
|
1416
|
+
type Stamp = { event: string; databaseId?: number };
|
|
1417
|
+
const nodeBuckets = new Map<string, Stamp[]>();
|
|
1418
|
+
const byUrl = new Map<string, Stamp | null>(); // null = duplicate URL → ambiguous, never joined
|
|
1419
|
+
for (const node of nodes) {
|
|
1420
|
+
if (typeof node !== "object" || node === null) continue;
|
|
1421
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
1422
|
+
const n = node as {
|
|
1423
|
+
__typename?: string;
|
|
1424
|
+
databaseId?: number | null;
|
|
1425
|
+
name?: string;
|
|
1426
|
+
startedAt?: string | null;
|
|
1427
|
+
detailsUrl?: string | null;
|
|
1428
|
+
checkSuite?: { workflowRun?: { event?: string | null } | null } | null;
|
|
1429
|
+
};
|
|
1430
|
+
if (n.__typename !== "CheckRun") continue; // a StatusContext carries no triggering event
|
|
1431
|
+
const event = n.checkSuite?.workflowRun?.event;
|
|
1432
|
+
if (typeof event !== "string" || event.trim() === "") continue; // unreadable run — nothing to stamp
|
|
1433
|
+
const stamp: Stamp = { event, ...(typeof n.databaseId === "number" ? { databaseId: n.databaseId } : {}) };
|
|
1434
|
+
if (typeof n.detailsUrl === "string" && n.detailsUrl !== "") {
|
|
1435
|
+
byUrl.set(n.detailsUrl, byUrl.has(n.detailsUrl) ? null : stamp);
|
|
1436
|
+
}
|
|
1437
|
+
const name = n.name ?? "";
|
|
1438
|
+
const startedAt = typeof n.startedAt === "string" ? n.startedAt : "";
|
|
1439
|
+
const bucket = nodeBuckets.get(bucketKey(name, startedAt)) ?? [];
|
|
1440
|
+
bucket.push(stamp);
|
|
1441
|
+
nodeBuckets.set(bucketKey(name, startedAt), bucket);
|
|
1442
|
+
}
|
|
1443
|
+
const stampEntry = (entry: RollupEntry, stamp: Stamp): void => {
|
|
1444
|
+
entry.event = stamp.event;
|
|
1445
|
+
if (stamp.databaseId !== undefined) entry.databaseId = stamp.databaseId;
|
|
1446
|
+
};
|
|
1447
|
+
const rollupBuckets = new Map<string, RollupEntry[]>();
|
|
1448
|
+
for (const entry of rollup) {
|
|
1449
|
+
// (1) Immutable identity: the details URL embeds the run/job id and is present in BOTH reads.
|
|
1450
|
+
const url = entry.detailsUrl;
|
|
1451
|
+
if (typeof url === "string" && url !== "" && byUrl.has(url)) {
|
|
1452
|
+
const stamp = byUrl.get(url);
|
|
1453
|
+
if (stamp) stampEntry(entry, stamp);
|
|
1454
|
+
continue; // joined (or ambiguous) by identity — never fall back to the heuristic bucket
|
|
1455
|
+
}
|
|
1456
|
+
const name = entry.name || entry.workflowName || "";
|
|
1457
|
+
const startedAt = entry.startedAt ?? "";
|
|
1458
|
+
const bucket = rollupBuckets.get(bucketKey(name, startedAt)) ?? [];
|
|
1459
|
+
bucket.push(entry);
|
|
1460
|
+
rollupBuckets.set(bucketKey(name, startedAt), bucket);
|
|
1461
|
+
}
|
|
1462
|
+
// (2) Fallback for entries without a joinable URL: a `(name, startedAt)` bucket. NEVER assign by
|
|
1463
|
+
// position within a multi-run bucket (PR #957 review) — stamp only when the bucket sizes agree AND
|
|
1464
|
+
// every node in it carries the SAME event, so the stamp is order-independent. A mixed-event
|
|
1465
|
+
// multi-run bucket stays event-less rather than risk swapping events across runs.
|
|
1466
|
+
for (const [key, entries] of rollupBuckets) {
|
|
1467
|
+
const nodeBucket = nodeBuckets.get(key);
|
|
1468
|
+
if (nodeBucket === undefined || nodeBucket.length !== entries.length) continue;
|
|
1469
|
+
if (nodeBucket.length === 1) {
|
|
1470
|
+
stampEntry(entries[0], nodeBucket[0]);
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
const event = nodeBucket[0].event;
|
|
1474
|
+
if (!nodeBucket.every((st) => st.event === event)) continue;
|
|
1475
|
+
for (const entry of entries) entry.event = event; // databaseId is ambiguous here — not stamped
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** The ONE GraphQL page query reading each check-run's triggering event (PR #957). Shared by the
|
|
1480
|
+
* merge loop ({@link enrichRollupEvents}) and the readiness probe so the two can never drift; its
|
|
1481
|
+
* brace balance is pinned by a unit test (a malformed query is rejected by GitHub and silently
|
|
1482
|
+
* leaves the rollup event-less). */
|
|
1483
|
+
export const CHECK_EVENTS_QUERY =
|
|
1484
|
+
"query($o:String!,$r:String!,$n:Int!,$after:String){repository(owner:$o,name:$r){pullRequest(number:$n){" +
|
|
1485
|
+
"commits(last:1){nodes{commit{oid statusCheckRollup{contexts(first:100,after:$after){nodes{" +
|
|
1486
|
+
"__typename ... on CheckRun{databaseId name startedAt detailsUrl checkSuite{workflowRun{event}}}}" +
|
|
1487
|
+
"pageInfo{hasNextPage endCursor}}}}}}}}}";
|
|
1488
|
+
|
|
1489
|
+
/** Populate each rollup entry's `event` from GraphQL, because the `gh pr view --json
|
|
1490
|
+
* statusCheckRollup` exporter omits it (cli/cli `api/export_pr.go`) — without it every run takes the
|
|
1491
|
+
* name-only gate fallback and a green `push` run collapses away a red required `pull_request` run of
|
|
1492
|
+
* the same name, leaving #955 unfixed on the real transport (PR #957 review). The event lives at
|
|
1493
|
+
* `CheckRun.checkSuite.workflowRun.event` (there is no `CheckRun.event` field). Pages the contexts
|
|
1494
|
+
* connection to exhaustion (`pageInfo.endCursor`) so >100 checks are all enriched, and verifies the
|
|
1495
|
+
* read observed the SAME head SHA as the rollup (`headSha`) before stamping — a head that moved
|
|
1496
|
+
* between the two reads aborts the stamp rather than joining across heads. Never throws and never
|
|
1497
|
+
* blocks the read: any transport/parse failure leaves the rollup event-less (pre-#955 behaviour)
|
|
1498
|
+
* rather than wedging the merge loop. Mutates `rollup` in place. */
|
|
1499
|
+
export async function enrichRollupEvents(
|
|
1500
|
+
repo: string,
|
|
1501
|
+
number: number | string,
|
|
1502
|
+
rollup: RollupEntry[],
|
|
1503
|
+
headSha?: string,
|
|
1504
|
+
): Promise<void> {
|
|
1505
|
+
if (rollup.length === 0) return;
|
|
1506
|
+
const [owner, name] = repo.split("/");
|
|
1507
|
+
const nodes: unknown[] = [];
|
|
1508
|
+
let after: string | null = null;
|
|
1509
|
+
try {
|
|
1510
|
+
// Page the contexts connection to exhaustion: `first:100` returns one page, and a PR with >100
|
|
1511
|
+
// contexts would leave every later page event-less (cross-event failures hidden there) and split
|
|
1512
|
+
// a same-event cancelled/rerun pair across the boundary into two gates (PR #957 review).
|
|
1513
|
+
for (;;) {
|
|
1514
|
+
const query = CHECK_EVENTS_QUERY;
|
|
1515
|
+
const args = ["api", "graphql", "-f", `query=${query}`, "-f", `o=${owner}`, "-f", `r=${name}`, "-F", `n=${number}`];
|
|
1516
|
+
if (after !== null) args.push("-f", `after=${after}`);
|
|
1517
|
+
const out = await runGh(args);
|
|
1518
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
1519
|
+
const payload = JSON.parse(out) as CheckEventsResponse;
|
|
1520
|
+
const commit = payload.data?.repository?.pullRequest?.commits?.nodes?.[0]?.commit;
|
|
1521
|
+
// Head-pin: the rollup came from an earlier `gh pr view` read; if the head moved since, these
|
|
1522
|
+
// contexts belong to a DIFFERENT head — stamping them would join events across heads.
|
|
1523
|
+
if (typeof headSha === "string" && headSha !== "" && commit?.oid !== undefined && commit.oid !== headSha) return;
|
|
1524
|
+
const contexts = commit?.statusCheckRollup?.contexts;
|
|
1525
|
+
nodes.push(...(contexts?.nodes ?? []));
|
|
1526
|
+
const pageInfo = contexts?.pageInfo;
|
|
1527
|
+
if (pageInfo?.hasNextPage !== true || typeof pageInfo.endCursor !== "string" || pageInfo.endCursor === "") break;
|
|
1528
|
+
after = pageInfo.endCursor;
|
|
1529
|
+
}
|
|
1530
|
+
} catch {
|
|
1531
|
+
return; // a transport/parse hiccup leaves the rollup event-less (conservative name-only gates)
|
|
1532
|
+
}
|
|
1533
|
+
applyRollupEvents(rollup, nodes);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1259
1536
|
export async function fetchPrState(
|
|
1260
1537
|
repo: string,
|
|
1261
1538
|
number: number | string,
|
|
@@ -1283,6 +1560,12 @@ export async function fetchPrState(
|
|
|
1283
1560
|
baseRefName?: string | null;
|
|
1284
1561
|
};
|
|
1285
1562
|
const rollup = j.statusCheckRollup ?? [];
|
|
1563
|
+
// The `gh pr view --json statusCheckRollup` exporter does NOT emit a run's triggering `event`
|
|
1564
|
+
// (cli/cli `api/export_pr.go` projects only name/status/conclusion/timestamps/workflowName), so a
|
|
1565
|
+
// raw rollup would take the name-only fallback and a green `push` run would still hide a red
|
|
1566
|
+
// `pull_request` run of the same name — leaving #955 unfixed on the real transport. Enrich each
|
|
1567
|
+
// entry with its event (GraphQL `CheckRun.checkSuite.workflowRun.event`) before grouping.
|
|
1568
|
+
await enrichRollupEvents(repo, number, rollup, j.headRefOid ?? undefined);
|
|
1286
1569
|
const names = failingCheckNames(rollup);
|
|
1287
1570
|
const merged = j.state === "MERGED" || !!j.mergedAt;
|
|
1288
1571
|
// Probe native-queue membership only for the queued-PR reconciliation (block 4) — it is an extra
|
|
@@ -1300,7 +1583,7 @@ export async function fetchPrState(
|
|
|
1300
1583
|
totalChecks: rollup.length,
|
|
1301
1584
|
presentCheckNames: allCheckNames(rollup),
|
|
1302
1585
|
pendingCheckNames: pendingCheckNames(rollup),
|
|
1303
|
-
|
|
1586
|
+
checkGateConclusions: checkGateConclusions(rollup),
|
|
1304
1587
|
isDraft: !!j.isDraft,
|
|
1305
1588
|
headRefOid: j.headRefOid ?? null,
|
|
1306
1589
|
mergeQueueEntry,
|
|
@@ -1339,7 +1622,7 @@ export async function fetchPrState(
|
|
|
1339
1622
|
totalChecks: -1, // …and the fresh-head-run remedy stays conservative (never reopens blind)
|
|
1340
1623
|
presentCheckNames: [], // …can't enumerate checks in token mode → no required-check presence signal
|
|
1341
1624
|
pendingCheckNames: [], // …no per-check pending signal either → classifier degrades to today's switch
|
|
1342
|
-
|
|
1625
|
+
checkGateConclusions: {}, // …no per-gate conclusions → protocol-aware gate falls through in token mode
|
|
1343
1626
|
isDraft: !!j.draft,
|
|
1344
1627
|
headRefOid: j.head?.sha ?? null,
|
|
1345
1628
|
mergeQueueEntry, // native-queue membership (GraphQL), probed only for block-4 reconciliation
|
|
@@ -1679,21 +1962,37 @@ function requiredChecksVerdict(s: PrState, protocol?: MergeProtocol): "blocked"
|
|
|
1679
1962
|
let anyBlocked = false;
|
|
1680
1963
|
let anyPending = false;
|
|
1681
1964
|
for (const rc of required) {
|
|
1682
|
-
// Absent from the head
|
|
1683
|
-
if (!present.has(rc.name)
|
|
1684
|
-
anyPending = true;
|
|
1685
|
-
continue;
|
|
1686
|
-
}
|
|
1687
|
-
const conclusion = (s.checkConclusions[rc.name] ?? "").toUpperCase();
|
|
1688
|
-
if (conclusion === "") {
|
|
1689
|
-
// Present but no terminal conclusion yet (and not flagged pending) — treat conservatively as
|
|
1690
|
-
// not-yet-concluded rather than as a pass.
|
|
1965
|
+
// Absent from the head entirely → not-yet-run → wait (never treat absence as pass).
|
|
1966
|
+
if (!present.has(rc.name)) {
|
|
1691
1967
|
anyPending = true;
|
|
1692
1968
|
continue;
|
|
1693
1969
|
}
|
|
1694
1970
|
const accepted = rc.acceptedConclusions.map((a) => a.toUpperCase());
|
|
1695
|
-
|
|
1696
|
-
|
|
1971
|
+
// Evaluate EVERY retained gate of this required name against `acceptedConclusions`, and a
|
|
1972
|
+
// SETTLED rejection BEFORE any pending sibling (issue #955). Same-named runs from different
|
|
1973
|
+
// events are different gates, so the name can carry several terminal conclusions. Two distinct
|
|
1974
|
+
// failure modes are guarded here:
|
|
1975
|
+
// • a settled gate whose conclusion is NOT accepted (a hard failure like `FAILURE`, or any
|
|
1976
|
+
// other unaccepted terminal conclusion — e.g. a `NEUTRAL`/`SKIPPED` gate under a
|
|
1977
|
+
// success-only protocol) is a red required check → `blocked`, routed to fix-ci NOW. A
|
|
1978
|
+
// same-named gate still pending must NOT read as `waiting` first and delay that remediation
|
|
1979
|
+
// (a red PR `build` plus an in-progress push `build` is BLOCKED, not "wait").
|
|
1980
|
+
// • a same-named gate that IS accepted (e.g. a `SUCCESS` run) must not satisfy the required
|
|
1981
|
+
// check while a sibling gate settled unaccepted — every retained gate must be accepted.
|
|
1982
|
+
const gates = s.checkGateConclusions[rc.name] ?? [];
|
|
1983
|
+
let namePending = pending.has(rc.name);
|
|
1984
|
+
let nameBlocked = false;
|
|
1985
|
+
for (const g of gates) {
|
|
1986
|
+
const conclusion = g.toUpperCase();
|
|
1987
|
+
if (conclusion === "") {
|
|
1988
|
+
// This gate has not concluded — treat conservatively as not-yet-concluded, not a pass.
|
|
1989
|
+
namePending = true;
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1992
|
+
if (!accepted.includes(conclusion)) nameBlocked = true; // settled, NOT accepted → red
|
|
1993
|
+
}
|
|
1994
|
+
if (nameBlocked) anyBlocked = true;
|
|
1995
|
+
else if (namePending) anyPending = true;
|
|
1697
1996
|
}
|
|
1698
1997
|
// A failing required check outranks a pending one: it needs fix-ci now, not more waiting.
|
|
1699
1998
|
if (anyBlocked) return "blocked";
|
|
@@ -1711,18 +2010,25 @@ const SETTLED_OK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED"]);
|
|
|
1711
2010
|
/** Issue #951: is this BLOCKED PR merely waiting for the repo's declared `mergify-queue` land step?
|
|
1712
2011
|
* True only when the protocol lands via `mergify-queue`, checks are enumerable and settled (none
|
|
1713
2012
|
* failing, none pending), and every non-SUCCESS/SKIPPED check is a Mergify queue check at NEUTRAL —
|
|
1714
|
-
* with at least one such check present. Anything else keeps the conservative `waiting`.
|
|
2013
|
+
* with at least one such check present. Anything else keeps the conservative `waiting`. With
|
|
2014
|
+
* per-gate conclusions (#955) EVERY retained gate of a name must qualify: a same-named gate that
|
|
2015
|
+
* settled unaccepted (not SUCCESS/SKIPPED, not a Mergify-queue NEUTRAL) disqualifies the name even
|
|
2016
|
+
* when a sibling gate is green. */
|
|
1715
2017
|
export function awaitingMergifyEnqueue(s: PrState, protocol?: MergeProtocol): boolean {
|
|
1716
2018
|
if (protocol?.land.method !== "mergify-queue") return false;
|
|
1717
2019
|
if (s.failingChecks !== 0 || s.pendingCheckNames.length > 0) return false;
|
|
1718
2020
|
let sawMergifyNeutral = false;
|
|
1719
|
-
for (const [name,
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
2021
|
+
for (const [name, gates] of Object.entries(s.checkGateConclusions)) {
|
|
2022
|
+
let nameHasMergifyNeutral = false;
|
|
2023
|
+
for (const conclusion of gates) {
|
|
2024
|
+
if (SETTLED_OK_CONCLUSIONS.has(conclusion)) continue;
|
|
2025
|
+
if (conclusion === "NEUTRAL" && isMergifyQueueCheck(name)) {
|
|
2026
|
+
nameHasMergifyNeutral = true;
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
return false;
|
|
1724
2030
|
}
|
|
1725
|
-
|
|
2031
|
+
if (nameHasMergifyNeutral) sawMergifyNeutral = true;
|
|
1726
2032
|
}
|
|
1727
2033
|
return sawMergifyNeutral;
|
|
1728
2034
|
}
|
|
@@ -39,7 +39,7 @@ function draftState(overrides: Partial<PrState> = {}): PrState {
|
|
|
39
39
|
totalChecks: 0, // no head run yet → frugal-CI stuck state
|
|
40
40
|
presentCheckNames: [],
|
|
41
41
|
pendingCheckNames: [],
|
|
42
|
-
|
|
42
|
+
checkGateConclusions: {},
|
|
43
43
|
isDraft: true,
|
|
44
44
|
headRefOid: "h1",
|
|
45
45
|
...overrides,
|