@intentius/chant 0.124.42 → 0.124.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.124.42",
3
+ "version": "0.124.44",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -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,
@@ -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
+ });
@@ -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
- * 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.
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
- export function runHookList(
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, exec, log } = at;
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
- 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
- }
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 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})`)] };
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 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).
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 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)}`);
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
- if (!before || when >= before.at) said.set(who, { what, at: when });
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 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)}`);
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) {
@@ -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 });