@intentius/chant 0.124.43 → 0.124.45

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.
Files changed (72) hide show
  1. package/dist/ci-identity.d.ts +183 -12
  2. package/dist/ci-identity.d.ts.map +1 -1
  3. package/dist/ci-identity.js +362 -53
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +17 -1
  6. package/dist/cli/handlers/pr-env.d.ts +8 -2
  7. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  8. package/dist/cli/handlers/pr-env.js +126 -225
  9. package/dist/cli/handlers/pr.d.ts +3 -0
  10. package/dist/cli/handlers/pr.d.ts.map +1 -1
  11. package/dist/cli/handlers/pr.js +1 -0
  12. package/dist/components/cli-support.d.ts +2 -0
  13. package/dist/components/cli-support.d.ts.map +1 -1
  14. package/dist/components/cli-support.js +4 -2
  15. package/dist/components/fan-out-support.d.ts +3 -0
  16. package/dist/components/fan-out-support.d.ts.map +1 -1
  17. package/dist/components/fan-out-support.js +1 -1
  18. package/dist/components/pr-env-run.d.ts +270 -0
  19. package/dist/components/pr-env-run.d.ts.map +1 -0
  20. package/dist/components/pr-env-run.js +440 -0
  21. package/dist/components/pr-environment.d.ts +89 -14
  22. package/dist/components/pr-environment.d.ts.map +1 -1
  23. package/dist/components/pr-environment.js +94 -8
  24. package/dist/components/pr-pipeline.d.ts +10 -1
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/op/op-waves.d.ts +17 -0
  27. package/dist/op/op-waves.d.ts.map +1 -1
  28. package/dist/op/op-waves.js +23 -1
  29. package/dist/pr-forge.d.ts +15 -1
  30. package/dist/pr-forge.d.ts.map +1 -1
  31. package/dist/pr-forge.js +28 -16
  32. package/dist/pr-requires.d.ts +5 -1
  33. package/dist/pr-requires.d.ts.map +1 -1
  34. package/dist/wave-runtime.mjs +179 -179
  35. package/dist/workspace/ci-cloud-identity.d.ts +3 -1
  36. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
  37. package/dist/workspace/ci-cloud-identity.js +16 -1
  38. package/dist/workspace/ci-run.d.ts +35 -8
  39. package/dist/workspace/ci-run.d.ts.map +1 -1
  40. package/dist/workspace/ci-run.js +95 -21
  41. package/dist/workspace/ci-secrets.d.ts.map +1 -1
  42. package/dist/workspace/ci-secrets.js +4 -3
  43. package/dist/workspace/declaration.d.ts +10 -0
  44. package/dist/workspace/declaration.d.ts.map +1 -1
  45. package/dist/workspace/declaration.js +17 -1
  46. package/dist/workspace/declaration.schema.json +36 -1
  47. package/package.json +1 -1
  48. package/src/ci-identity-targets.test.ts +275 -0
  49. package/src/ci-identity.ts +394 -61
  50. package/src/cli/handlers/build.ts +15 -1
  51. package/src/cli/handlers/pr-env.ts +136 -227
  52. package/src/cli/handlers/pr.ts +4 -0
  53. package/src/components/cli-support.ts +6 -1
  54. package/src/components/fan-out-support.ts +4 -1
  55. package/src/components/pr-env-run.test.ts +290 -0
  56. package/src/components/pr-env-run.ts +634 -0
  57. package/src/components/pr-environment.ts +159 -12
  58. package/src/components/pr-pipeline.ts +10 -1
  59. package/src/op/op-waves.ts +38 -1
  60. package/src/pr-forge-bundle.test.ts +1 -1
  61. package/src/pr-forge.ts +44 -17
  62. package/src/pr-requires.test.ts +43 -6
  63. package/src/pr-requires.ts +5 -1
  64. package/src/workspace/ci-cloud-identity.test.ts +20 -0
  65. package/src/workspace/ci-cloud-identity.ts +14 -1
  66. package/src/workspace/ci-run.test.ts +74 -1
  67. package/src/workspace/ci-run.ts +122 -29
  68. package/src/workspace/ci-secrets.test.ts +1 -1
  69. package/src/workspace/ci-secrets.ts +4 -3
  70. package/src/workspace/declaration.schema.json +36 -1
  71. package/src/workspace/declaration.test.ts +7 -0
  72. package/src/workspace/declaration.ts +24 -2
@@ -32,14 +32,19 @@
32
32
  *
33
33
  * terragucci's `ephemeral.ts` gives each copy a suffixed Terraform state key.
34
34
  * That part is Terraform's own and stays there; chant keys ownership on the
35
- * marker its lexicons stamp and read back (`../lifecycle/teardown.ts`).
35
+ * marker its lexicons stamp and read back (`../lifecycle/teardown.ts`). A
36
+ * product supplies the rest as adapters (#4330): its plan and apply
37
+ * (`./pr-env-run.ts`), and a teardown for what no lexicon tears down
38
+ * ({@link PrEnvTeardownAdapter}), which reads the marker back from the copy
39
+ * as a lexicon does.
36
40
  */
37
41
 
38
42
  import { environmentEndpoint, environmentName, isEnvironmentPattern, isOwnershipParamRef, matchesDeclaredEnvironment, resolveOwnershipStack, type ChantConfig, type EnvironmentDeclaration } from "../config";
39
43
  import type { OwnershipMarker } from "../ownership";
40
44
  import { executeTeardown, planTeardown, type TeardownPlan, type TeardownPlanEntry, type TeardownReport } from "../lifecycle/teardown";
41
- import type { ObservationLexicon } from "../lexicon";
45
+ import type { ObservationLexicon, TeardownCandidate, TeardownEnumeration, TeardownExecution } from "../lexicon";
42
46
  import { applyLiveEndpoint } from "../live-endpoint";
47
+ import type { ComponentEnvironmentDeclaration } from "./component";
43
48
 
44
49
  /** The gate a pull request's environment waits on, under the pull request's op `pr-<n>`. */
45
50
  export const PR_ENV_GATE = "pr-env";
@@ -167,6 +172,35 @@ export function derivePrEnvironment(config: ChantConfig, template: string, pr: n
167
172
  return { env, template, stack, param: own.param, environments, ...(endpoint ? { endpoint } : {}) };
168
173
  }
169
174
 
175
+ /** The placeholder a template's site URL or domain names the environment with (#4330). */
176
+ export const PR_ENV_SITE_PLACEHOLDER = "{env}";
177
+
178
+ /**
179
+ * A component's site in `env`, a pull request's environment, carried over
180
+ * from its site in `template` (#4330). An entry the component declares for
181
+ * `env` itself wins. Otherwise the template's runtime and lifecycle remote
182
+ * carry over as they are. Its URL and domain carry over only when they name
183
+ * the environment with `{env}`, which becomes `pr-<n>`: a literal URL or
184
+ * domain is the template's own release, so it is left out rather than
185
+ * pointing pr-<n> at it. Undefined when neither is declared.
186
+ */
187
+ export function prEnvironmentSite(declared: readonly ComponentEnvironmentDeclaration[] | undefined, template: string, env: string): ComponentEnvironmentDeclaration | undefined {
188
+ const own = declared?.find((d) => d.name === env);
189
+ if (own) return { ...own };
190
+ const from = declared?.find((d) => d.name === template);
191
+ if (!from) return undefined;
192
+ const named = (v: string | undefined): string | undefined => (v && v.includes(PR_ENV_SITE_PLACEHOLDER) ? v.split(PR_ENV_SITE_PLACEHOLDER).join(env) : undefined);
193
+ const url = named(from.url);
194
+ const domain = named(from.domain);
195
+ return {
196
+ name: env,
197
+ ...(from.runtime ? { runtime: from.runtime } : {}),
198
+ ...(url ? { url } : {}),
199
+ ...(domain ? { domain } : {}),
200
+ ...(from.lifecycle ? { lifecycle: from.lifecycle } : {}),
201
+ };
202
+ }
203
+
170
204
  // ── records ────────────────────────────────────────────────────────────────
171
205
 
172
206
  /** One line of `_pr-envs/records.jsonl`: an apply, or a teardown. */
@@ -186,6 +220,12 @@ export interface PrEnvRecord {
186
220
  commit: string;
187
221
  /** The components applied (up) or the lexicons torn down (down). */
188
222
  components: string[];
223
+ /**
224
+ * Up (#4330): where each component's copy lives, by component, as the
225
+ * adapter that applied it says (a Terraform root's suffixed state key).
226
+ * A teardown adapter reads it back from the live environment.
227
+ */
228
+ locations?: Record<string, string>;
189
229
  /** The change-set digest applied; empty for a teardown. */
190
230
  planDigest: string;
191
231
  /** `incomplete`: a teardown that left something, so the environment stays live and the sweep tries again. */
@@ -229,6 +269,8 @@ export interface LivePrEnvironment {
229
269
  commit: string;
230
270
  /** Every component an apply of it ever touched. */
231
271
  components: string[];
272
+ /** Where each component's copy lives, merged over every up (#4330). */
273
+ locations?: Record<string, string>;
232
274
  applied: string;
233
275
  expiresAt: string;
234
276
  /** Its last teardown left something, so the sweep tries again. */
@@ -245,6 +287,7 @@ export function livePrEnvironments(records: readonly PrEnvRecord[]): Map<string,
245
287
  for (const r of records) {
246
288
  if (r.kind === "up") {
247
289
  const was = live.get(r.env);
290
+ const locations = { ...(was?.locations ?? {}), ...(r.locations ?? {}) };
248
291
  live.set(r.env, {
249
292
  pr: r.pr,
250
293
  env: r.env,
@@ -253,6 +296,7 @@ export function livePrEnvironments(records: readonly PrEnvRecord[]): Map<string,
253
296
  marker: { ...r.marker },
254
297
  commit: r.commit,
255
298
  components: [...new Set([...(was?.components ?? []), ...r.components])],
299
+ ...(Object.keys(locations).length > 0 ? { locations } : {}),
256
300
  applied: r.timestamp,
257
301
  expiresAt: r.expiresAt ?? r.timestamp,
258
302
  });
@@ -302,12 +346,61 @@ export function ownedEntries(plan: TeardownPlan, marker: OwnershipMarker): { pla
302
346
  return { plan: { ...plan, entries: plan.entries.filter(owns) }, dropped: plan.entries.filter((e) => !owns(e)) };
303
347
  }
304
348
 
349
+ /**
350
+ * A teardown a product supplies for what no lexicon tears down (#4330), such
351
+ * as a Terraform root's copy under a suffixed state key: chant has no
352
+ * Terraform runner, so terragucci supplies this. It takes part in the same
353
+ * two halves a lexicon's teardown does (`../lifecycle/teardown.ts`): `owned`
354
+ * names what the environment owns, each with the marker read back from it,
355
+ * and `teardown` deletes the candidates chant hands back. Every candidate
356
+ * whose marker is not exactly `{ stack, env: "pr-<n>" }` is dropped before
357
+ * `teardown` sees it, and a candidate it says nothing about counts as failed.
358
+ */
359
+ export interface PrEnvTeardownAdapter {
360
+ /** The name outcomes are attributed to, as a lexicon's are. */
361
+ name: string;
362
+ /**
363
+ * The lexicons this adapter tears down in place of (`terraform`). Their own
364
+ * teardown is left out, so a lexicon with no teardown execution does not
365
+ * leave the environment incomplete when the adapter covers it.
366
+ */
367
+ lexicons?: string[];
368
+ /** What the environment owns: candidates with their read-back markers, and what could not be read. */
369
+ owned(input: PrEnvTeardownAdapterInput): Promise<TeardownEnumeration>;
370
+ /** Delete the candidates: one outcome per candidate, keyed by name. */
371
+ teardown(input: PrEnvTeardownAdapterInput & { candidates: TeardownCandidate[] }): Promise<TeardownExecution>;
372
+ }
373
+
374
+ /** What a {@link PrEnvTeardownAdapter} is told about the environment. */
375
+ export interface PrEnvTeardownAdapterInput {
376
+ /** `pr-<n>`. */
377
+ environment: string;
378
+ /** The marker everything is selected on. */
379
+ marker: OwnershipMarker;
380
+ /** The environment as its records say it stands: the commit last applied, its components and their locations. */
381
+ live: LivePrEnvironment;
382
+ }
383
+
384
+ /** An adapter in the shape `planTeardown` and `executeTeardown` call: a lexicon with only its two teardown halves. */
385
+ function adapterLexicon(adapter: PrEnvTeardownAdapter, live: LivePrEnvironment): ObservationLexicon {
386
+ const shaped: Pick<ObservationLexicon, "name" | "teardownOwned" | "executeTeardown"> = {
387
+ name: adapter.name,
388
+ teardownOwned: (o) => adapter.owned({ environment: o.environment, marker: o.marker, live }),
389
+ executeTeardown: (o) => adapter.teardown({ environment: o.environment, marker: o.marker, live, candidates: o.candidates }),
390
+ };
391
+ return shaped as ObservationLexicon;
392
+ }
393
+
305
394
  /** What {@link tearDownPrEnvironment} needs. */
306
395
  export interface PrEnvTeardownInput {
307
396
  pr: number;
308
397
  live: LivePrEnvironment;
309
- config: ChantConfig;
310
- plugins: ObservationLexicon[];
398
+ /** The project's config, for its environments and stacks. Absent: a product with no chant.config.ts, whose adapters do the teardown. */
399
+ config?: ChantConfig;
400
+ /** The lexicons whose teardown runs. Default none. */
401
+ plugins?: ObservationLexicon[];
402
+ /** Teardowns a product supplies (#4330), run after the lexicons'. */
403
+ adapters?: PrEnvTeardownAdapter[];
311
404
  warn?: (message: string) => void;
312
405
  }
313
406
 
@@ -316,18 +409,30 @@ export interface PrEnvTeardownInput {
316
409
  * before any read when {@link teardownRefusal} says so; otherwise the live
317
410
  * resources under the record's marker are planned, every entry whose marker
318
411
  * is not exactly `{ stack, env: "pr-<n>" }` is dropped ({@link ownedEntries}),
319
- * and only the rest go to each lexicon's teardown. The endpoint is the one
320
- * the environment was applied at: its own `pr-*` entry's, else the template's.
412
+ * and only the rest go to each lexicon's or adapter's teardown. The endpoint
413
+ * is the one the environment was applied at: its own `pr-*` entry's, else the
414
+ * template's. A lexicon an adapter covers (`lexicons`) is left out.
321
415
  */
322
416
  export async function tearDownPrEnvironment(input: PrEnvTeardownInput): Promise<{ refusal: string } | { report: TeardownReport; dropped: number; marker: { stack: string; env: string } }> {
323
- const { pr, live, config, plugins } = input;
417
+ const { pr, live } = input;
418
+ const config = input.config ?? {};
324
419
  const refusal = teardownRefusal(pr, live, config.environments);
325
420
  if (refusal) return { refusal };
421
+ const adapters = input.adapters ?? [];
422
+ const covered = new Set(adapters.flatMap((a) => a.lexicons ?? []));
423
+ const names = new Set<string>();
424
+ for (const a of adapters) {
425
+ if (names.has(a.name)) return { refusal: `two teardown adapters are named ${a.name}, so their outcomes could not be told apart; nothing was torn down` };
426
+ names.add(a.name);
427
+ }
428
+ const lexicons = (input.plugins ?? []).filter((x) => !covered.has(x.name));
429
+ if (lexicons.some((x) => names.has(x.name))) return { refusal: `a teardown adapter shares its name with a lexicon (${[...names].filter((n) => lexicons.some((x) => x.name === n)).join(", ")}), so their outcomes could not be told apart; nothing was torn down` };
326
430
  const env = prEnvironmentName(pr);
327
431
  const marker = { stack: live.marker.stack, env };
328
432
  const endpoint = environmentEndpoint(config.environments, env) ?? environmentEndpoint(config.environments, live.template);
329
- const applied = applyLiveEndpoint(endpoint ? [{ name: env, endpoint }] : [], env, plugins.filter((x) => x.teardownOwned || x.describeResources || x.executeTeardown));
433
+ const applied = applyLiveEndpoint(endpoint ? [{ name: env, endpoint }] : [], env, lexicons.filter((x) => x.teardownOwned || x.describeResources || x.executeTeardown));
330
434
  if (applied.notice) input.warn?.(applied.notice);
435
+ const plugins = [...lexicons, ...adapters.map((a) => adapterLexicon(a, live))];
331
436
  const deployedStacks = (config.stacks ?? []).map((s) => ({ name: s.name, ...(s.region ? { region: s.region } : {}) }));
332
437
  try {
333
438
  const planned = await planTeardown({ environment: env, stack: marker.stack, plugins, ...(deployedStacks.length > 0 ? { deployedStacks } : {}) });
@@ -405,6 +510,7 @@ export function acceptHandedRecord(raw: unknown, check: HandedRecordCheck): { re
405
510
  marker: { stack: r.marker.stack, env },
406
511
  commit: check.head,
407
512
  components: [...r.components],
513
+ ...(handedLocations(r.locations) ? { locations: handedLocations(r.locations)! } : {}),
408
514
  planDigest: typeof r.planDigest === "string" ? r.planDigest : "",
409
515
  result: r.result,
410
516
  by: check.by,
@@ -417,6 +523,21 @@ export function acceptHandedRecord(raw: unknown, check: HandedRecordCheck): { re
417
523
  };
418
524
  }
419
525
 
526
+ /** A handed-on report row's site, kept only as its three strings. */
527
+ function handedSite(raw: unknown): PrEnvReportRow["site"] | undefined {
528
+ if (typeof raw !== "object" || raw === null) return undefined;
529
+ const r = raw as Record<string, unknown>;
530
+ const site = Object.fromEntries((["runtime", "url", "domain"] as const).filter((k) => typeof r[k] === "string").map((k) => [k, r[k] as string]));
531
+ return Object.keys(site).length > 0 ? site : undefined;
532
+ }
533
+
534
+ /** A handed-on record's locations, kept only as a map of strings to strings. */
535
+ function handedLocations(raw: unknown): Record<string, string> | undefined {
536
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
537
+ const out = Object.fromEntries(Object.entries(raw as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === "string"));
538
+ return Object.keys(out).length > 0 ? out : undefined;
539
+ }
540
+
420
541
  /**
421
542
  * The report `pr-env up` handed on, checked as data before the post job
422
543
  * renders a note from it: an `up` report for this pull request's `pr-<n>`.
@@ -445,7 +566,16 @@ export function handedPrEnvReport(raw: unknown, check: { pr: number; head: strin
445
566
  status: r.status,
446
567
  ...(typeof r.message === "string" ? { message: r.message } : {}),
447
568
  ...(typeof r.endpoint === "string" ? { endpoint: r.endpoint } : {}),
448
- components: (Array.isArray(r.components) ? r.components : []).map((c) => ({ component: str(c?.component), status: str(c?.status), create: num(c?.create), update: num(c?.update), replace: num(c?.replace), delete: num(c?.delete) })),
569
+ components: (Array.isArray(r.components) ? r.components : []).map((c) => ({
570
+ component: str(c?.component),
571
+ status: str(c?.status),
572
+ create: num(c?.create),
573
+ update: num(c?.update),
574
+ replace: num(c?.replace),
575
+ delete: num(c?.delete),
576
+ ...(typeof c?.location === "string" ? { location: c.location } : {}),
577
+ ...(handedSite(c?.site) ? { site: handedSite(c?.site)! } : {}),
578
+ })),
449
579
  ...(Array.isArray(r.approvedBy) ? { approvedBy: r.approvedBy.filter((a): a is string => typeof a === "string") } : {}),
450
580
  },
451
581
  };
@@ -453,6 +583,20 @@ export function handedPrEnvReport(raw: unknown, check: { pr: number; head: strin
453
583
 
454
584
  // ── the note ───────────────────────────────────────────────────────────────
455
585
 
586
+ /** One component's row in the report and the note. */
587
+ export interface PrEnvReportRow {
588
+ component: string;
589
+ status: string;
590
+ create: number;
591
+ update: number;
592
+ replace: number;
593
+ delete: number;
594
+ /** Where its copy lives, as the adapter says (#4330). */
595
+ location?: string;
596
+ /** Its site in pr-<n>: the runtime, URL and domain carried over from the template's (#4330, {@link prEnvironmentSite}). */
597
+ site?: { runtime?: string; url?: string; domain?: string };
598
+ }
599
+
456
600
  /** What one `pr-env up` or `down` run came to, as `pr-env.json` holds it. */
457
601
  export interface PrEnvReport {
458
602
  stage: "up" | "down";
@@ -467,7 +611,7 @@ export interface PrEnvReport {
467
611
  status: "applied" | "nothing" | "waiting" | "changed" | "plan-failed" | "failed" | "refused" | "destroyed" | "incomplete" | "absent";
468
612
  message?: string;
469
613
  endpoint?: string;
470
- components: Array<{ component: string; status: string; create: number; update: number; replace: number; delete: number }>;
614
+ components: PrEnvReportRow[];
471
615
  approvedBy?: string[];
472
616
  expiresAt?: string;
473
617
  outcomes?: PrEnvRecord["outcomes"];
@@ -505,8 +649,11 @@ export function renderPrEnvNote(r: PrEnvReport, approver = "<you>"): string {
505
649
  }
506
650
  if (r.message) out.push("", `> ${r.message.split("\n").join("\n> ")}`);
507
651
  if (r.components.length > 0) {
508
- out.push("", "| Component | Status | Create | Update | Replace | Delete |", "|---|---|---:|---:|---:|---:|");
509
- for (const c of r.components) out.push(`| ${code(c.component)} | ${c.status} | ${c.create} | ${c.update} | ${c.replace} | ${c.delete} |`);
652
+ // #4330: a Where column when a row has a URL or a location, as code: env-up ran the change's code, so the post job does not link it.
653
+ const at = (c: PrEnvReportRow): string | undefined => c.site?.url ?? c.site?.domain ?? c.location;
654
+ const where = r.components.some((c) => at(c));
655
+ out.push("", `| Component | Status | Create | Update | Replace | Delete |${where ? " Where |" : ""}`, `|---|---|---:|---:|---:|---:|${where ? "---|" : ""}`);
656
+ for (const c of r.components) out.push(`| ${code(c.component)} | ${c.status} | ${c.create} | ${c.update} | ${c.replace} | ${c.delete} |${where ? ` ${at(c) ? code(at(c)!) : ""} |` : ""}`);
510
657
  }
511
658
  if (r.digest) out.push("", `Plan digest: ${code(r.digest)}${r.approvedBy?.length ? `, approved by ${r.approvedBy.map(code).join(", ")}` : ""}`);
512
659
  out.push("", `${code(r.env)} is not a release target: releases come from green commits.`);
@@ -89,7 +89,7 @@ import { EMPTY_TREE_SHA } from "./changed-files";
89
89
  import { layerWaves } from "../gated-waves";
90
90
  import type { DriverComponent } from "./driver";
91
91
  import type { GatedWavePipelineOptions } from "./gated-wave-pipeline";
92
- import type { PipelineCloudIdentity } from "../ci-identity";
92
+ import type { CloudTradeSpec, PipelineCloudIdentity } from "../ci-identity";
93
93
 
94
94
  /** The variable `chant ci run` reads its runner key from (`RUNNER_KEY_ENV` in ../workspace/ci-green.ts). */
95
95
  const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
@@ -211,6 +211,15 @@ export interface PrLoopPipelineOptions {
211
211
  * ({@link prEnvJobs}). Set from `--pr-env` beside `--pr-loop`. Default false.
212
212
  */
213
213
  prEnvironment?: boolean;
214
+ /**
215
+ * The cloud identity a pull request environment's jobs take (#4330), from
216
+ * `ci.identity.cloud` (`resolvePrEnvCloudIdentity` in ../ci-identity.ts):
217
+ * the apply identity of a rule that names the pull request environments.
218
+ * env-up, env-down and env-sweep trade their OIDC token for it before the
219
+ * checkout, and env-up's later steps start without the token variables.
220
+ * Absent: they request no OIDC token.
221
+ */
222
+ prEnvIdentity?: CloudTradeSpec;
214
223
  }
215
224
 
216
225
  /** One job's command and the forge values it reads, as environment variables for its step. */
@@ -76,6 +76,7 @@
76
76
 
77
77
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
78
78
  import type { CiRunner } from "../ci-jobs";
79
+ import type { CloudTradeSpec } from "../ci-identity";
79
80
  import { gatePointProblems, type GatePoint } from "./gate-point";
80
81
  import { quorumProblems, type GateQuorumRule } from "./gate-quorum";
81
82
  import { notifyConfigProblem, type NotifyConfig } from "../notify";
@@ -261,6 +262,13 @@ export interface OpWaveJobGroup {
261
262
  * is requested.
262
263
  */
263
264
  idTokens?: Record<string, string>;
265
+ /**
266
+ * The cloud identities the group's jobs trade the job's OIDC token for
267
+ * (#4332), in place of the wave's: a {@link CloudTradeSpec}, as
268
+ * `cloudIdentityWave` in ../ci-identity.ts gives a group its apply
269
+ * identities. The trade runs before the checkout.
270
+ */
271
+ cloud?: CloudTradeSpec;
264
272
  }
265
273
 
266
274
  /** One wave: runs that apply together, behind one gate. */
@@ -321,6 +329,15 @@ export interface OpWave {
321
329
  * job's, and a group's own replace them.
322
330
  */
323
331
  idTokens?: Record<string, string>;
332
+ /**
333
+ * The cloud identities this wave's jobs trade the job's OIDC token for
334
+ * (#4332), before the checkout: a {@link CloudTradeSpec}. With `jobs`,
335
+ * the deciding job's, and a group's own `cloud` replaces it.
336
+ * `cloudIdentityWave` in ../ci-identity.ts gives the deciding job every
337
+ * plan identity of the wave's targets. Each later step of the job starts
338
+ * without the variables a token is requested with.
339
+ */
340
+ cloud?: CloudTradeSpec;
324
341
  /**
325
342
  * Split the wave's apply into job groups (#3914): a deciding job, which
326
343
  * runs with the wave's own settings, plans every run and decides the gate,
@@ -644,6 +661,7 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
644
661
  }
645
662
  if (wave.runner !== undefined) assertOpWaveRunner(where, `wave "${wave.name}" runner`, wave.runner);
646
663
  if (wave.idTokens !== undefined) assertOpWaveIdTokens(where, `wave "${wave.name}" idTokens`, wave.idTokens);
664
+ if (wave.cloud !== undefined) assertOpWaveCloud(where, `wave "${wave.name}" cloud`, wave.cloud);
647
665
  if (wave.jobs !== undefined) assertOpWaveJobGroups(where, wave);
648
666
  if (wave.discover !== undefined && typeof wave.discover !== "boolean") throw new Error(`${where}: wave "${wave.name}" has discover ${JSON.stringify(wave.discover)}; use true or false.`);
649
667
  if (wave.rest !== undefined && typeof wave.rest !== "boolean") throw new Error(`${where}: wave "${wave.name}" has rest ${JSON.stringify(wave.rest)}; use true or false.`);
@@ -684,6 +702,24 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
684
702
 
685
703
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
686
704
 
705
+ /** The key each cloud of a `cloud` trade names its identity under (`IDENTITY_KEY` in ../ci-identity.ts, kept here so this module imports none of it). */
706
+ const CLOUD_IDENTITY_KEY: Record<string, string> = { aws: "role", gcp: "serviceAccount", azure: "clientId" };
707
+
708
+ /** Refuse a `cloud` (#4332) the trade could not run: an unknown cloud, one that names no identity, or no audience. */
709
+ function assertOpWaveCloud(where: string, field: string, cloud: unknown): void {
710
+ if (cloud === null || typeof cloud !== "object" || Array.isArray(cloud) || Object.keys(cloud).length === 0) throw new Error(`${where}: ${field} must name one or more of aws, gcp and azure.`);
711
+ for (const [name, c] of Object.entries(cloud as Record<string, unknown>)) {
712
+ const key = CLOUD_IDENTITY_KEY[name];
713
+ if (!key) throw new Error(`${where}: ${field}.${name} is not a cloud; use aws, gcp or azure.`);
714
+ const t = c as Record<string, unknown> | null;
715
+ const targets = t?.targets;
716
+ const targetsOk = targets === undefined || (Array.isArray(targets) && targets.every((e) => e && Array.isArray((e as { targets?: unknown }).targets) && typeof (e as Record<string, unknown>)[key] === "string"));
717
+ if (!t || typeof t.audience !== "string" || !targetsOk || (typeof t[key] !== "string" && !(Array.isArray(targets) && targets.length > 0))) {
718
+ throw new Error(`${where}: ${field}.${name} must give an audience and a ${key}, or targets each with targets and a ${key}, as cloudIdentityWave gives them.`);
719
+ }
720
+ }
721
+ }
722
+
687
723
  /** Refuse a `runner` no forge could read (#3914). */
688
724
  function assertOpWaveRunner(where: string, field: string, runner: unknown): void {
689
725
  const labels = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every((l) => typeof l === "string" && l.trim() !== "");
@@ -723,7 +759,7 @@ function assertOpWaveJobGroups(where: string, wave: OpWave): void {
723
759
  if (group.name === "decide" || /^share-\d+$/.test(group.name)) throw new Error(`${where}: ${field} is named like the wave's own jobs (decide, share-<i>); name it another way.`);
724
760
  if (names.has(group.name)) throw new Error(`${where}: ${at} names job group "${group.name}" twice.`);
725
761
  names.add(group.name);
726
- const known = new Set(["name", "runs", "shares", "runner", "environment", "variables", "setup", "idTokens"]);
762
+ const known = new Set(["name", "runs", "shares", "runner", "environment", "variables", "setup", "idTokens", "cloud"]);
727
763
  const extra = Object.keys(group).find((key) => !known.has(key));
728
764
  if (extra !== undefined) throw new Error(`${where}: ${field}.${extra} is not a setting; a job group takes ${[...known].join(", ")}.`);
729
765
  if (!Array.isArray(group.runs) || group.runs.length === 0 || !group.runs.every((r) => typeof r === "string" && r.trim() !== "")) {
@@ -733,6 +769,7 @@ function assertOpWaveJobGroups(where: string, wave: OpWave): void {
733
769
  if (!Number.isInteger(shares) || shares < 1) throw new Error(`${where}: ${field} has shares ${group.shares}; use a whole number from 1.`);
734
770
  if (group.runner !== undefined) assertOpWaveRunner(where, `${field} runner`, group.runner);
735
771
  if (group.idTokens !== undefined) assertOpWaveIdTokens(where, `${field} idTokens`, group.idTokens);
772
+ if (group.cloud !== undefined) assertOpWaveCloud(where, `${field} cloud`, group.cloud);
736
773
  if (group.variables !== undefined && (group.variables === null || typeof group.variables !== "object" || Array.isArray(group.variables) || !Object.values(group.variables).every((v) => typeof v === "string"))) {
737
774
  throw new Error(`${where}: ${field} variables must be { "<name>": "<value>" }.`);
738
775
  }
@@ -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 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).
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) the approver filter (#4337) and the approved fields (#4287).
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
@@ -420,8 +420,11 @@ export interface PrForge {
420
420
  * counts when it came after the merge request's latest push, read from its
421
421
  * system notes, and never from the token's own user. `approvers` narrows
422
422
  * who counts (#4337), and a refusal names each approver left out and why.
423
+ * The result also lists who counted and who was left out (#4287), so a
424
+ * caller never parses `reason`. On GitLab the newest diff version must be
425
+ * of `head`, or the check is pending.
423
426
  */
424
- prApproved?(pr: number, head: string, approvers?: PrApproverFilter): Promise<PrRequireResult>;
427
+ prApproved?(pr: number, head: string, approvers?: PrApproverFilter): Promise<PrApprovalResult>;
425
428
  /**
426
429
  * The forge says `pr` merges as it stands: no conflicts, and on GitHub no
427
430
  * branch protection blocking it. Pending while the forge works it out. On
@@ -481,19 +484,26 @@ async function countApprovers(
481
484
  authors: ReadonlySet<string>,
482
485
  filter: PrApproverFilter | undefined,
483
486
  rule: (login: string) => Promise<string | undefined>,
484
- ): Promise<{ by: string[]; not: string[] }> {
487
+ ): Promise<{ by: string[]; not: PrExcludedApprover[] }> {
485
488
  const by: string[] = [];
486
- const not: string[] = [];
489
+ const not: PrExcludedApprover[] = [];
487
490
  for (const login of approved) {
488
491
  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);
492
+ if (why) not.push({ login, reason: why });
490
493
  else by.push(login);
491
494
  }
492
495
  return { by, not };
493
496
  }
494
497
 
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);
498
+ /** The approved verdict over who counted: a pass naming them, or a refusal naming each one left out and why. */
499
+ function approvalVerdict(by: string[], not: PrExcludedApprover[], passLine: string, noneLine: string): PrApprovalResult {
500
+ const fields = { approvers: by, excluded: not };
501
+ if (by.length > 0) return { ...pass(`${passLine} by ${by.join(", ")}`), ...fields };
502
+ return { ...refuse(not.length > 0 ? `${noneLine}: ${not.map((n) => n.reason).join("; ")}` : noneLine), ...fields };
503
+ }
504
+
505
+ /** An approved refusal before anyone was counted. */
506
+ const notCounted = (r: PrRequireResult): PrApprovalResult => ({ ...r, approvers: [], excluded: [] });
497
507
 
498
508
  /** GitLab's access level for Developer, the least that may push. */
499
509
  const GITLAB_DEVELOPER = 30;
@@ -510,6 +520,18 @@ export interface PrRequireResult {
510
520
  reason: string;
511
521
  }
512
522
 
523
+ /**
524
+ * What {@link PrForge.prApproved} found (#4287). `approvers` and `excluded`
525
+ * are empty when it refused before counting, such as on a moved head or a
526
+ * request for changes.
527
+ */
528
+ export interface PrApprovalResult extends PrRequireResult {
529
+ /** The logins whose approval of the head counted, sorted. */
530
+ approvers: string[];
531
+ /** The logins that approved and did not count, each with the reason the refusal gives. */
532
+ excluded: PrExcludedApprover[];
533
+ }
534
+
513
535
  const short = (sha: string | undefined): string => (sha ?? "nothing").slice(0, 8);
514
536
  const pass = (reason: string): PrRequireResult => ({ passed: true, reason });
515
537
  const refuse = (reason: string, pending = false): PrRequireResult => ({ passed: false, ...(pending ? { pending: true } : {}), reason });
@@ -867,7 +889,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
867
889
  async prApproved(pr, head, approvers) {
868
890
  const got = await api.call("GET", `${repo}/pulls/${pr}`);
869
891
  const at = headOf(got);
870
- if (at !== head) return moved(`pull request ${pr}`, head, at);
892
+ if (at !== head) return notCounted(moved(`pull request ${pr}`, head, at));
871
893
  // A reviewer's newest deciding review stands, as in approvers; an approval counts only of this head.
872
894
  const standing = new Map<string, { state: string; commit?: string; official?: boolean }>();
873
895
  for (const r of await api.list(`${repo}/pulls/${pr}/reviews`, pageParams, pageSize)) {
@@ -879,7 +901,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
879
901
  standing.set(login, { state, ...(commit ? { commit } : {}), ...(typeof r.official === "boolean" ? { official: r.official } : {}) });
880
902
  }
881
903
  const changes = [...standing].filter(([, r]) => r.state === "CHANGES_REQUESTED" || r.state === "REQUEST_CHANGES").map(([l]) => l).sort();
882
- if (changes.length > 0) return refuse(`${changes.join(", ")} asked for changes`);
904
+ if (changes.length > 0) return notCounted(refuse(`${changes.join(", ")} asked for changes`));
883
905
  const authors = new Set(await authorsOf(pr));
884
906
  const approved = [...standing].filter(([, r]) => r.state === "APPROVED" && r.commit === head).map(([l]) => l).sort();
885
907
  const rule = async (login: string): Promise<string | undefined> => {
@@ -895,8 +917,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
895
917
  return p.some((x) => x !== undefined && GITHUB_WRITERS.has(x)) ? undefined : `${login} has no write access`;
896
918
  };
897
919
  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);
899
- return pass(`approved at ${short(head)} by ${by.join(", ")}`);
920
+ return approvalVerdict(by, not, `approved at ${short(head)}`, `no reviewer other than its authors approved its head ${short(head)}`);
900
921
  },
901
922
  async prMergeable(pr, head) {
902
923
  const got = await api.call("GET", `${repo}/pulls/${pr}`);
@@ -1262,14 +1283,21 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
1262
1283
  async prApproved(mr, head, approvers) {
1263
1284
  const got = await api.call("GET", `${notes}/${mr}`);
1264
1285
  const at = isObject(got) ? str(got.sha) : undefined;
1265
- if (at !== head) return moved(`merge request !${mr}`, head, at);
1286
+ if (at !== head) return notCounted(moved(`merge request !${mr}`, head, at));
1266
1287
  // The token's own user approves for the pipeline, never as a reviewer.
1267
1288
  const me = await api.call("GET", "/user");
1268
1289
  const self = isObject(me) ? str(me.username) : undefined;
1269
- if (!self) return refuse("GitLab did not say which user the job's token acts as, so no approval counts");
1270
- // An approval counts once it came after the latest push: the newest version of the diff.
1290
+ if (!self) return notCounted(refuse("GitLab did not say which user the job's token acts as, so no approval counts"));
1291
+ // An approval counts once it came after the latest push: the newest version of the diff, which must be of head.
1271
1292
  const versions = await api.call("GET", `${notes}/${mr}/versions`);
1272
- const since = Math.max(-Infinity, ...(Array.isArray(versions) ? versions : []).map((v) => Date.parse((isObject(v) ? str(v.created_at) : undefined) ?? "")).filter(Number.isFinite));
1293
+ let since = -Infinity;
1294
+ let latest: string | undefined;
1295
+ for (const v of Array.isArray(versions) ? versions : []) {
1296
+ const when = Date.parse((isObject(v) ? str(v.created_at) : undefined) ?? "");
1297
+ if (Number.isFinite(when) && when > since) [since, latest] = [when, isObject(v) ? str(v.head_commit_sha) : undefined];
1298
+ }
1299
+ // GitLab records a push's version after it moves the head, so an older one is asked again.
1300
+ if (latest !== head) return notCounted(refuse(`merge request !${mr}'s newest diff version is of ${short(latest)}, not ${short(head)}`, true));
1273
1301
  const said = new Map<string, { what: string; at: number; id?: number }>();
1274
1302
  for (const n of await api.list(`${notes}/${mr}/notes?sort=asc&order_by=created_at`, pageParams, 100)) {
1275
1303
  if (!isObject(n) || n.system !== true) continue;
@@ -1283,7 +1311,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
1283
1311
  if (!before || when >= before.at) said.set(who, { what, at: when, ...(id !== undefined ? { id } : {}) });
1284
1312
  }
1285
1313
  const changes = [...said].filter(([, v]) => v.what === "changes").map(([w]) => w).sort();
1286
- if (changes.length > 0) return refuse(`${changes.join(", ")} requested changes`);
1314
+ if (changes.length > 0) return notCounted(refuse(`${changes.join(", ")} requested changes`));
1287
1315
  const authors = new Set(await authorsOf(mr));
1288
1316
  const approved = [...said].filter(([, v]) => v.what === "approved" && v.at > since).map(([w]) => w).sort();
1289
1317
  const least = approvers?.minAccessLevel ?? GITLAB_DEVELOPER;
@@ -1301,8 +1329,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
1301
1329
  return level >= least ? undefined : `${login} has access level ${level}, under ${least}`;
1302
1330
  };
1303
1331
  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);
1305
- return pass(`approved after ${short(head)} by ${by.join(", ")}`);
1332
+ return approvalVerdict(by, not, `approved after ${short(head)}`, `no member other than its authors approved it after its latest push, ${short(head)}`);
1306
1333
  },
1307
1334
  async prMergeable(mr, head) {
1308
1335
  const got = await api.call("GET", `${notes}/${mr}`);
@@ -32,14 +32,14 @@ describe("github", () => {
32
32
 
33
33
  test("approved: a reviewer other than the authors approved this head", async () => {
34
34
  const f = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }] });
35
- expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob" });
35
+ expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob", approvers: ["bob"], excluded: [] });
36
36
  });
37
37
 
38
38
  test("approved: an approval of an older head, the author's own, or a request for changes does not pass", async () => {
39
39
  const older = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: OTHER }, { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD }] });
40
40
  expect((await older.prApproved!(7, HEAD)).passed).toBe(false);
41
41
  const changes = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "carol" }, state: "CHANGES_REQUESTED", commit_id: HEAD }] });
42
- expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes" });
42
+ expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes", approvers: [], excluded: [] });
43
43
  });
44
44
 
45
45
  test("approved: forgeRule counts only writers, exclude leaves a login out, and the refusal says why (#4337)", async () => {
@@ -53,10 +53,24 @@ describe("github", () => {
53
53
  [`GET ${repo}/collaborators/bob/permission`]: { permission: "read", role_name: "triage" },
54
54
  [`GET ${repo}/collaborators/Carol/permission`]: { permission: "write", role_name: "maintain" },
55
55
  });
56
- expect(await f.prApproved!(7, HEAD, { forgeRule: true })).toEqual({ passed: true, reason: "approved at aaaaaaaa by Carol" });
56
+ expect(await f.prApproved!(7, HEAD, { forgeRule: true })).toEqual({
57
+ passed: true,
58
+ reason: "approved at aaaaaaaa by Carol",
59
+ approvers: ["Carol"],
60
+ excluded: [
61
+ { login: "alice", reason: "alice is one of its authors" },
62
+ { login: "bob", reason: "bob has no write access" },
63
+ ],
64
+ });
57
65
  expect(await f.prApproved!(7, HEAD, { forgeRule: true, exclude: [{ login: "carol", reason: "carol asked for aaaaaaa" }] })).toEqual({
58
66
  passed: false,
59
67
  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",
68
+ approvers: [],
69
+ excluded: [
70
+ { login: "Carol", reason: "carol asked for aaaaaaa" },
71
+ { login: "alice", reason: "alice is one of its authors" },
72
+ { login: "bob", reason: "bob has no write access" },
73
+ ],
60
74
  });
61
75
  expect((await f.prApproved!(7, HEAD, { exclude: ["bob"] })).reason).toBe("approved at aaaaaaaa by Carol");
62
76
  });
@@ -112,7 +126,14 @@ describe("forgejo", () => {
112
126
  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
127
  expect((await f.prApproved!(4, HEAD, { forgeRule: true })).reason).toBe("approved at aaaaaaaa by erin");
114
128
  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");
129
+ expect(out[0]).toMatchObject({
130
+ reason: "no reviewer other than its authors approved its head aaaaaaaa: bob's review is not official; erin is excluded",
131
+ approvers: [],
132
+ excluded: [
133
+ { login: "bob", reason: "bob's review is not official" },
134
+ { login: "erin", reason: "erin is excluded" },
135
+ ],
136
+ });
116
137
  });
117
138
 
118
139
  test("undiverged: the pull request's merge base is the base branch's tip", async () => {
@@ -150,7 +171,7 @@ describe("gitlab", () => {
150
171
  [`GET ${project}/merge_requests/3`]: mr(),
151
172
  [`GET ${project}/merge_requests/3/commits`]: [],
152
173
  "GET /api/v4/user": { id: 1, username: "chant-bot" },
153
- [`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z" }],
174
+ [`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z", head_commit_sha: HEAD }],
154
175
  ...routes,
155
176
  }),
156
177
  });
@@ -165,7 +186,7 @@ describe("gitlab", () => {
165
186
  });
166
187
  expect((await f.prApproved!(3, HEAD)).passed).toBe(false);
167
188
  const g = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z")] });
168
- expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob" });
189
+ expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob", approvers: ["bob"], excluded: [] });
169
190
  const h = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z"), note("carol", "requested changes", "2026-10-01T12:00:00Z")] });
170
191
  expect((await h.prApproved!(3, HEAD)).reason).toBe("carol requested changes");
171
192
  });
@@ -185,6 +206,22 @@ describe("gitlab", () => {
185
206
  expect((await f.prApproved!(3, HEAD, { forgeRule: true, minAccessLevel: 50 })).reason).toBe(
186
207
  "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
208
  );
209
+ expect((await f.prApproved!(3, HEAD, { forgeRule: true })).excluded).toEqual([
210
+ { login: "bob", reason: "bob has access level 20, under 30" },
211
+ { login: "eve", reason: "eve is not a member" },
212
+ ]);
213
+ });
214
+
215
+ test("approved: pending until GitLab's newest diff version is of the head, as terragucci checked (#4287)", async () => {
216
+ const approval = note("bob", "approved this merge request", "2026-10-01T11:00:00Z");
217
+ const versions = [
218
+ { created_at: "2026-10-01T10:00:00Z", head_commit_sha: HEAD },
219
+ { created_at: "2026-10-01T12:00:00Z", head_commit_sha: OTHER },
220
+ ];
221
+ const f = forge({ [`GET ${project}/merge_requests/3/notes`]: [approval], [`GET ${project}/merge_requests/3/versions`]: versions });
222
+ expect(await f.prApproved!(3, HEAD)).toEqual({ passed: false, pending: true, reason: "merge request !3's newest diff version is of cccccccc, not aaaaaaaa", approvers: [], excluded: [] });
223
+ const none = forge({ [`GET ${project}/merge_requests/3/notes`]: [approval], [`GET ${project}/merge_requests/3/versions`]: [] });
224
+ expect(await none.prApproved!(3, HEAD)).toMatchObject({ passed: false, pending: true });
188
225
  });
189
226
 
190
227
  test("mergeable and undiverged read the merge request", async () => {