@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.
- package/dist/ci-identity.d.ts +183 -12
- package/dist/ci-identity.d.ts.map +1 -1
- package/dist/ci-identity.js +362 -53
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +17 -1
- package/dist/cli/handlers/pr-env.d.ts +8 -2
- package/dist/cli/handlers/pr-env.d.ts.map +1 -1
- package/dist/cli/handlers/pr-env.js +126 -225
- package/dist/cli/handlers/pr.d.ts +3 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +1 -0
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +4 -2
- package/dist/components/fan-out-support.d.ts +3 -0
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/fan-out-support.js +1 -1
- package/dist/components/pr-env-run.d.ts +270 -0
- package/dist/components/pr-env-run.d.ts.map +1 -0
- package/dist/components/pr-env-run.js +440 -0
- package/dist/components/pr-environment.d.ts +89 -14
- package/dist/components/pr-environment.d.ts.map +1 -1
- package/dist/components/pr-environment.js +94 -8
- package/dist/components/pr-pipeline.d.ts +10 -1
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +17 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +23 -1
- package/dist/pr-forge.d.ts +15 -1
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +28 -16
- package/dist/pr-requires.d.ts +5 -1
- package/dist/pr-requires.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +179 -179
- package/dist/workspace/ci-cloud-identity.d.ts +3 -1
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
- package/dist/workspace/ci-cloud-identity.js +16 -1
- package/dist/workspace/ci-run.d.ts +35 -8
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +95 -21
- package/dist/workspace/ci-secrets.d.ts.map +1 -1
- package/dist/workspace/ci-secrets.js +4 -3
- package/dist/workspace/declaration.d.ts +10 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +17 -1
- package/dist/workspace/declaration.schema.json +36 -1
- package/package.json +1 -1
- package/src/ci-identity-targets.test.ts +275 -0
- package/src/ci-identity.ts +394 -61
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/pr-env.ts +136 -227
- package/src/cli/handlers/pr.ts +4 -0
- package/src/components/cli-support.ts +6 -1
- package/src/components/fan-out-support.ts +4 -1
- package/src/components/pr-env-run.test.ts +290 -0
- package/src/components/pr-env-run.ts +634 -0
- package/src/components/pr-environment.ts +159 -12
- package/src/components/pr-pipeline.ts +10 -1
- package/src/op/op-waves.ts +38 -1
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.ts +44 -17
- package/src/pr-requires.test.ts +43 -6
- package/src/pr-requires.ts +5 -1
- package/src/workspace/ci-cloud-identity.test.ts +20 -0
- package/src/workspace/ci-cloud-identity.ts +14 -1
- package/src/workspace/ci-run.test.ts +74 -1
- package/src/workspace/ci-run.ts +122 -29
- package/src/workspace/ci-secrets.test.ts +1 -1
- package/src/workspace/ci-secrets.ts +4 -3
- package/src/workspace/declaration.schema.json +36 -1
- package/src/workspace/declaration.test.ts +7 -0
- 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:
|
|
310
|
-
|
|
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
|
|
320
|
-
* the environment was applied at: its own `pr-*` entry's, else the
|
|
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
|
|
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,
|
|
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) => ({
|
|
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:
|
|
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
|
-
|
|
509
|
-
|
|
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. */
|
package/src/op/op-waves.ts
CHANGED
|
@@ -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)
|
|
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<
|
|
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:
|
|
487
|
+
): Promise<{ by: string[]; not: PrExcludedApprover[] }> {
|
|
485
488
|
const by: string[] = [];
|
|
486
|
-
const not:
|
|
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
|
-
/**
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/src/pr-requires.test.ts
CHANGED
|
@@ -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({
|
|
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]
|
|
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 () => {
|