@intentius/chant 0.124.42 → 0.124.43
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/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +1 -0
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/op-waves-run.d.ts +3 -3
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +2 -2
- package/dist/op/stage-hooks.d.ts +20 -0
- package/dist/op/stage-hooks.d.ts.map +1 -1
- package/dist/op/stage-hooks.js +76 -19
- package/dist/pr-forge.d.ts +31 -3
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +78 -8
- package/dist/pr-requires.d.ts +11 -2
- package/dist/pr-requires.d.ts.map +1 -1
- package/dist/pr-requires.js +6 -1
- package/dist/wave-runtime.mjs +165 -165
- package/package.json +1 -1
- package/src/cli/handlers/pr.ts +1 -0
- package/src/op/index.ts +4 -0
- package/src/op/op-waves-run.ts +6 -2
- package/src/op/stage-hooks-async.test.ts +79 -0
- package/src/op/stage-hooks.ts +116 -21
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.ts +113 -13
- package/src/pr-requires.test.ts +64 -0
- package/src/pr-requires.ts +17 -3
package/package.json
CHANGED
package/src/cli/handlers/pr.ts
CHANGED
|
@@ -1076,6 +1076,7 @@ async function beforeMergeChecks(ctx: CommandContext): Promise<PrBeforeMerge | n
|
|
|
1076
1076
|
}
|
|
1077
1077
|
// The apply's own statuses never hold it back: chant ci run's, and the report's.
|
|
1078
1078
|
const ignore = [`chant/${gate}`, `chant/${PR_APPLY_GATE}`, prStatusContext("apply", member)];
|
|
1079
|
+
// No `only`: ws-106 gives the declaration no way to leave a check out, so all four are asked (#4336).
|
|
1079
1080
|
const requires = await checkPrRequires(forge, args.pr, head, { ignore });
|
|
1080
1081
|
for (const r of requires) console.error(formatInfo(`${r.name}: ${r.passed ? "passed" : r.pending ? "pending" : "unmet"}, ${r.reason}`));
|
|
1081
1082
|
const unmet = unmetRequires(requires);
|
package/src/op/index.ts
CHANGED
|
@@ -75,6 +75,8 @@ export {
|
|
|
75
75
|
OP_WAVE_MAX_REPLANS,
|
|
76
76
|
opWaveHooksAtBase,
|
|
77
77
|
runStageHooks,
|
|
78
|
+
runStageHooksAsync,
|
|
79
|
+
spawnExecAsync,
|
|
78
80
|
opWaveNeedsApproval,
|
|
79
81
|
opWavePlansPath,
|
|
80
82
|
opWaveRunsPath,
|
|
@@ -91,6 +93,7 @@ export type {
|
|
|
91
93
|
OpWaveDecision,
|
|
92
94
|
OpWaveApplyHooksRun,
|
|
93
95
|
OpWaveApplyOutcome,
|
|
96
|
+
OpWaveAsyncExec,
|
|
94
97
|
OpWaveExec,
|
|
95
98
|
OpWaveHeldOn,
|
|
96
99
|
OpWaveHoldRequest,
|
|
@@ -106,6 +109,7 @@ export type {
|
|
|
106
109
|
OpWaveShowAtBase,
|
|
107
110
|
RecordHeadPlansOptions,
|
|
108
111
|
RunOpWaveApplyHooksOptions,
|
|
112
|
+
RunStageHooksAsyncOptions,
|
|
109
113
|
RunStageHooksOptions,
|
|
110
114
|
StageHooksRun,
|
|
111
115
|
RunOpWaveOptions,
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -112,19 +112,23 @@ import {
|
|
|
112
112
|
readHoldRequest,
|
|
113
113
|
runHookList,
|
|
114
114
|
runStageHooks,
|
|
115
|
+
runStageHooksAsync,
|
|
115
116
|
spawnExec,
|
|
117
|
+
spawnExecAsync,
|
|
116
118
|
type HookPlace,
|
|
119
|
+
type OpWaveAsyncExec,
|
|
117
120
|
type OpWaveExec,
|
|
118
121
|
type OpWaveHoldRequest,
|
|
119
122
|
type OpWaveHookOutcome,
|
|
120
123
|
type OpWaveHookRun,
|
|
121
124
|
type OtherGateHold,
|
|
125
|
+
type RunStageHooksAsyncOptions,
|
|
122
126
|
type RunStageHooksOptions,
|
|
123
127
|
type StageHooksRun,
|
|
124
128
|
} from "./stage-hooks";
|
|
125
129
|
|
|
126
|
-
export { OP_WAVE_PENDING_RESERVED, opWaveArgv, runStageHooks };
|
|
127
|
-
export type { OpWaveExec, OpWaveHoldRequest, OpWaveHookOutcome, OpWaveHookRun, RunStageHooksOptions, StageHooksRun };
|
|
130
|
+
export { OP_WAVE_PENDING_RESERVED, opWaveArgv, runStageHooks, runStageHooksAsync, spawnExecAsync };
|
|
131
|
+
export type { OpWaveAsyncExec, OpWaveExec, OpWaveHoldRequest, OpWaveHookOutcome, OpWaveHookRun, RunStageHooksAsyncOptions, RunStageHooksOptions, StageHooksRun };
|
|
128
132
|
|
|
129
133
|
/** What a run's plan command writes to `{plan}`. */
|
|
130
134
|
export interface OpWavePlanFile {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runStageHooksAsync (#4334): the async stage runner gives what the sync one
|
|
3
|
+
* gives for the same hooks, and two calls run side by side.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import type { OpWaveHook } from "./op-waves";
|
|
10
|
+
import { runStageHooks, runStageHooksAsync, spawnExecAsync, type OpWaveAsyncExec, type OpWaveExec } from "./stage-hooks";
|
|
11
|
+
|
|
12
|
+
const hooks: OpWaveHook[] = [
|
|
13
|
+
{ name: "each", command: ["hook", "each", "{target}", "{op}"], perRun: true, targets: ["api"] },
|
|
14
|
+
{ name: "gate", command: ["hook", "gate", "{wave}"], onFailure: "approve" },
|
|
15
|
+
{ name: "other", command: ["hook", "other"], onFailure: "approve" },
|
|
16
|
+
{ name: "lost", command: ["hook", "lost"] },
|
|
17
|
+
{ name: "stop", command: ["hook", "stop"] },
|
|
18
|
+
{ name: "after", command: ["hook", "after"] },
|
|
19
|
+
];
|
|
20
|
+
const runs = [{ target: "api", op: "pr-7" }, { target: "web", op: "pr-7" }];
|
|
21
|
+
|
|
22
|
+
/** Exit codes by the hook's second word; `other` writes a hold on another gate and `lost` cannot start. */
|
|
23
|
+
function codeOf(argv: string[], cwd: string, env?: Record<string, string>): number {
|
|
24
|
+
if (argv[1] === "other") writeFileSync(join(cwd, env!.CHANT_HOLD!), JSON.stringify({ gate: "dba", digest: "sha256:abc" }));
|
|
25
|
+
if (argv[1] === "lost") throw new Error("spawn hook ENOENT");
|
|
26
|
+
return argv[1] === "gate" || argv[1] === "other" || argv[1] === "stop" ? 1 : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("runStageHooksAsync (#4334)", () => {
|
|
30
|
+
test("gives the outcomes, holds and failure the sync runner gives", async () => {
|
|
31
|
+
const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
|
|
32
|
+
const options = { name: "pr-7", op: "pr-7", wave: "prod", runs, runsFile: "runs.json", holdFile: "hold.json", cwd };
|
|
33
|
+
const syncCalls: string[] = [];
|
|
34
|
+
const syncLog: string[] = [];
|
|
35
|
+
const exec: OpWaveExec = (argv, at, env) => (syncCalls.push(argv.join(" ")), codeOf(argv, at, env));
|
|
36
|
+
const sync = runStageHooks("before-plan", hooks, { ...options, exec, log: (l) => syncLog.push(l) });
|
|
37
|
+
const asyncCalls: string[] = [];
|
|
38
|
+
const asyncLog: string[] = [];
|
|
39
|
+
const asyncExec: OpWaveAsyncExec = async (argv, at, env) => (asyncCalls.push(argv.join(" ")), codeOf(argv, at, env));
|
|
40
|
+
const ran = await runStageHooksAsync("before-plan", hooks, { ...options, exec: asyncExec, log: (l) => asyncLog.push(l) });
|
|
41
|
+
expect(ran).toEqual(sync);
|
|
42
|
+
expect(asyncCalls).toEqual(syncCalls);
|
|
43
|
+
expect(asyncLog).toEqual(syncLog);
|
|
44
|
+
expect(asyncCalls).toEqual(["hook each api pr-7", "hook gate prod", "hook other", "hook lost"]);
|
|
45
|
+
expect(ran.failed).toBe("before-plan hook lost exited 127");
|
|
46
|
+
expect(ran.holds).toEqual(["before-plan hook gate", "before-plan hook other (on pr-7 / dba)"]);
|
|
47
|
+
expect(ran.outcomes.map((o) => o.outcome)).toEqual(["passed", "held", "held", "failed"]);
|
|
48
|
+
expect(asyncLog).toContain("before-plan hook lost did not run: spawn hook ENOENT");
|
|
49
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("two calls run side by side", async () => {
|
|
53
|
+
const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
|
|
54
|
+
const started: string[] = [];
|
|
55
|
+
let release!: () => void;
|
|
56
|
+
const both = new Promise<void>((resolve) => (release = resolve));
|
|
57
|
+
const exec: OpWaveAsyncExec = async (argv) => {
|
|
58
|
+
started.push(argv[1]!);
|
|
59
|
+
if (started.length === 2) release();
|
|
60
|
+
await both;
|
|
61
|
+
return 0;
|
|
62
|
+
};
|
|
63
|
+
const one = (name: string) =>
|
|
64
|
+
runStageHooksAsync("before-plan", [{ command: ["hook", name] }], { name, op: name, wave: "prod", runs: [], runsFile: `${name}/runs.json`, holdFile: `${name}/hold.json`, cwd, exec, log: () => {} });
|
|
65
|
+
const [a, b] = await Promise.all([one("a"), one("b")]);
|
|
66
|
+
expect(started.sort()).toEqual(["a", "b"]);
|
|
67
|
+
expect([a.outcomes[0]!.outcome, b.outcomes[0]!.outcome]).toEqual(["passed", "passed"]);
|
|
68
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the default exec spawns the hook, gives its exit code and rejects a command that cannot start", async () => {
|
|
72
|
+
const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
|
|
73
|
+
expect(await spawnExecAsync([process.execPath, "-e", "process.exit(process.env.CODE === '3' ? 3 : 0)"], cwd, { CODE: "3" })).toBe(3);
|
|
74
|
+
await expect(spawnExecAsync(["./no-such-hook"], cwd)).rejects.toThrow(/ENOENT/);
|
|
75
|
+
const ran = await runStageHooksAsync("after-apply", [{ name: "ok", command: [process.execPath, "-e", "process.exit(0)"] }], { name: "s", op: "s", wave: "w", runs: [], runsFile: "runs.json", holdFile: "hold.json", cwd, log: () => {} });
|
|
76
|
+
expect(ran).toEqual({ outcomes: [{ moment: "after-apply", name: "ok", exit: 0, outcome: "passed" }], holds: [] });
|
|
77
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/op/stage-hooks.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the same hooks inside a wave job.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
10
10
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { opWaveHookRuns, type OpWaveHook, type OpWaveHookMoment } from "./op-waves";
|
|
@@ -69,6 +69,17 @@ export const spawnExec: OpWaveExec = (argv, cwd, env) => {
|
|
|
69
69
|
return result.status ?? 1;
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
+
/** {@link OpWaveExec} for {@link runStageHooksAsync} (#4334): the exit code comes back as a promise, so the event loop runs while a hook does. */
|
|
73
|
+
export type OpWaveAsyncExec = (argv: string[], cwd: string, env?: Record<string, string>) => Promise<number>;
|
|
74
|
+
|
|
75
|
+
/** {@link spawnExec} on `spawn`: it rejects when the command cannot start. */
|
|
76
|
+
export const spawnExecAsync: OpWaveAsyncExec = (argv, cwd, env) =>
|
|
77
|
+
new Promise((resolve, reject) => {
|
|
78
|
+
const child = spawn(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
79
|
+
child.once("error", reject);
|
|
80
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
81
|
+
});
|
|
82
|
+
|
|
72
83
|
/** Fill one argv template. A placeholder whose value is not given is left as it is. */
|
|
73
84
|
export function opWaveArgv(
|
|
74
85
|
template: readonly string[],
|
|
@@ -134,22 +145,30 @@ export interface HookPlace {
|
|
|
134
145
|
holdPath?: string;
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
/** One hook call {@link hookListSteps} asks its driver to run. The driver sends back the exit code. */
|
|
149
|
+
interface HookCall {
|
|
150
|
+
label: string;
|
|
151
|
+
argv: string[];
|
|
152
|
+
cwd: string;
|
|
153
|
+
env: Record<string, string>;
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
157
|
+
* The hook list as steps (#4334): it yields each call and takes its exit
|
|
158
|
+
* code back, so the sync and async runners share the targets, hold, outcome
|
|
159
|
+
* and other-gate handling and differ only in how they wait for a call.
|
|
141
160
|
*/
|
|
142
|
-
|
|
143
|
-
at: HookPlace,
|
|
161
|
+
function* hookListSteps(
|
|
162
|
+
at: Omit<HookPlace, "exec">,
|
|
144
163
|
moment: OpWaveHookMoment,
|
|
145
164
|
hooks: readonly OpWaveHook[] | undefined,
|
|
146
165
|
runs: readonly OpWaveHookRun[],
|
|
147
166
|
runsPath: string,
|
|
148
|
-
extraEnv: Record<string, string
|
|
149
|
-
): HookListOutcome {
|
|
167
|
+
extraEnv: Record<string, string>,
|
|
168
|
+
): Generator<HookCall, HookListOutcome, number> {
|
|
150
169
|
const out: HookListOutcome = { holds: [], outcomes: [], otherGates: [] };
|
|
151
170
|
if (!hooks || hooks.length === 0) return out;
|
|
152
|
-
const { name: specName, op, waveName, k, cwd,
|
|
171
|
+
const { name: specName, op, waveName, k, cwd, log } = at;
|
|
153
172
|
// #3821: a hook that holds the wave on another gate names it in this file.
|
|
154
173
|
const holdPath = at.holdPath ?? `.chant/op-waves/${specName}/wave-${k}/hold.json`;
|
|
155
174
|
const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
|
|
@@ -170,14 +189,8 @@ export function runHookList(
|
|
|
170
189
|
: [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
|
|
171
190
|
for (const call of calls) {
|
|
172
191
|
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
173
|
-
let code: number;
|
|
174
192
|
rmSync(join(cwd, holdPath), { force: true });
|
|
175
|
-
|
|
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
|
-
}
|
|
193
|
+
const code = yield { label, argv: call.argv, cwd, env: call.env };
|
|
181
194
|
const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
|
|
182
195
|
if (code === 0) {
|
|
183
196
|
out.outcomes.push({ ...base, outcome: "passed" });
|
|
@@ -204,6 +217,64 @@ export function runHookList(
|
|
|
204
217
|
return out;
|
|
205
218
|
}
|
|
206
219
|
|
|
220
|
+
/** The exit code a call that did not start counts as, after its error is logged. */
|
|
221
|
+
function didNotRun(call: HookCall, err: unknown, log: (line: string) => void): number {
|
|
222
|
+
log(`${call.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
223
|
+
return 127;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Run one moment's hooks, in order: each once for the wave, or once per run
|
|
228
|
+
* for a `perRun` hook. A hook that fails stops the rest, unless its
|
|
229
|
+
* `onFailure` is `approve`: then it holds the wave and the hooks after it run.
|
|
230
|
+
*/
|
|
231
|
+
export function runHookList(
|
|
232
|
+
at: HookPlace,
|
|
233
|
+
moment: OpWaveHookMoment,
|
|
234
|
+
hooks: readonly OpWaveHook[] | undefined,
|
|
235
|
+
runs: readonly OpWaveHookRun[],
|
|
236
|
+
runsPath: string,
|
|
237
|
+
extraEnv: Record<string, string> = {},
|
|
238
|
+
): HookListOutcome {
|
|
239
|
+
const steps = hookListSteps(at, moment, hooks, runs, runsPath, extraEnv);
|
|
240
|
+
let step = steps.next();
|
|
241
|
+
while (!step.done) {
|
|
242
|
+
const call = step.value;
|
|
243
|
+
let code: number;
|
|
244
|
+
try {
|
|
245
|
+
code = at.exec(call.argv, call.cwd, call.env);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
code = didNotRun(call, err, at.log);
|
|
248
|
+
}
|
|
249
|
+
step = steps.next(code);
|
|
250
|
+
}
|
|
251
|
+
return step.value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** {@link runHookList} with an exec that returns a promise (#4334). */
|
|
255
|
+
export async function runHookListAsync(
|
|
256
|
+
at: Omit<HookPlace, "exec"> & { exec: OpWaveAsyncExec },
|
|
257
|
+
moment: OpWaveHookMoment,
|
|
258
|
+
hooks: readonly OpWaveHook[] | undefined,
|
|
259
|
+
runs: readonly OpWaveHookRun[],
|
|
260
|
+
runsPath: string,
|
|
261
|
+
extraEnv: Record<string, string> = {},
|
|
262
|
+
): Promise<HookListOutcome> {
|
|
263
|
+
const steps = hookListSteps(at, moment, hooks, runs, runsPath, extraEnv);
|
|
264
|
+
let step = steps.next();
|
|
265
|
+
while (!step.done) {
|
|
266
|
+
const call = step.value;
|
|
267
|
+
let code: number;
|
|
268
|
+
try {
|
|
269
|
+
code = await at.exec(call.argv, call.cwd, call.env);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
code = didNotRun(call, err, at.log);
|
|
272
|
+
}
|
|
273
|
+
step = steps.next(code);
|
|
274
|
+
}
|
|
275
|
+
return step.value;
|
|
276
|
+
}
|
|
277
|
+
|
|
207
278
|
/** The options of {@link runStageHooks} (#4288). */
|
|
208
279
|
export interface RunStageHooksOptions {
|
|
209
280
|
/** What the hooks belong to: `CHANT_OP_WAVES`, and the `{op}` of a hook that runs once. */
|
|
@@ -235,6 +306,22 @@ export interface StageHooksRun {
|
|
|
235
306
|
holds: string[];
|
|
236
307
|
}
|
|
237
308
|
|
|
309
|
+
/** {@link RunStageHooksOptions} for {@link runStageHooksAsync} (#4334). */
|
|
310
|
+
export interface RunStageHooksAsyncOptions extends Omit<RunStageHooksOptions, "exec"> {
|
|
311
|
+
/** Default: {@link spawnExecAsync}. */
|
|
312
|
+
exec?: OpWaveAsyncExec;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Where a stage runner's hooks run, without the exec. */
|
|
316
|
+
function stagePlace(options: Omit<RunStageHooksOptions, "exec">): Omit<HookPlace, "exec"> {
|
|
317
|
+
return { name: options.name, op: options.op, waveName: options.wave, k: options.k ?? 0, cwd: options.cwd ?? process.cwd(), log: options.log ?? ((line: string) => console.error(line)), holdPath: options.holdFile };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** A stage run's result: a hold on another gate counts as a plain hold. */
|
|
321
|
+
function stageRun(out: HookListOutcome, name: string): StageHooksRun {
|
|
322
|
+
return { outcomes: out.outcomes, ...(out.failed ? { failed: out.failed } : {}), holds: [...out.holds, ...out.otherGates.map((g) => `${g.label} (on ${g.request.op ?? name} / ${g.request.gate})`)] };
|
|
323
|
+
}
|
|
324
|
+
|
|
238
325
|
/**
|
|
239
326
|
* Run one moment's hooks outside an Op waves job (#4288): the pull-request
|
|
240
327
|
* jobs, or a product's own stage, with the same `targets`, `perRun`,
|
|
@@ -243,9 +330,17 @@ export interface StageHooksRun {
|
|
|
243
330
|
* caller's.
|
|
244
331
|
*/
|
|
245
332
|
export function runStageHooks(moment: OpWaveHookMoment, hooks: readonly OpWaveHook[] | undefined, options: RunStageHooksOptions): StageHooksRun {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
333
|
+
const place = { ...stagePlace(options), exec: options.exec ?? spawnExec };
|
|
334
|
+
return stageRun(runHookList(place, moment, hooks, options.runs, options.runsFile, options.env), options.name);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* {@link runStageHooks} with an exec that returns a promise (#4334), so a
|
|
339
|
+
* product can run several stages' hooks side by side. The hooks of one call
|
|
340
|
+
* still run one after another. Give each concurrent call its own `holdFile`
|
|
341
|
+
* and `runsFile`.
|
|
342
|
+
*/
|
|
343
|
+
export async function runStageHooksAsync(moment: OpWaveHookMoment, hooks: readonly OpWaveHook[] | undefined, options: RunStageHooksAsyncOptions): Promise<StageHooksRun> {
|
|
344
|
+
const place = { ...stagePlace(options), exec: options.exec ?? spawnExecAsync };
|
|
345
|
+
return stageRun(await runHookListAsync(place, moment, hooks, options.runs, options.runsFile, options.env), options.name);
|
|
251
346
|
}
|
|
@@ -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
|
|
37
|
+
// About 50 KB today, after the pull request authors (#4000), pullRequestOpen for target locks (#4293), the four requires checks of an apply before merge (#4301) and the approver filter (#4337).
|
|
38
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
|
@@ -418,10 +418,16 @@ export interface PrForge {
|
|
|
418
418
|
* someone other than the pull request's authors ({@link authors}) approved
|
|
419
419
|
* `head`, and no standing review asks for changes. On GitLab an approval
|
|
420
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.
|
|
421
|
+
* system notes, and never from the token's own user. `approvers` narrows
|
|
422
|
+
* who counts (#4337), and a refusal names each approver left out and why.
|
|
423
|
+
*/
|
|
424
|
+
prApproved?(pr: number, head: string, approvers?: PrApproverFilter): Promise<PrRequireResult>;
|
|
425
|
+
/**
|
|
426
|
+
* The forge says `pr` merges as it stands: no conflicts, and on GitHub no
|
|
427
|
+
* branch protection blocking it. Pending while the forge works it out. On
|
|
428
|
+
* Forgejo, which answers `mergeable: false` while it still checks, a false
|
|
429
|
+
* is pending too, so it counts as conflicts only once the reads run out.
|
|
422
430
|
*/
|
|
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
431
|
prMergeable?(pr: number, head: string): Promise<PrRequireResult>;
|
|
426
432
|
/** `head` contains the tip of the branch `pr` merges into. */
|
|
427
433
|
prUndiverged?(pr: number, head: string): Promise<PrRequireResult>;
|
|
@@ -433,6 +439,68 @@ export interface PrForge {
|
|
|
433
439
|
prChecksPassed?(pr: number, head: string, ignore?: readonly string[]): Promise<PrRequireResult>;
|
|
434
440
|
}
|
|
435
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Who counts as an approver for {@link PrForge.prApproved} (#4337). With
|
|
444
|
+
* none set, any reviewer other than the pull request's authors counts.
|
|
445
|
+
*/
|
|
446
|
+
export interface PrApproverFilter {
|
|
447
|
+
/**
|
|
448
|
+
* The forge's own rule for who may approve. GitHub: write access (admin,
|
|
449
|
+
* maintain or write), read per approver. Forgejo: the review is official,
|
|
450
|
+
* which Forgejo marks for someone with write access. GitLab: an access
|
|
451
|
+
* level of at least `minAccessLevel`, read per approver.
|
|
452
|
+
*/
|
|
453
|
+
forgeRule?: boolean;
|
|
454
|
+
/** GitLab under `forgeRule`: the least access level that counts. Default 30, Developer. */
|
|
455
|
+
minAccessLevel?: number;
|
|
456
|
+
/** Logins whose approval never counts, compared case-insensitively, each with the reason a refusal gives if one is set. */
|
|
457
|
+
exclude?: readonly (string | PrExcludedApprover)[];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** A login left out of the approvers, and the reason a refusal gives. */
|
|
461
|
+
export interface PrExcludedApprover {
|
|
462
|
+
login: string;
|
|
463
|
+
reason: string;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Why `login`'s approval does not count under `filter`'s `exclude`, or undefined. */
|
|
467
|
+
function excludedWhy(filter: PrApproverFilter | undefined, login: string): string | undefined {
|
|
468
|
+
for (const e of filter?.exclude ?? []) {
|
|
469
|
+
const who = typeof e === "string" ? e : e.login;
|
|
470
|
+
if (who.toLowerCase() === login.toLowerCase()) return typeof e === "string" ? `${login} is excluded` : e.reason;
|
|
471
|
+
}
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The approvers who count, and why each other one did not: an author, then
|
|
477
|
+
* `exclude`, then the forge's rule (`rule` gives a reason or undefined).
|
|
478
|
+
*/
|
|
479
|
+
async function countApprovers(
|
|
480
|
+
approved: readonly string[],
|
|
481
|
+
authors: ReadonlySet<string>,
|
|
482
|
+
filter: PrApproverFilter | undefined,
|
|
483
|
+
rule: (login: string) => Promise<string | undefined>,
|
|
484
|
+
): Promise<{ by: string[]; not: string[] }> {
|
|
485
|
+
const by: string[] = [];
|
|
486
|
+
const not: string[] = [];
|
|
487
|
+
for (const login of approved) {
|
|
488
|
+
const why = authors.has(login) ? `${login} is one of its authors` : (excludedWhy(filter, login) ?? (filter?.forgeRule ? await rule(login) : undefined));
|
|
489
|
+
if (why) not.push(why);
|
|
490
|
+
else by.push(login);
|
|
491
|
+
}
|
|
492
|
+
return { by, not };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** A refusal for no approver counted, naming each one left out. */
|
|
496
|
+
const noApprover = (line: string, not: readonly string[]): PrRequireResult => refuse(not.length > 0 ? `${line}: ${not.join("; ")}` : line);
|
|
497
|
+
|
|
498
|
+
/** GitLab's access level for Developer, the least that may push. */
|
|
499
|
+
const GITLAB_DEVELOPER = 30;
|
|
500
|
+
|
|
501
|
+
/** GitHub's permissions that may approve under {@link PrApproverFilter.forgeRule}. */
|
|
502
|
+
const GITHUB_WRITERS = new Set(["admin", "maintain", "write"]);
|
|
503
|
+
|
|
436
504
|
/** What one of the requires checks (#4301) found. */
|
|
437
505
|
export interface PrRequireResult {
|
|
438
506
|
passed: boolean;
|
|
@@ -796,25 +864,38 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
796
864
|
return [...standing].filter(([, state]) => state === "APPROVED").map(([login]) => login).sort();
|
|
797
865
|
},
|
|
798
866
|
authors: authorsOf,
|
|
799
|
-
async prApproved(pr, head) {
|
|
867
|
+
async prApproved(pr, head, approvers) {
|
|
800
868
|
const got = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
801
869
|
const at = headOf(got);
|
|
802
870
|
if (at !== head) return moved(`pull request ${pr}`, head, at);
|
|
803
871
|
// 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 }>();
|
|
872
|
+
const standing = new Map<string, { state: string; commit?: string; official?: boolean }>();
|
|
805
873
|
for (const r of await api.list(`${repo}/pulls/${pr}/reviews`, pageParams, pageSize)) {
|
|
806
874
|
const login = loginOf(r);
|
|
807
875
|
if (!login || !isObject(r) || typeof r.state !== "string") continue;
|
|
808
876
|
const state = r.dismissed === true ? "DISMISSED" : r.state.toUpperCase();
|
|
809
877
|
if (state === "COMMENTED" || state === "COMMENT" || state === "PENDING") continue;
|
|
810
878
|
const commit = str(r.commit_id);
|
|
811
|
-
standing.set(login, { state, ...(commit ? { commit } : {}) });
|
|
879
|
+
standing.set(login, { state, ...(commit ? { commit } : {}), ...(typeof r.official === "boolean" ? { official: r.official } : {}) });
|
|
812
880
|
}
|
|
813
881
|
const changes = [...standing].filter(([, r]) => r.state === "CHANGES_REQUESTED" || r.state === "REQUEST_CHANGES").map(([l]) => l).sort();
|
|
814
882
|
if (changes.length > 0) return refuse(`${changes.join(", ")} asked for changes`);
|
|
815
883
|
const authors = new Set(await authorsOf(pr));
|
|
816
|
-
const
|
|
817
|
-
|
|
884
|
+
const approved = [...standing].filter(([, r]) => r.state === "APPROVED" && r.commit === head).map(([l]) => l).sort();
|
|
885
|
+
const rule = async (login: string): Promise<string | undefined> => {
|
|
886
|
+
// Forgejo marks a review official when its author may write; terragucci read a missing flag as official too.
|
|
887
|
+
if (kind === "forgejo") return standing.get(login)?.official === false ? `${login}'s review is not official` : undefined;
|
|
888
|
+
let got: unknown;
|
|
889
|
+
try {
|
|
890
|
+
got = await api.call("GET", `${repo}/collaborators/${encodeURIComponent(login)}/permission`);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
return `could not read ${login}'s access (${err instanceof Error ? err.message : String(err)})`;
|
|
893
|
+
}
|
|
894
|
+
const p = isObject(got) ? [str(got.permission), str(got.role_name)] : [];
|
|
895
|
+
return p.some((x) => x !== undefined && GITHUB_WRITERS.has(x)) ? undefined : `${login} has no write access`;
|
|
896
|
+
};
|
|
897
|
+
const { by, not } = await countApprovers(approved, authors, approvers, rule);
|
|
898
|
+
if (by.length === 0) return noApprover(`no reviewer other than its authors approved its head ${short(head)}`, not);
|
|
818
899
|
return pass(`approved at ${short(head)} by ${by.join(", ")}`);
|
|
819
900
|
},
|
|
820
901
|
async prMergeable(pr, head) {
|
|
@@ -823,6 +904,9 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
823
904
|
if (at !== head) return moved(`pull request ${pr}`, head, at);
|
|
824
905
|
const m = isObject(got) ? got : {};
|
|
825
906
|
const into = isObject(m.base) ? str(m.base.ref) : undefined;
|
|
907
|
+
// Forgejo answers mergeable false while it still checks for conflicts, and names no state that tells the two apart, so its false is asked again.
|
|
908
|
+
if (kind === "forgejo" && m.draft === true) return refuse("it is a draft");
|
|
909
|
+
if (kind === "forgejo" && m.mergeable === false) return refuse(`Forgejo reports conflicts with ${into ?? "its base"}, or is still checking for them`, true);
|
|
826
910
|
if (m.mergeable === false || m.mergeable_state === "dirty") return refuse(`the forge reports conflicts with ${into ?? "its base"}`);
|
|
827
911
|
if (m.mergeable !== true) return refuse("the forge has not worked out yet whether it can merge", true);
|
|
828
912
|
if (kind === "github" && m.mergeable_state === "blocked") return refuse(`branch protection on ${into ?? "its base"} blocks its merge`);
|
|
@@ -1175,7 +1259,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
1175
1259
|
return [...new Set(logins)].sort();
|
|
1176
1260
|
},
|
|
1177
1261
|
authors: authorsOf,
|
|
1178
|
-
async prApproved(mr, head) {
|
|
1262
|
+
async prApproved(mr, head, approvers) {
|
|
1179
1263
|
const got = await api.call("GET", `${notes}/${mr}`);
|
|
1180
1264
|
const at = isObject(got) ? str(got.sha) : undefined;
|
|
1181
1265
|
if (at !== head) return moved(`merge request !${mr}`, head, at);
|
|
@@ -1186,7 +1270,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
1186
1270
|
// An approval counts once it came after the latest push: the newest version of the diff.
|
|
1187
1271
|
const versions = await api.call("GET", `${notes}/${mr}/versions`);
|
|
1188
1272
|
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 }>();
|
|
1273
|
+
const said = new Map<string, { what: string; at: number; id?: number }>();
|
|
1190
1274
|
for (const n of await api.list(`${notes}/${mr}/notes?sort=asc&order_by=created_at`, pageParams, 100)) {
|
|
1191
1275
|
if (!isObject(n) || n.system !== true) continue;
|
|
1192
1276
|
const who = authorOf(n);
|
|
@@ -1195,13 +1279,29 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
1195
1279
|
const when = Date.parse(str(n.created_at) ?? "");
|
|
1196
1280
|
if (!who || who === self || !what || !Number.isFinite(when)) continue;
|
|
1197
1281
|
const before = said.get(who);
|
|
1198
|
-
|
|
1282
|
+
const id = isObject(n.author) ? num(n.author.id) : undefined;
|
|
1283
|
+
if (!before || when >= before.at) said.set(who, { what, at: when, ...(id !== undefined ? { id } : {}) });
|
|
1199
1284
|
}
|
|
1200
1285
|
const changes = [...said].filter(([, v]) => v.what === "changes").map(([w]) => w).sort();
|
|
1201
1286
|
if (changes.length > 0) return refuse(`${changes.join(", ")} requested changes`);
|
|
1202
1287
|
const authors = new Set(await authorsOf(mr));
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1288
|
+
const approved = [...said].filter(([, v]) => v.what === "approved" && v.at > since).map(([w]) => w).sort();
|
|
1289
|
+
const least = approvers?.minAccessLevel ?? GITLAB_DEVELOPER;
|
|
1290
|
+
const rule = async (login: string): Promise<string | undefined> => {
|
|
1291
|
+
const id = said.get(login)?.id;
|
|
1292
|
+
if (id === undefined) return `GitLab did not say who ${login} is`;
|
|
1293
|
+
let member: unknown;
|
|
1294
|
+
try {
|
|
1295
|
+
member = await api.call("GET", `${project}/members/all/${id}`);
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
if (err instanceof ForgeApiError && err.status === 404) return `${login} is not a member`;
|
|
1298
|
+
return `could not read ${login}'s access (${err instanceof Error ? err.message : String(err)})`;
|
|
1299
|
+
}
|
|
1300
|
+
const level = isObject(member) ? (num(member.access_level) ?? 0) : 0;
|
|
1301
|
+
return level >= least ? undefined : `${login} has access level ${level}, under ${least}`;
|
|
1302
|
+
};
|
|
1303
|
+
const { by, not } = await countApprovers(approved, authors, approvers, rule);
|
|
1304
|
+
if (by.length === 0) return noApprover(`no member other than its authors approved it after its latest push, ${short(head)}`, not);
|
|
1205
1305
|
return pass(`approved after ${short(head)} by ${by.join(", ")}`);
|
|
1206
1306
|
},
|
|
1207
1307
|
async prMergeable(mr, head) {
|
package/src/pr-requires.test.ts
CHANGED
|
@@ -42,6 +42,25 @@ describe("github", () => {
|
|
|
42
42
|
expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes" });
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test("approved: forgeRule counts only writers, exclude leaves a login out, and the refusal says why (#4337)", async () => {
|
|
46
|
+
const reviews = [
|
|
47
|
+
{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD },
|
|
48
|
+
{ user: { login: "Carol" }, state: "APPROVED", commit_id: HEAD },
|
|
49
|
+
{ user: { login: "alice" }, state: "APPROVED", commit_id: HEAD },
|
|
50
|
+
];
|
|
51
|
+
const f = forge({
|
|
52
|
+
[`GET ${repo}/pulls/7/reviews`]: reviews,
|
|
53
|
+
[`GET ${repo}/collaborators/bob/permission`]: { permission: "read", role_name: "triage" },
|
|
54
|
+
[`GET ${repo}/collaborators/Carol/permission`]: { permission: "write", role_name: "maintain" },
|
|
55
|
+
});
|
|
56
|
+
expect(await f.prApproved!(7, HEAD, { forgeRule: true })).toEqual({ passed: true, reason: "approved at aaaaaaaa by Carol" });
|
|
57
|
+
expect(await f.prApproved!(7, HEAD, { forgeRule: true, exclude: [{ login: "carol", reason: "carol asked for aaaaaaa" }] })).toEqual({
|
|
58
|
+
passed: false,
|
|
59
|
+
reason: "no reviewer other than its authors approved its head aaaaaaaa: carol asked for aaaaaaa; alice is one of its authors; bob has no write access",
|
|
60
|
+
});
|
|
61
|
+
expect((await f.prApproved!(7, HEAD, { exclude: ["bob"] })).reason).toBe("approved at aaaaaaaa by Carol");
|
|
62
|
+
});
|
|
63
|
+
|
|
45
64
|
test("every check fails when the forge's head is another commit", async () => {
|
|
46
65
|
const f = forge({ [`GET ${repo}/pulls/7`]: pull({ head: { ref: "f", sha: OTHER } }) });
|
|
47
66
|
const r = await f.prMergeable!(7, HEAD);
|
|
@@ -89,11 +108,27 @@ describe("forgejo", () => {
|
|
|
89
108
|
expect((await f.prApproved!(4, HEAD)).reason).toBe("dave asked for changes");
|
|
90
109
|
});
|
|
91
110
|
|
|
111
|
+
test("approved: forgeRule counts an official review only, through checkPrRequires' approvers (#4337)", async () => {
|
|
112
|
+
const f = forge({ [`GET ${repo}/pulls/4/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD, official: false }, { user: { login: "erin" }, state: "APPROVED", commit_id: HEAD, official: true }] });
|
|
113
|
+
expect((await f.prApproved!(4, HEAD, { forgeRule: true })).reason).toBe("approved at aaaaaaaa by erin");
|
|
114
|
+
const out = await checkPrRequires(f, 4, HEAD, { only: ["approved"], approvers: { forgeRule: true, exclude: ["erin"] } });
|
|
115
|
+
expect(out[0]!.reason).toBe("no reviewer other than its authors approved its head aaaaaaaa: bob's review is not official; erin is excluded");
|
|
116
|
+
});
|
|
117
|
+
|
|
92
118
|
test("undiverged: the pull request's merge base is the base branch's tip", async () => {
|
|
93
119
|
expect((await forge({}).prUndiverged!(4, HEAD)).passed).toBe(true);
|
|
94
120
|
expect((await forge({ [`GET ${repo}/pulls/4`]: pull({ merge_base: OTHER }) }).prUndiverged!(4, HEAD)).reason).toBe("aaaaaaaa does not contain main at bbbbbbbb");
|
|
95
121
|
});
|
|
96
122
|
|
|
123
|
+
test("mergeable: a false while Forgejo still checks is pending, and passes once it settles (#4335)", async () => {
|
|
124
|
+
expect(await forge({ [`GET ${repo}/pulls/4`]: pull({ mergeable: false }) }).prMergeable!(4, HEAD)).toMatchObject({ passed: false, pending: true });
|
|
125
|
+
expect(await forge({ [`GET ${repo}/pulls/4`]: pull({ mergeable: false, draft: true }) }).prMergeable!(4, HEAD)).toEqual({ passed: false, reason: "it is a draft" });
|
|
126
|
+
let reads = 0;
|
|
127
|
+
const flips = forge({ [`GET ${repo}/pulls/4`]: () => pull({ mergeable: ++reads > 2 }), [`GET ${repo}/pulls/4/reviews`]: [], [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [{ context: "ci/test", status: "success" }] } });
|
|
128
|
+
const out = await checkPrRequires(flips, 4, HEAD, { wait: async () => {} });
|
|
129
|
+
expect(out.find((o) => o.name === "mergeable")).toEqual({ name: "mergeable", passed: true, reason: "it merges into main as it stands" });
|
|
130
|
+
});
|
|
131
|
+
|
|
97
132
|
test("mergeable: no branch protection reading, and checks from the combined status alone", async () => {
|
|
98
133
|
expect((await forge({ [`GET ${repo}/pulls/4`]: pull({ mergeable_state: "blocked" }) }).prMergeable!(4, HEAD)).passed).toBe(true);
|
|
99
134
|
const f = forge({ [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [{ context: "ci/test", status: "success" }] } });
|
|
@@ -135,6 +170,23 @@ describe("gitlab", () => {
|
|
|
135
170
|
expect((await h.prApproved!(3, HEAD)).reason).toBe("carol requested changes");
|
|
136
171
|
});
|
|
137
172
|
|
|
173
|
+
test("approved: forgeRule reads each approver's access level, Developer by default (#4337)", async () => {
|
|
174
|
+
const at = "2026-10-01T11:00:00Z";
|
|
175
|
+
const f = forge({
|
|
176
|
+
[`GET ${project}/merge_requests/3/notes`]: [
|
|
177
|
+
{ ...note("bob", "approved this merge request", at), author: { username: "bob", id: 5 } },
|
|
178
|
+
{ ...note("dan", "approved this merge request", at), author: { username: "dan", id: 6 } },
|
|
179
|
+
{ ...note("eve", "approved this merge request", at), author: { username: "eve", id: 7 } },
|
|
180
|
+
],
|
|
181
|
+
[`GET ${project}/members/all/5`]: { access_level: 20 },
|
|
182
|
+
[`GET ${project}/members/all/6`]: { access_level: 40 },
|
|
183
|
+
});
|
|
184
|
+
expect((await f.prApproved!(3, HEAD, { forgeRule: true })).reason).toBe("approved after aaaaaaaa by dan");
|
|
185
|
+
expect((await f.prApproved!(3, HEAD, { forgeRule: true, minAccessLevel: 50 })).reason).toBe(
|
|
186
|
+
"no member other than its authors approved it after its latest push, aaaaaaaa: bob has access level 20, under 50; dan has access level 40, under 50; eve is not a member",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
138
190
|
test("mergeable and undiverged read the merge request", async () => {
|
|
139
191
|
expect((await forge({}).prMergeable!(3, HEAD)).passed).toBe(true);
|
|
140
192
|
expect(await forge({ [`GET ${project}/merge_requests/3`]: mr({ detailed_merge_status: "checking" }) }).prMergeable!(3, HEAD)).toMatchObject({ passed: false, pending: true });
|
|
@@ -175,6 +227,18 @@ describe("checkPrRequires", () => {
|
|
|
175
227
|
expect(unmetRequires(out)).toBeNull();
|
|
176
228
|
});
|
|
177
229
|
|
|
230
|
+
test("only: a check left out passes as not required and asks the forge nothing (#4336)", async () => {
|
|
231
|
+
const calls: string[] = [];
|
|
232
|
+
const ask = (name: string) => async () => (calls.push(name), { passed: false, reason: `${name} unmet` });
|
|
233
|
+
const forge = { kind: "github", prApproved: ask("approved"), prMergeable: ask("mergeable"), prUndiverged: ask("undiverged"), prChecksPassed: ask("checks") } as unknown as PrForge;
|
|
234
|
+
const out = await checkPrRequires(forge, 7, HEAD, { only: ["approved", "checks"], reads: 1 });
|
|
235
|
+
expect(calls).toEqual(["approved", "checks"]);
|
|
236
|
+
expect(out.map((o) => o.name)).toEqual([...PR_APPLY_REQUIRES]);
|
|
237
|
+
expect(out[1]).toEqual({ name: "mergeable", passed: true, reason: "not required" });
|
|
238
|
+
expect(unmetRequires(out)).toBe("approved: approved unmet; checks: checks unmet");
|
|
239
|
+
expect(unmetRequires(await checkPrRequires(forge, 7, HEAD, { only: [] }))).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
178
242
|
test("a client without a method, or a call that throws, is unmet", async () => {
|
|
179
243
|
const forge = { kind: "gitlab", prApproved: async () => { throw new Error("401"); } } as unknown as PrForge;
|
|
180
244
|
const out = await checkPrRequires(forge, 3, HEAD, { reads: 1 });
|