@intentius/chant 0.124.41 → 0.124.42
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/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +3 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -3
- package/dist/cli/handlers/pr.d.ts +3 -1
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +112 -2
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +11 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +8 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +15 -0
- package/dist/cli/registry.d.ts +19 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability.d.ts +47 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +44 -0
- package/dist/components/cli-support.d.ts +6 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +8 -1
- package/dist/components/driver.d.ts +10 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +52 -2
- package/dist/components/fan-out-run.d.ts +9 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +17 -1
- package/dist/components/gate-plan.d.ts +2 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +1 -0
- package/dist/components/pr-pipeline.d.ts +20 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +54 -16
- package/dist/components/pr-run.d.ts +6 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +3 -0
- package/dist/components/wave-plan.d.ts +14 -1
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +34 -2
- package/dist/op/activities/pr-refs.d.ts +10 -0
- package/dist/op/activities/pr-refs.d.ts.map +1 -0
- package/dist/op/activities/pr-refs.js +26 -0
- package/dist/op/activities/predict-behaviour.d.ts +2 -4
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.js +2 -21
- package/dist/op/activities/review-agent.js +1 -1
- package/dist/op/op-waves-run.d.ts +3 -79
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +4 -133
- package/dist/op/stage-hooks.d.ts +132 -0
- package/dist/op/stage-hooks.d.ts.map +1 -0
- package/dist/op/stage-hooks.js +142 -0
- package/dist/pr-forge.d.ts +28 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +251 -46
- package/dist/pr-loop.d.ts +18 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- package/dist/pr-requires.d.ts +38 -0
- package/dist/pr-requires.d.ts.map +1 -0
- package/dist/pr-requires.js +48 -0
- package/dist/wave-runtime.mjs +201 -201
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +19 -4
- package/dist/workspace/ci-init.d.ts.map +1 -1
- package/dist/workspace/ci-init.js +5 -4
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-workflow-at.d.ts +14 -0
- package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
- package/dist/workspace/ci-workflow-at.js +17 -0
- package/dist/workspace/ci-workflow.d.ts +11 -3
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +10 -2
- package/dist/workspace/declaration.d.ts +6 -3
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +2 -2
- package/dist/workspace/pr-report.schema.json +23 -3
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/build.ts +3 -0
- package/src/cli/handlers/fan-out.ts +27 -3
- package/src/cli/handlers/pr-before-merge.test.ts +276 -0
- package/src/cli/handlers/pr.ts +109 -2
- package/src/cli/handlers/run.ts +10 -0
- package/src/cli/main.ts +8 -2
- package/src/cli/parse-args.ts +11 -0
- package/src/cli/registry.ts +19 -0
- package/src/components/capability-targets.test.ts +162 -0
- package/src/components/capability.ts +71 -0
- package/src/components/cli-support.ts +14 -1
- package/src/components/driver.ts +54 -3
- package/src/components/fan-out-run.ts +19 -1
- package/src/components/gate-plan.ts +3 -0
- package/src/components/pr-pipeline.ts +64 -16
- package/src/components/pr-run.ts +9 -0
- package/src/components/wave-plan.ts +38 -3
- package/src/op/activities/pr-refs.ts +28 -0
- package/src/op/activities/predict-behaviour.ts +3 -23
- package/src/op/activities/review-agent.ts +1 -1
- package/src/op/op-waves-run.ts +20 -228
- package/src/op/stage-hooks.ts +251 -0
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.ts +235 -37
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.ts +25 -1
- package/src/pr-requires.test.ts +184 -0
- package/src/pr-requires.ts +79 -0
- package/src/review-agent-bundle.test.ts +35 -0
- package/src/stage-hooks-bundle.test.ts +38 -0
- package/src/workspace/checks/ci-workflow.test.ts +9 -0
- package/src/workspace/checks/ci-workflow.ts +18 -4
- package/src/workspace/ci-init.ts +5 -4
- package/src/workspace/ci-run.ts +2 -1
- package/src/workspace/ci-workflow-at.ts +23 -0
- package/src/workspace/ci-workflow.ts +13 -2
- package/src/workspace/declaration.schema.json +2 -2
- package/src/workspace/declaration.ts +6 -3
- package/src/workspace/pr-report.schema.json +23 -3
- package/src/workspace/reason-codes.ts +1 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stage-hook runner (#4288): one moment's hooks, with the `targets`,
|
|
3
|
+
* `perRun`, `onFailure` and argv filling a wave job uses. Its own module so
|
|
4
|
+
* a product that runs hooks around its own stages (terragucci) bundles it
|
|
5
|
+
* without the wave loop, the gate or the ledger. `op-waves-run.ts` runs
|
|
6
|
+
* the same hooks inside a wave job.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { opWaveHookRuns, type OpWaveHook, type OpWaveHookMoment } from "./op-waves";
|
|
13
|
+
|
|
14
|
+
/** What one moment's hooks came to, before any hold on another gate is settled. */
|
|
15
|
+
export interface HookListOutcome {
|
|
16
|
+
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
17
|
+
failed?: string;
|
|
18
|
+
/** The `onFailure: approve` hooks that failed. */
|
|
19
|
+
holds: string[];
|
|
20
|
+
outcomes: OpWaveHookOutcome[];
|
|
21
|
+
/** The hook calls that held the wave on another gate. */
|
|
22
|
+
otherGates: OtherGateHold[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
|
|
26
|
+
export const OP_WAVE_PENDING_RESERVED = [
|
|
27
|
+
"version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
|
|
28
|
+
"environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer", "wave", "heldBy",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
/** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
|
|
32
|
+
export interface OpWaveHoldRequest {
|
|
33
|
+
/** The op the gate is recorded under. Default: the spec's name. */
|
|
34
|
+
op?: string;
|
|
35
|
+
gate: string;
|
|
36
|
+
/** The digest an approval binds. */
|
|
37
|
+
digest: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
/** Fields for the pending fact, as a plans file's `pending`. */
|
|
40
|
+
pending?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One hook's run (#3762). */
|
|
44
|
+
export interface OpWaveHookOutcome {
|
|
45
|
+
moment: OpWaveHookMoment;
|
|
46
|
+
name: string;
|
|
47
|
+
/** The run, for a `perRun` hook. */
|
|
48
|
+
target?: string;
|
|
49
|
+
exit: number;
|
|
50
|
+
outcome: "passed" | "failed" | "held" | "skipped";
|
|
51
|
+
/** Why the moment was skipped, for `skipped` (#3811). */
|
|
52
|
+
reason?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One run as a hook sees it (#3804). */
|
|
56
|
+
export interface OpWaveHookRun {
|
|
57
|
+
target: string;
|
|
58
|
+
op: string;
|
|
59
|
+
/** The run's plan file, once planned. */
|
|
60
|
+
plan?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
|
|
64
|
+
export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
|
|
65
|
+
|
|
66
|
+
export const spawnExec: OpWaveExec = (argv, cwd, env) => {
|
|
67
|
+
const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
68
|
+
if (result.error) throw result.error;
|
|
69
|
+
return result.status ?? 1;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Fill one argv template. A placeholder whose value is not given is left as it is. */
|
|
73
|
+
export function opWaveArgv(
|
|
74
|
+
template: readonly string[],
|
|
75
|
+
values: { op: string; wave: string; target?: string; plan?: string; runs?: string; plans?: string },
|
|
76
|
+
): string[] {
|
|
77
|
+
const pairs: Array<[string, string | undefined]> = [
|
|
78
|
+
["{op}", values.op],
|
|
79
|
+
["{target}", values.target],
|
|
80
|
+
["{wave}", values.wave],
|
|
81
|
+
["{plans}", values.plans],
|
|
82
|
+
["{plan}", values.plan],
|
|
83
|
+
["{runs}", values.runs],
|
|
84
|
+
];
|
|
85
|
+
return template.map((part) => pairs.reduce((out, [key, value]) => (value === undefined ? out : out.replaceAll(key, value)), part));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
|
|
89
|
+
export function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
|
|
90
|
+
if (value === undefined) return undefined;
|
|
91
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
|
|
92
|
+
const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
|
|
93
|
+
if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
|
|
94
|
+
if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
|
|
95
|
+
return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
|
|
99
|
+
export interface OtherGateHold {
|
|
100
|
+
label: string;
|
|
101
|
+
name: string;
|
|
102
|
+
moment: OpWaveHookMoment;
|
|
103
|
+
target?: string;
|
|
104
|
+
argv: string[];
|
|
105
|
+
env: Record<string, string>;
|
|
106
|
+
request: OpWaveHoldRequest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
|
|
110
|
+
export function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
|
|
111
|
+
if (!existsSync(path)) return undefined;
|
|
112
|
+
try {
|
|
113
|
+
const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
|
|
114
|
+
if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
|
|
115
|
+
if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
|
|
116
|
+
return { gate: r.gate, digest: r.digest, ...(r.op ? { op: r.op } : {}), ...(typeof r.description === "string" ? { description: r.description } : {}), ...(r.pending ? { pending: pendingFields(r.pending, `the hold ${label} wrote`) ?? {} } : {}) };
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log(`${label} wrote a hold file that cannot be read (${err instanceof Error ? err.message : String(err)}), so it holds the wave on its set digest`);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Where hooks run: the wave they belong to and the job's tools. */
|
|
124
|
+
export interface HookPlace {
|
|
125
|
+
/** The spec's name and `op`. */
|
|
126
|
+
name: string;
|
|
127
|
+
op: string;
|
|
128
|
+
waveName: string;
|
|
129
|
+
k: number;
|
|
130
|
+
cwd: string;
|
|
131
|
+
exec: OpWaveExec;
|
|
132
|
+
log: (line: string) => void;
|
|
133
|
+
/** Where a hook writes a hold on another gate (`CHANT_HOLD`). Default: under the wave's directory. */
|
|
134
|
+
holdPath?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run one moment's hooks, in order: each once for the wave, or once per run
|
|
139
|
+
* for a `perRun` hook. A hook that fails stops the rest, unless its
|
|
140
|
+
* `onFailure` is `approve`: then it holds the wave and the hooks after it run.
|
|
141
|
+
*/
|
|
142
|
+
export function runHookList(
|
|
143
|
+
at: HookPlace,
|
|
144
|
+
moment: OpWaveHookMoment,
|
|
145
|
+
hooks: readonly OpWaveHook[] | undefined,
|
|
146
|
+
runs: readonly OpWaveHookRun[],
|
|
147
|
+
runsPath: string,
|
|
148
|
+
extraEnv: Record<string, string> = {},
|
|
149
|
+
): HookListOutcome {
|
|
150
|
+
const out: HookListOutcome = { holds: [], outcomes: [], otherGates: [] };
|
|
151
|
+
if (!hooks || hooks.length === 0) return out;
|
|
152
|
+
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
153
|
+
// #3821: a hook that holds the wave on another gate names it in this file.
|
|
154
|
+
const holdPath = at.holdPath ?? `.chant/op-waves/${specName}/wave-${k}/hold.json`;
|
|
155
|
+
const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
|
|
156
|
+
for (const hook of hooks) {
|
|
157
|
+
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
158
|
+
// #4288: a hook with `targets` is for the runs they match. Once for the wave, it runs when one matches or no run is known yet.
|
|
159
|
+
const mine = opWaveHookRuns(hook, runs);
|
|
160
|
+
if (hook.targets && (hook.perRun ? mine.length === 0 : mine.length === 0 && runs.length > 0)) {
|
|
161
|
+
log(`${moment} hook ${name}: no run matches its targets (${hook.targets.join(", ")}), so it did not run`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const calls = hook.perRun
|
|
165
|
+
? mine.map((run) => ({
|
|
166
|
+
target: run.target as string | undefined,
|
|
167
|
+
argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
|
|
168
|
+
env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
|
|
169
|
+
}))
|
|
170
|
+
: [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
|
|
171
|
+
for (const call of calls) {
|
|
172
|
+
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
173
|
+
let code: number;
|
|
174
|
+
rmSync(join(cwd, holdPath), { force: true });
|
|
175
|
+
try {
|
|
176
|
+
code = exec(call.argv, cwd, call.env);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
log(`${label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
|
+
code = 127;
|
|
180
|
+
}
|
|
181
|
+
const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
|
|
182
|
+
if (code === 0) {
|
|
183
|
+
out.outcomes.push({ ...base, outcome: "passed" });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (hook.onFailure === "approve") {
|
|
187
|
+
out.outcomes.push({ ...base, outcome: "held" });
|
|
188
|
+
const request = readHoldRequest(join(cwd, holdPath), log, label);
|
|
189
|
+
if (request) {
|
|
190
|
+
out.otherGates.push({ label, name, moment, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
|
|
191
|
+
log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
out.holds.push(label);
|
|
195
|
+
log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
out.outcomes.push({ ...base, outcome: "failed" });
|
|
199
|
+
out.failed = `${label} exited ${code}`;
|
|
200
|
+
log(out.failed);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The options of {@link runStageHooks} (#4288). */
|
|
208
|
+
export interface RunStageHooksOptions {
|
|
209
|
+
/** What the hooks belong to: `CHANT_OP_WAVES`, and the `{op}` of a hook that runs once. */
|
|
210
|
+
name: string;
|
|
211
|
+
op: string;
|
|
212
|
+
/** `{wave}` and `CHANT_WAVE`. */
|
|
213
|
+
wave: string;
|
|
214
|
+
/** `CHANT_WAVE_NUMBER`. Default 0. */
|
|
215
|
+
k?: number;
|
|
216
|
+
/** The runs a `perRun` hook runs for and `targets` match against. */
|
|
217
|
+
runs: readonly OpWaveHookRun[];
|
|
218
|
+
/** `{runs}` and `CHANT_RUNS`, relative to `cwd`; the caller writes it. */
|
|
219
|
+
runsFile: string;
|
|
220
|
+
/** `CHANT_HOLD`, relative to `cwd`. */
|
|
221
|
+
holdFile: string;
|
|
222
|
+
/** Added to every hook's environment. */
|
|
223
|
+
env?: Record<string, string>;
|
|
224
|
+
cwd?: string;
|
|
225
|
+
exec?: OpWaveExec;
|
|
226
|
+
log?: (line: string) => void;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** What {@link runStageHooks} gave. */
|
|
230
|
+
export interface StageHooksRun {
|
|
231
|
+
outcomes: OpWaveHookOutcome[];
|
|
232
|
+
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
233
|
+
failed?: string;
|
|
234
|
+
/** The `onFailure: approve` hooks that failed. A hold on another gate counts as one here. */
|
|
235
|
+
holds: string[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Run one moment's hooks outside an Op waves job (#4288): the pull-request
|
|
240
|
+
* jobs, or a product's own stage, with the same `targets`, `perRun`,
|
|
241
|
+
* `onFailure` and argv filling a wave job uses. A hook that holds on another
|
|
242
|
+
* gate (`CHANT_HOLD`) is counted as a plain hold; settling that gate is the
|
|
243
|
+
* caller's.
|
|
244
|
+
*/
|
|
245
|
+
export function runStageHooks(moment: OpWaveHookMoment, hooks: readonly OpWaveHook[] | undefined, options: RunStageHooksOptions): StageHooksRun {
|
|
246
|
+
const cwd = options.cwd ?? process.cwd();
|
|
247
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
248
|
+
const place: HookPlace = { name: options.name, op: options.op, waveName: options.wave, k: options.k ?? 0, cwd, exec: options.exec ?? spawnExec, log, holdPath: options.holdFile };
|
|
249
|
+
const out = runHookList(place, moment, hooks, options.runs, options.runsFile, options.env);
|
|
250
|
+
return { outcomes: out.outcomes, ...(out.failed ? { failed: out.failed } : {}), holds: [...out.holds, ...out.otherGates.map((g) => `${g.label} (on ${g.request.op ?? options.name} / ${g.request.gate})`)] };
|
|
251
|
+
}
|
|
@@ -34,7 +34,7 @@ describe("the pr-forge bundle", () => {
|
|
|
34
34
|
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
35
35
|
}
|
|
36
36
|
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
|
|
37
|
-
// About
|
|
38
|
-
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(
|
|
37
|
+
// About 47 KB today, after the pull request authors (#4000), pullRequestOpen for target locks (#4293) and the four requires checks of an apply before merge (#4301).
|
|
38
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(52 * 1024);
|
|
39
39
|
});
|
|
40
40
|
});
|
package/src/pr-forge.ts
CHANGED
|
@@ -411,6 +411,49 @@ export interface PrForge {
|
|
|
411
411
|
* pipelines. Throws a {@link ForgeApiError} when the forge will not say.
|
|
412
412
|
*/
|
|
413
413
|
scheduledRuns?(query: ScheduledRunsQuery): Promise<ScheduledRuns>;
|
|
414
|
+
/**
|
|
415
|
+
* The four checks an apply before merge requires (#4301, ws-106), each
|
|
416
|
+
* about pull request `pr` at `head`, the commit the job checked out. Each
|
|
417
|
+
* fails when the forge's head for `pr` is another commit. `approved`:
|
|
418
|
+
* someone other than the pull request's authors ({@link authors}) approved
|
|
419
|
+
* `head`, and no standing review asks for changes. On GitLab an approval
|
|
420
|
+
* counts when it came after the merge request's latest push, read from its
|
|
421
|
+
* system notes, and never from the token's own user.
|
|
422
|
+
*/
|
|
423
|
+
prApproved?(pr: number, head: string): Promise<PrRequireResult>;
|
|
424
|
+
/** The forge says `pr` merges as it stands: no conflicts, and on GitHub no branch protection blocking it. Pending while the forge works it out. */
|
|
425
|
+
prMergeable?(pr: number, head: string): Promise<PrRequireResult>;
|
|
426
|
+
/** `head` contains the tip of the branch `pr` merges into. */
|
|
427
|
+
prUndiverged?(pr: number, head: string): Promise<PrRequireResult>;
|
|
428
|
+
/**
|
|
429
|
+
* Every status and check reported on `head` passed, and at least one did.
|
|
430
|
+
* Statuses named in `ignore` are left out, such as the apply's own.
|
|
431
|
+
* Pending while one still runs or none has reported.
|
|
432
|
+
*/
|
|
433
|
+
prChecksPassed?(pr: number, head: string, ignore?: readonly string[]): Promise<PrRequireResult>;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** What one of the requires checks (#4301) found. */
|
|
437
|
+
export interface PrRequireResult {
|
|
438
|
+
passed: boolean;
|
|
439
|
+
/** Not settled yet: the forge is still working it out, or a check still runs. Asking again may pass. */
|
|
440
|
+
pending?: boolean;
|
|
441
|
+
/** One line for a person. */
|
|
442
|
+
reason: string;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const short = (sha: string | undefined): string => (sha ?? "nothing").slice(0, 8);
|
|
446
|
+
const pass = (reason: string): PrRequireResult => ({ passed: true, reason });
|
|
447
|
+
const refuse = (reason: string, pending = false): PrRequireResult => ({ passed: false, ...(pending ? { pending: true } : {}), reason });
|
|
448
|
+
const moved = (what: string, head: string, now: string | undefined): PrRequireResult => refuse(`${what}'s head is ${short(now)}, not ${short(head)}, the commit this job checked out`);
|
|
449
|
+
|
|
450
|
+
/** The verdict over statuses and checks: failing and still-running names, and how many reported. */
|
|
451
|
+
function checksVerdict(what: string, head: string, failing: string[], waiting: string[], count: number): PrRequireResult {
|
|
452
|
+
const names = (l: string[]) => [...new Set(l)].sort().join(", ");
|
|
453
|
+
if (failing.length > 0) return refuse(`${names(failing)} failed on ${short(head)}`);
|
|
454
|
+
if (waiting.length > 0) return refuse(`${names(waiting)} still running on ${short(head)}`, true);
|
|
455
|
+
if (count === 0) return refuse(`no check has reported on ${what}'s head ${short(head)} yet`, true);
|
|
456
|
+
return pass(`${count} check${count === 1 ? "" : "s"} passed on ${short(head)}`);
|
|
414
457
|
}
|
|
415
458
|
|
|
416
459
|
/** Which scheduled Op job {@link PrForge.scheduledRuns} asks about. */
|
|
@@ -624,6 +667,11 @@ export interface GithubForgeOptions {
|
|
|
624
667
|
fetch?: ForgeFetch;
|
|
625
668
|
}
|
|
626
669
|
|
|
670
|
+
/** GitLab's merge statuses while it works out whether a merge request can merge (#4301). */
|
|
671
|
+
const GITLAB_CHECKING = new Set(["checking", "unchecked", "preparing", "approvals_syncing", "cannot_be_merged_recheck"]);
|
|
672
|
+
/** GitLab's commit statuses still running. */
|
|
673
|
+
const GITLAB_RUNNING = new Set(["created", "waiting_for_resource", "preparing", "pending", "running", "scheduled"]);
|
|
674
|
+
|
|
627
675
|
/** GitHub keeps a status description to 140 characters; Forgejo follows. */
|
|
628
676
|
const GITHUB_DESCRIPTION = 140;
|
|
629
677
|
/** GitLab keeps a status description to 255 characters. */
|
|
@@ -693,6 +741,33 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
693
741
|
...(url ? { target_url: url } : {}),
|
|
694
742
|
});
|
|
695
743
|
};
|
|
744
|
+
const authorsOf = async (pr: number): Promise<string[]> => {
|
|
745
|
+
const logins = new Set<string>();
|
|
746
|
+
const add = (v: unknown) => {
|
|
747
|
+
if (isObject(v) && typeof v.login === "string" && v.login !== "") logins.add(v.login);
|
|
748
|
+
};
|
|
749
|
+
const opened = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
750
|
+
if (isObject(opened)) add(opened.user);
|
|
751
|
+
for (const c of await api.list(`${repo}/pulls/${pr}/commits`, pageParams, pageSize)) {
|
|
752
|
+
if (!isObject(c)) continue;
|
|
753
|
+
add(c.author);
|
|
754
|
+
add(c.committer);
|
|
755
|
+
}
|
|
756
|
+
return [...logins].sort();
|
|
757
|
+
};
|
|
758
|
+
const branchTip = async (branch: string): Promise<string | undefined> => {
|
|
759
|
+
let b: unknown;
|
|
760
|
+
try {
|
|
761
|
+
b = await api.call("GET", `${repo}/branches/${branch.split("/").map(encodeURIComponent).join("/")}`);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
if (err instanceof ForgeApiError && err.status === 404) return undefined;
|
|
764
|
+
throw err;
|
|
765
|
+
}
|
|
766
|
+
// GitHub names the commit `sha`, Forgejo `id`.
|
|
767
|
+
const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
|
|
768
|
+
return commit ? str(commit.sha) ?? str(commit.id) : undefined;
|
|
769
|
+
};
|
|
770
|
+
const headOf = (v: unknown): string | undefined => (isObject(v) && isObject(v.head) ? str(v.head.sha) : undefined);
|
|
696
771
|
return {
|
|
697
772
|
kind,
|
|
698
773
|
host,
|
|
@@ -720,19 +795,84 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
720
795
|
}
|
|
721
796
|
return [...standing].filter(([, state]) => state === "APPROVED").map(([login]) => login).sort();
|
|
722
797
|
},
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
};
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
for (const
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
798
|
+
authors: authorsOf,
|
|
799
|
+
async prApproved(pr, head) {
|
|
800
|
+
const got = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
801
|
+
const at = headOf(got);
|
|
802
|
+
if (at !== head) return moved(`pull request ${pr}`, head, at);
|
|
803
|
+
// A reviewer's newest deciding review stands, as in approvers; an approval counts only of this head.
|
|
804
|
+
const standing = new Map<string, { state: string; commit?: string }>();
|
|
805
|
+
for (const r of await api.list(`${repo}/pulls/${pr}/reviews`, pageParams, pageSize)) {
|
|
806
|
+
const login = loginOf(r);
|
|
807
|
+
if (!login || !isObject(r) || typeof r.state !== "string") continue;
|
|
808
|
+
const state = r.dismissed === true ? "DISMISSED" : r.state.toUpperCase();
|
|
809
|
+
if (state === "COMMENTED" || state === "COMMENT" || state === "PENDING") continue;
|
|
810
|
+
const commit = str(r.commit_id);
|
|
811
|
+
standing.set(login, { state, ...(commit ? { commit } : {}) });
|
|
812
|
+
}
|
|
813
|
+
const changes = [...standing].filter(([, r]) => r.state === "CHANGES_REQUESTED" || r.state === "REQUEST_CHANGES").map(([l]) => l).sort();
|
|
814
|
+
if (changes.length > 0) return refuse(`${changes.join(", ")} asked for changes`);
|
|
815
|
+
const authors = new Set(await authorsOf(pr));
|
|
816
|
+
const by = [...standing].filter(([l, r]) => r.state === "APPROVED" && r.commit === head && !authors.has(l)).map(([l]) => l).sort();
|
|
817
|
+
if (by.length === 0) return refuse(`no reviewer other than its authors approved its head ${short(head)}`);
|
|
818
|
+
return pass(`approved at ${short(head)} by ${by.join(", ")}`);
|
|
819
|
+
},
|
|
820
|
+
async prMergeable(pr, head) {
|
|
821
|
+
const got = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
822
|
+
const at = headOf(got);
|
|
823
|
+
if (at !== head) return moved(`pull request ${pr}`, head, at);
|
|
824
|
+
const m = isObject(got) ? got : {};
|
|
825
|
+
const into = isObject(m.base) ? str(m.base.ref) : undefined;
|
|
826
|
+
if (m.mergeable === false || m.mergeable_state === "dirty") return refuse(`the forge reports conflicts with ${into ?? "its base"}`);
|
|
827
|
+
if (m.mergeable !== true) return refuse("the forge has not worked out yet whether it can merge", true);
|
|
828
|
+
if (kind === "github" && m.mergeable_state === "blocked") return refuse(`branch protection on ${into ?? "its base"} blocks its merge`);
|
|
829
|
+
return pass(`it merges into ${into ?? "its base"} as it stands`);
|
|
830
|
+
},
|
|
831
|
+
async prUndiverged(pr, head) {
|
|
832
|
+
const got = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
833
|
+
const at = headOf(got);
|
|
834
|
+
if (at !== head) return moved(`pull request ${pr}`, head, at);
|
|
835
|
+
const into = isObject(got) && isObject(got.base) ? str(got.base.ref) : undefined;
|
|
836
|
+
if (!into) return refuse(`the forge names no branch pull request ${pr} merges into`);
|
|
837
|
+
if (kind === "github") {
|
|
838
|
+
// behind_by: the commits on the base branch that the head does not contain.
|
|
839
|
+
const cmp = await api.call("GET", `${repo}/compare/${encodeURIComponent(into)}...${head}`);
|
|
840
|
+
const behind = isObject(cmp) ? num(cmp.behind_by) : undefined;
|
|
841
|
+
if (behind === undefined) return refuse(`the forge did not say how far ${short(head)} is behind ${into}`);
|
|
842
|
+
return behind === 0 ? pass(`${short(head)} contains ${into}`) : refuse(`${short(head)} is ${behind} commit${behind === 1 ? "" : "s"} behind ${into}`);
|
|
843
|
+
}
|
|
844
|
+
// Forgejo: the pull request's merge base is the base branch's tip exactly when the head contains it.
|
|
845
|
+
const tip = await branchTip(into);
|
|
846
|
+
const base = isObject(got) ? str(got.merge_base) : undefined;
|
|
847
|
+
if (!tip || !base) return refuse(`the forge did not say where ${into} is or where ${short(head)} left it`);
|
|
848
|
+
return base === tip ? pass(`${short(head)} contains ${into} at ${short(tip)}`) : refuse(`${short(head)} does not contain ${into} at ${short(tip)}`);
|
|
849
|
+
},
|
|
850
|
+
async prChecksPassed(_pr, head, ignore = []) {
|
|
851
|
+
const skip = new Set(ignore);
|
|
852
|
+
const failing: string[] = [];
|
|
853
|
+
const waiting: string[] = [];
|
|
854
|
+
let count = 0;
|
|
855
|
+
// The combined status holds each context's newest status.
|
|
856
|
+
const combined = await api.call("GET", `${repo}/commits/${head}/status`);
|
|
857
|
+
for (const st of isObject(combined) && Array.isArray(combined.statuses) ? combined.statuses : []) {
|
|
858
|
+
const context = isObject(st) ? str(st.context) : undefined;
|
|
859
|
+
if (!context || skip.has(context)) continue;
|
|
860
|
+
const state = str(st.state) ?? str(st.status) ?? "";
|
|
861
|
+
count++;
|
|
862
|
+
if (state === "failure" || state === "error" || state === "failed") failing.push(context);
|
|
863
|
+
else if (state === "pending" || state === "running" || state === "") waiting.push(context);
|
|
734
864
|
}
|
|
735
|
-
|
|
865
|
+
if (kind === "github") {
|
|
866
|
+
const runs = await api.call("GET", `${repo}/commits/${head}/check-runs?per_page=100`);
|
|
867
|
+
for (const run of isObject(runs) && Array.isArray(runs.check_runs) ? runs.check_runs : []) {
|
|
868
|
+
const name = (isObject(run) ? str(run.name) : undefined) ?? "a check";
|
|
869
|
+
if (skip.has(name) || !isObject(run)) continue;
|
|
870
|
+
count++;
|
|
871
|
+
if (run.status !== "completed") waiting.push(name);
|
|
872
|
+
else if (!["success", "neutral", "skipped"].includes(String(run.conclusion))) failing.push(name);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return checksVerdict(`pull request ${_pr}`, head, failing, waiting, count);
|
|
736
876
|
},
|
|
737
877
|
async pullRequestOpen(pr) {
|
|
738
878
|
try {
|
|
@@ -817,18 +957,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
817
957
|
}
|
|
818
958
|
return out.sort((a, b) => a.number - b.number);
|
|
819
959
|
},
|
|
820
|
-
|
|
821
|
-
let b: unknown;
|
|
822
|
-
try {
|
|
823
|
-
b = await api.call("GET", `${repo}/branches/${branch.split("/").map(encodeURIComponent).join("/")}`);
|
|
824
|
-
} catch (err) {
|
|
825
|
-
if (err instanceof ForgeApiError && err.status === 404) return undefined;
|
|
826
|
-
throw err;
|
|
827
|
-
}
|
|
828
|
-
// GitHub names the commit `sha`, Forgejo `id`.
|
|
829
|
-
const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
|
|
830
|
-
return commit ? str(commit.sha) ?? str(commit.id) : undefined;
|
|
831
|
-
},
|
|
960
|
+
branchHead: branchTip,
|
|
832
961
|
async scheduledRuns(q) {
|
|
833
962
|
const file = encodeURIComponent(q.workflow);
|
|
834
963
|
if (kind === "github") {
|
|
@@ -998,6 +1127,22 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
998
1127
|
await api.call("PUT", `${project}/issues/${n}`, { state_event: "close" });
|
|
999
1128
|
},
|
|
1000
1129
|
};
|
|
1130
|
+
const authorsOf = async (mr: number): Promise<string[]> => {
|
|
1131
|
+
const logins = new Set<string>();
|
|
1132
|
+
const merge = await api.call("GET", `${notes}/${mr}`);
|
|
1133
|
+
const author = isObject(merge) ? authorOf(merge) : undefined;
|
|
1134
|
+
if (author) logins.add(author);
|
|
1135
|
+
const emails = new Set<string>();
|
|
1136
|
+
for (const c of await api.list(`${notes}/${mr}/commits`, pageParams, 100)) {
|
|
1137
|
+
if (!isObject(c)) continue;
|
|
1138
|
+
for (const email of [str(c.author_email), str(c.committer_email)]) if (email) emails.add(email.toLowerCase());
|
|
1139
|
+
}
|
|
1140
|
+
for (const email of [...emails].sort()) {
|
|
1141
|
+
const users = await api.call("GET", `/users?search=${encodeURIComponent(email)}`);
|
|
1142
|
+
for (const u of Array.isArray(users) ? users : []) if (isObject(u) && typeof u.username === "string") logins.add(u.username);
|
|
1143
|
+
}
|
|
1144
|
+
return [...logins].sort();
|
|
1145
|
+
};
|
|
1001
1146
|
return {
|
|
1002
1147
|
kind: "gitlab",
|
|
1003
1148
|
host,
|
|
@@ -1029,21 +1174,74 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
1029
1174
|
.filter((l): l is string => l !== undefined);
|
|
1030
1175
|
return [...new Set(logins)].sort();
|
|
1031
1176
|
},
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
const
|
|
1035
|
-
const
|
|
1036
|
-
if (
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1177
|
+
authors: authorsOf,
|
|
1178
|
+
async prApproved(mr, head) {
|
|
1179
|
+
const got = await api.call("GET", `${notes}/${mr}`);
|
|
1180
|
+
const at = isObject(got) ? str(got.sha) : undefined;
|
|
1181
|
+
if (at !== head) return moved(`merge request !${mr}`, head, at);
|
|
1182
|
+
// The token's own user approves for the pipeline, never as a reviewer.
|
|
1183
|
+
const me = await api.call("GET", "/user");
|
|
1184
|
+
const self = isObject(me) ? str(me.username) : undefined;
|
|
1185
|
+
if (!self) return refuse("GitLab did not say which user the job's token acts as, so no approval counts");
|
|
1186
|
+
// An approval counts once it came after the latest push: the newest version of the diff.
|
|
1187
|
+
const versions = await api.call("GET", `${notes}/${mr}/versions`);
|
|
1188
|
+
const since = Math.max(-Infinity, ...(Array.isArray(versions) ? versions : []).map((v) => Date.parse((isObject(v) ? str(v.created_at) : undefined) ?? "")).filter(Number.isFinite));
|
|
1189
|
+
const said = new Map<string, { what: string; at: number }>();
|
|
1190
|
+
for (const n of await api.list(`${notes}/${mr}/notes?sort=asc&order_by=created_at`, pageParams, 100)) {
|
|
1191
|
+
if (!isObject(n) || n.system !== true) continue;
|
|
1192
|
+
const who = authorOf(n);
|
|
1193
|
+
const body = (str(n.body) ?? "").trim();
|
|
1194
|
+
const what = /^approved this merge request/.test(body) ? "approved" : /^unapproved this merge request/.test(body) ? "unapproved" : /^requested changes/.test(body) ? "changes" : undefined;
|
|
1195
|
+
const when = Date.parse(str(n.created_at) ?? "");
|
|
1196
|
+
if (!who || who === self || !what || !Number.isFinite(when)) continue;
|
|
1197
|
+
const before = said.get(who);
|
|
1198
|
+
if (!before || when >= before.at) said.set(who, { what, at: when });
|
|
1199
|
+
}
|
|
1200
|
+
const changes = [...said].filter(([, v]) => v.what === "changes").map(([w]) => w).sort();
|
|
1201
|
+
if (changes.length > 0) return refuse(`${changes.join(", ")} requested changes`);
|
|
1202
|
+
const authors = new Set(await authorsOf(mr));
|
|
1203
|
+
const by = [...said].filter(([w, v]) => v.what === "approved" && v.at > since && !authors.has(w)).map(([w]) => w).sort();
|
|
1204
|
+
if (by.length === 0) return refuse(`no member other than its authors approved it after its latest push, ${short(head)}`);
|
|
1205
|
+
return pass(`approved after ${short(head)} by ${by.join(", ")}`);
|
|
1206
|
+
},
|
|
1207
|
+
async prMergeable(mr, head) {
|
|
1208
|
+
const got = await api.call("GET", `${notes}/${mr}`);
|
|
1209
|
+
const m = isObject(got) ? got : {};
|
|
1210
|
+
if (str(m.sha) !== head) return moved(`merge request !${mr}`, head, str(m.sha));
|
|
1211
|
+
const status = str(m.detailed_merge_status) ?? (m.merge_status === "can_be_merged" ? "mergeable" : (str(m.merge_status) ?? ""));
|
|
1212
|
+
const into = str(m.target_branch) ?? "its target branch";
|
|
1213
|
+
if (status === "conflict" || m.has_conflicts === true) return refuse(`GitLab reports conflicts with ${into}`);
|
|
1214
|
+
if (GITLAB_CHECKING.has(status)) return refuse("GitLab has not worked out yet whether it can merge", true);
|
|
1215
|
+
if (status !== "mergeable") return refuse(`GitLab says ${status || "nothing"} about merging it`);
|
|
1216
|
+
return pass(`it merges into ${into} as it stands`);
|
|
1217
|
+
},
|
|
1218
|
+
async prUndiverged(mr, head) {
|
|
1219
|
+
const got = await api.call("GET", `${notes}/${mr}?include_diverged_commits_count=true`);
|
|
1220
|
+
const m = isObject(got) ? got : {};
|
|
1221
|
+
if (str(m.sha) !== head) return moved(`merge request !${mr}`, head, str(m.sha));
|
|
1222
|
+
const into = str(m.target_branch) ?? "its target branch";
|
|
1223
|
+
const behind = num(m.diverged_commits_count);
|
|
1224
|
+
if (behind === undefined) return refuse(`GitLab did not say how far ${short(head)} is behind ${into}`);
|
|
1225
|
+
return behind === 0 ? pass(`${short(head)} contains ${into}`) : refuse(`${short(head)} is ${behind} commit${behind === 1 ? "" : "s"} behind ${into}`);
|
|
1226
|
+
},
|
|
1227
|
+
async prChecksPassed(mr, head, ignore = []) {
|
|
1228
|
+
const skip = new Set(ignore);
|
|
1229
|
+
// Each name's newest status; a job allowed to fail holds nothing back.
|
|
1230
|
+
const newest = new Map<string, Record<string, unknown>>();
|
|
1231
|
+
for (const st of await api.list(`${project}/repository/commits/${head}/statuses?all=true`, pageParams, 100)) {
|
|
1232
|
+
const name = isObject(st) ? str(st.name) : undefined;
|
|
1233
|
+
if (!name || skip.has(name) || !isObject(st)) continue;
|
|
1234
|
+
const had = newest.get(name);
|
|
1235
|
+
if (!had || (num(st.id) ?? 0) > (num(had.id) ?? 0)) newest.set(name, st);
|
|
1041
1236
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1237
|
+
const failing: string[] = [];
|
|
1238
|
+
const waiting: string[] = [];
|
|
1239
|
+
for (const [name, st] of newest) {
|
|
1240
|
+
const state = str(st.status) ?? "";
|
|
1241
|
+
if ((state === "failed" || state === "canceled") && st.allow_failure !== true) failing.push(name);
|
|
1242
|
+
else if (GITLAB_RUNNING.has(state)) waiting.push(name);
|
|
1045
1243
|
}
|
|
1046
|
-
return
|
|
1244
|
+
return checksVerdict(`merge request !${mr}`, head, failing, waiting, newest.size);
|
|
1047
1245
|
},
|
|
1048
1246
|
async pullRequestOpen(mr) {
|
|
1049
1247
|
try {
|
|
@@ -41,7 +41,7 @@ describe("the pull-request loop bundle", () => {
|
|
|
41
41
|
"packages/core/src/pr-forge.ts",
|
|
42
42
|
"packages/core/src/pr-loop.ts",
|
|
43
43
|
]);
|
|
44
|
-
// About
|
|
45
|
-
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(
|
|
44
|
+
// About 89 KB today, after the hooks line (#4288), the blocked set (#4291), the scheduled-run reads (#4289) and the requires checks (#4301).
|
|
45
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(96 * 1024);
|
|
46
46
|
});
|
|
47
47
|
});
|
package/src/pr-loop.ts
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
50
|
import type { ChangeSetAction, ChangeSetDocument } from "./change-set";
|
|
51
|
+
import type { PrRequireOutcome } from "./pr-requires";
|
|
51
52
|
import { GITHUB_COMMENT_LIMIT, groupChangeSet, renderPlanSummaryMarkdown } from "./plan-summary";
|
|
52
53
|
|
|
53
54
|
/** The schema a pull-request report names in `$schema`. */
|
|
@@ -80,6 +81,9 @@ export const PR_REPORT_OVERDUE_FLOOR = "0.124.41";
|
|
|
80
81
|
/** The first chant whose report can carry `policy` and the `policy-denied` and `policy-error` refusals (#4294). */
|
|
81
82
|
export const PR_REPORT_POLICY_FLOOR = "0.124.41";
|
|
82
83
|
|
|
84
|
+
/** The first chant whose apply report can carry `beforeMerge` and the `requires-unmet` and `before-merge-off` refusals (#4301, ws-106). */
|
|
85
|
+
export const PR_REPORT_BEFORE_MERGE_FLOOR = "0.124.42";
|
|
86
|
+
|
|
83
87
|
/** The gate a pull request's apply waits on, unless the pipeline names another. */
|
|
84
88
|
export const PR_APPLY_GATE = "pr-apply";
|
|
85
89
|
|
|
@@ -275,7 +279,16 @@ export type PrRefusal =
|
|
|
275
279
|
/** `ci.policy` at base denied the plan (#4294), and no override of it counts. */
|
|
276
280
|
| "policy-denied"
|
|
277
281
|
/** `ci.policy` at base could not be run, or gave no answer chant can read (#4294). */
|
|
278
|
-
| "policy-error"
|
|
282
|
+
| "policy-error"
|
|
283
|
+
/** `pr-apply --before-merge` (#4301): the pull request is not approved, mergeable, undiverged or green; `beforeMerge` says which. */
|
|
284
|
+
| "requires-unmet"
|
|
285
|
+
/** `pr-apply --before-merge`: the declaration at base does not set `ci.workflow` to `apply-before-merge` for the member. */
|
|
286
|
+
| "before-merge-off";
|
|
287
|
+
|
|
288
|
+
/** An apply before merge (#4301, ws-106): the requires checks the forge answered, in order. */
|
|
289
|
+
export interface PrBeforeMerge {
|
|
290
|
+
requires: PrRequireOutcome[];
|
|
291
|
+
}
|
|
279
292
|
|
|
280
293
|
/**
|
|
281
294
|
* One wave of an apply with `--wave-gate` (#4003): what it planned, where it
|
|
@@ -445,6 +458,12 @@ export interface PrReport {
|
|
|
445
458
|
* within contract 1 by chant {@link PR_REPORT_POLICY_FLOOR}.
|
|
446
459
|
*/
|
|
447
460
|
policy?: PrPolicyReport;
|
|
461
|
+
/**
|
|
462
|
+
* `pr-apply --before-merge` (#4301): the requires checks. Absent on every
|
|
463
|
+
* other stage. Added within contract 1 by chant
|
|
464
|
+
* {@link PR_REPORT_BEFORE_MERGE_FLOOR}.
|
|
465
|
+
*/
|
|
466
|
+
beforeMerge?: PrBeforeMerge;
|
|
448
467
|
}
|
|
449
468
|
|
|
450
469
|
/** The counts a member's entries add up to. */
|
|
@@ -771,6 +790,11 @@ export function prStatusDescription(report: PrReport): string {
|
|
|
771
790
|
text = "refused: an earlier wave has not applied";
|
|
772
791
|
break;
|
|
773
792
|
}
|
|
793
|
+
if (report.refusal === "requires-unmet" || report.refusal === "before-merge-off") {
|
|
794
|
+
const unmet = (report.beforeMerge?.requires ?? []).filter((r) => !r.passed).map((r) => r.name);
|
|
795
|
+
text = report.refusal === "before-merge-off" ? "refused: this workspace does not apply before merge" : `refused: ${unmet.join(", ") || "a require"} unmet for an apply before merge`;
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
774
798
|
if (report.refusal === "policy-denied" || report.refusal === "policy-error") {
|
|
775
799
|
text = report.refusal === "policy-denied" ? `refused: the policy denied the plan (${(report.policy?.rules ?? []).join(", ")})` : "refused: the policy could not be checked";
|
|
776
800
|
break;
|