@intentius/chant 0.117.0 → 0.118.0
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/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/files.d.ts +8 -0
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/lexicon.d.ts +10 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +11 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +113 -0
- package/dist/op/gate-review.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +47 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +20 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +13 -2
- package/src/cli/commands/build.ts +2 -1
- package/src/cli/handlers/run-wave.ts +13 -1
- package/src/cli/main.ts +7 -1
- package/src/cli/plugins.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/discovery/files.ts +18 -0
- package/src/lexicon.ts +10 -1
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/op/gate-review.test.ts +225 -0
- package/src/op/gate-review.ts +339 -0
- package/src/op/op-waves-run.ts +190 -12
- package/src/op/op-waves.ts +25 -0
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { discover, type DiscoveryResult, type FoldDecision } from "./discovery/i
|
|
|
19
19
|
import { decodeEntitySet, type DiscoveredEntitiesJson } from "./discovery/entity-wire";
|
|
20
20
|
import { buildDependencyGraph } from "./discovery/graph";
|
|
21
21
|
import { topologicalSort } from "./sort";
|
|
22
|
+
import { statSync } from "node:fs";
|
|
22
23
|
import { resolve } from "node:path";
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -641,6 +642,15 @@ function generateManifest(
|
|
|
641
642
|
};
|
|
642
643
|
}
|
|
643
644
|
|
|
645
|
+
/** The directory a build discovered from, for build-root contributors; undefined when the build's path is not one (an entity-set JSON's label). */
|
|
646
|
+
function sourceDirOf(path: string): string | undefined {
|
|
647
|
+
try {
|
|
648
|
+
return statSync(path).isDirectory() ? resolve(path) : undefined;
|
|
649
|
+
} catch {
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
644
654
|
/** What merging build-root contributions produced: non-fatal render notes and
|
|
645
655
|
* fatal messages (a failed contributor, a name collision). */
|
|
646
656
|
export interface BuildRootMergeResult {
|
|
@@ -667,6 +677,7 @@ export interface BuildRootMergeResult {
|
|
|
667
677
|
export async function mergeBuildRootEntities(
|
|
668
678
|
entities: Map<string, Declarable>,
|
|
669
679
|
contributors: ReadonlyArray<BuildRootContributor>,
|
|
680
|
+
sourceDir?: string,
|
|
670
681
|
): Promise<BuildRootMergeResult> {
|
|
671
682
|
const warnings: string[] = [];
|
|
672
683
|
const errors: string[] = [];
|
|
@@ -677,7 +688,7 @@ export async function mergeBuildRootEntities(
|
|
|
677
688
|
// ciphertext file to resolve). Contributors run in order, so this also
|
|
678
689
|
// carries what earlier contributors added; the merge below is still the
|
|
679
690
|
// only writer, which is what keeps the collision refusal meaningful.
|
|
680
|
-
const contribution = await contribute({ entities });
|
|
691
|
+
const contribution = await contribute({ entities, ...(sourceDir !== undefined ? { sourceDir } : {}) });
|
|
681
692
|
warnings.push(...(contribution.warnings ?? []));
|
|
682
693
|
for (const [name, entity] of contribution.entities) {
|
|
683
694
|
if (entities.has(name)) {
|
|
@@ -809,7 +820,7 @@ async function buildFromDiscoveryResult(
|
|
|
809
820
|
// object, and re-running the contributors there would duplicate every
|
|
810
821
|
// contributed entity once per child.
|
|
811
822
|
if (!parentBuildStack && options?.buildRoots) {
|
|
812
|
-
const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots);
|
|
823
|
+
const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots, sourceDirOf(resolvedPathForChildStack));
|
|
813
824
|
warnings.push(...merged.warnings);
|
|
814
825
|
for (const message of merged.errors) {
|
|
815
826
|
errors.push(new BuildErrorClass("", message));
|
|
@@ -922,7 +922,8 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
922
922
|
const resourceCount = result.entities.size;
|
|
923
923
|
const fileCount = result.sourceFileCount;
|
|
924
924
|
|
|
925
|
-
|
|
925
|
+
// A build root (a lexicon's own source files, a rendered overlay) can declare everything without one .ts file.
|
|
926
|
+
if (fileCount === 0 && resourceCount === 0 && errors.length === 0) {
|
|
926
927
|
console.error(formatInfo("No source files found — create .ts files in the target directory"));
|
|
927
928
|
}
|
|
928
929
|
|
|
@@ -5,13 +5,25 @@
|
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { relative, resolve } from "node:path";
|
|
8
|
-
import { parseOpWavesSpec, runOpWave } from "../../op/op-waves-run";
|
|
8
|
+
import { parseOpWavesSpec, recordOpWaveHeadPlans, runOpWave } from "../../op/op-waves-run";
|
|
9
9
|
import { formatError } from "../format";
|
|
10
10
|
import type { CommandContext } from "../registry";
|
|
11
11
|
|
|
12
12
|
export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
|
|
13
13
|
const { args } = ctx;
|
|
14
14
|
const specFile = args.opsSpec;
|
|
15
|
+
if (specFile && args.recordPlans) {
|
|
16
|
+
try {
|
|
17
|
+
const spec = parseOpWavesSpec(readFileSync(resolve(specFile), "utf-8"), `--spec ${specFile}`);
|
|
18
|
+
const plans = await recordOpWaveHeadPlans({ spec });
|
|
19
|
+
if (args.json) console.log(JSON.stringify(plans, null, 2));
|
|
20
|
+
else for (const w of plans.waves) console.error(`wave ${w.wave} (${w.name}) at ${plans.head.slice(0, 12)}: ${w.digest}`);
|
|
21
|
+
return 0;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
15
27
|
if (!specFile || args.wave === undefined) {
|
|
16
28
|
console.error(formatError({
|
|
17
29
|
message: "`chant run wave` needs --spec <file> and --wave <k>",
|
package/src/cli/main.ts
CHANGED
|
@@ -108,6 +108,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
108
108
|
"--root-only",
|
|
109
109
|
"--generated",
|
|
110
110
|
"--decide",
|
|
111
|
+
"--record-plans",
|
|
111
112
|
]);
|
|
112
113
|
|
|
113
114
|
/**
|
|
@@ -359,6 +360,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
359
360
|
result.wave = wave;
|
|
360
361
|
} else if (arg === "--decide") {
|
|
361
362
|
result.decide = true;
|
|
363
|
+
} else if (arg === "--record-plans") {
|
|
364
|
+
result.recordPlans = true;
|
|
362
365
|
} else if (arg === "--share") {
|
|
363
366
|
const raw = args[++i];
|
|
364
367
|
const share = Number(raw);
|
|
@@ -925,7 +928,10 @@ Ops:
|
|
|
925
928
|
run wave --spec <file> --wave <k> Plan, gate and apply wave k of an Op
|
|
926
929
|
waves spec (#3679): the job a generated waves
|
|
927
930
|
pipeline runs. --decide plans and decides only;
|
|
928
|
-
--share <i> applies one share of a decided wave
|
|
931
|
+
--share <i> applies one share of a decided wave.
|
|
932
|
+
--record-plans (no --wave), in a pull request's
|
|
933
|
+
pipeline: plan every wave at its head and record the
|
|
934
|
+
digests a pr-review approval mode compares
|
|
929
935
|
run resume [--op <name>] Start again each CI job that waits at a gate an
|
|
930
936
|
approval now answers, through the forge's API
|
|
931
937
|
(#3683); approves nothing. --dry-run lists them
|
package/src/cli/plugins.ts
CHANGED
|
@@ -211,7 +211,7 @@ export function collectBuildRootContributors(
|
|
|
211
211
|
.filter((plugin) => typeof plugin.buildRoots === "function")
|
|
212
212
|
// `entities` is not bindable here — discovery has not run — so the merge
|
|
213
213
|
// hands it in when it calls the closure (#1828 / SOPS provenance).
|
|
214
|
-
.map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
|
|
214
|
+
.map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities, ...(ctx?.sourceDir !== undefined ? { sourceDir: ctx.sourceDir } : {}) }));
|
|
215
215
|
return [...codegen, ...roots];
|
|
216
216
|
}
|
|
217
217
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -252,6 +252,8 @@ export interface ParsedArgs {
|
|
|
252
252
|
wave?: number;
|
|
253
253
|
/** `chant run wave --decide` (#3679) — plan a wide wave and decide its gate, applying nothing. */
|
|
254
254
|
decide?: boolean;
|
|
255
|
+
/** `chant run wave --record-plans` (#3684) — plan every wave at a pull request's head and record the digests for a `pr-review` gate. */
|
|
256
|
+
recordPlans?: boolean;
|
|
255
257
|
/** `chant run wave --share <i>` (#3679) — apply share `i` of a decided wide wave. */
|
|
256
258
|
share?: number;
|
|
257
259
|
/** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
|
package/src/discovery/files.ts
CHANGED
|
@@ -116,3 +116,21 @@ export async function findInfraFiles(path: string, options?: FindInfraFilesOptio
|
|
|
116
116
|
accept: (name, full) => isSourceFileName(name) && !hasDiscoveryMarkerSync(full),
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Find the files under `path` that `accept` wants, through the same walk and
|
|
122
|
+
* the same project globs as {@link findInfraFiles}: a lexicon that reads
|
|
123
|
+
* source of its own beside the TypeScript (the sql lexicon's `.sql` files)
|
|
124
|
+
* sees exactly the tree source discovery sees, so `dist`, git-ignored paths,
|
|
125
|
+
* child projects and the project's `exclude` globs are left out for it too.
|
|
126
|
+
*/
|
|
127
|
+
export async function findSourceFiles(path: string, accept: (name: string, full: string) => boolean, options?: FindInfraFilesOptions): Promise<string[]> {
|
|
128
|
+
const globs = options?.globs === null ? undefined : (options?.globs ?? (await resolveDiscoveryGlobs(path)));
|
|
129
|
+
return walkDiscovery({
|
|
130
|
+
walker: "source",
|
|
131
|
+
root: path,
|
|
132
|
+
globs,
|
|
133
|
+
excludeDirs: await workspaceMemberDirs(path),
|
|
134
|
+
accept,
|
|
135
|
+
});
|
|
136
|
+
}
|
package/src/lexicon.ts
CHANGED
|
@@ -984,6 +984,15 @@ export interface BuildRootContext {
|
|
|
984
984
|
* treat an absent map as an empty one.
|
|
985
985
|
*/
|
|
986
986
|
entities?: ReadonlyMap<string, Declarable>;
|
|
987
|
+
/**
|
|
988
|
+
* The directory this build discovered its source in (the path handed to
|
|
989
|
+
* `build()`, which `--src` or `sourceDir` chose), absolute. A contributor
|
|
990
|
+
* that reads files beside the typed source (the sql lexicon's `.sql`
|
|
991
|
+
* files) reads them here, so it sees the tree discovery saw. Optional: a
|
|
992
|
+
* caller that is not a build (the graph paths) omits it, and a hook then
|
|
993
|
+
* falls back to `sourceDir` in `config`, resolved against `projectRoot`.
|
|
994
|
+
*/
|
|
995
|
+
sourceDir?: string;
|
|
987
996
|
}
|
|
988
997
|
|
|
989
998
|
/**
|
|
@@ -1003,7 +1012,7 @@ export interface BuildRootContribution {
|
|
|
1003
1012
|
* the argument is still assignable, which is what every pre-#1828 hook does.
|
|
1004
1013
|
*/
|
|
1005
1014
|
export type BuildRootContributor = (
|
|
1006
|
-
ctx: Pick<BuildRootContext, "entities">,
|
|
1015
|
+
ctx: Pick<BuildRootContext, "entities" | "sourceDir">,
|
|
1007
1016
|
) => Promise<BuildRootContribution>;
|
|
1008
1017
|
|
|
1009
1018
|
/**
|
|
@@ -222,6 +222,13 @@ export interface GateResolutionRecord {
|
|
|
222
222
|
* verifies. Absent on an unsigned approval.
|
|
223
223
|
*/
|
|
224
224
|
seal?: GateApprovalSeal;
|
|
225
|
+
/**
|
|
226
|
+
* The forge review this approval came from (#3684): a wave whose approval
|
|
227
|
+
* mode is `pr-review` writes it when the merged pull request's head was
|
|
228
|
+
* approved by a writer other than its author and planned this digest.
|
|
229
|
+
* `resolvedBy` is the first of `approvers`.
|
|
230
|
+
*/
|
|
231
|
+
review?: { pr: number; head: string; approvers: string[] };
|
|
225
232
|
/**
|
|
226
233
|
* The answer record that passed the gate, on a gate that asks a decision
|
|
227
234
|
* point (#3170). The run writes this resolution itself when the point's
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import type { ForgeFetch } from "../pr-forge";
|
|
6
|
+
import { waveSetDigest } from "../gated-waves";
|
|
7
|
+
import { sealGateApproval } from "../workspace/trust/seal";
|
|
8
|
+
import { TestRepo, hasSshKeygen } from "../workspace/trust/test-repo";
|
|
9
|
+
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
10
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
11
|
+
import { githubReviews, forgejoReviews, gitlabReviews, type MergedReview, type ReviewSource } from "./gate-review";
|
|
12
|
+
import type { OpWavesSpec } from "./op-waves";
|
|
13
|
+
import { recordOpWaveHeadPlans, runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
|
|
14
|
+
|
|
15
|
+
const HEAD = "h".repeat(40);
|
|
16
|
+
const MERGE = "m".repeat(40);
|
|
17
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
18
|
+
|
|
19
|
+
/** A fake forge answering GETs by path. */
|
|
20
|
+
function forge(routes: Record<string, unknown>): ForgeFetch {
|
|
21
|
+
return async (url) => {
|
|
22
|
+
const { pathname } = new URL(url);
|
|
23
|
+
if (!(pathname in routes)) return { ok: false, status: 404, text: async () => "not found" };
|
|
24
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(routes[pathname]) };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const opts = (fetch: ForgeFetch, apiBase = "https://api.github.com") => ({ apiBase, repo: "acme/db", token: "t", host: "github.com", fetch });
|
|
29
|
+
|
|
30
|
+
describe("the merged pull request's review", () => {
|
|
31
|
+
const pr = { number: 7, user: { login: "carol" }, head: { sha: HEAD }, html_url: "https://github.com/acme/db/pull/7" };
|
|
32
|
+
const base = {
|
|
33
|
+
"/repos/acme/db/commits/mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm/pulls": [{ number: 7, merged_at: "x", merge_commit_sha: MERGE }],
|
|
34
|
+
"/repos/acme/db/pulls/7": pr,
|
|
35
|
+
"/repos/acme/db/collaborators/alice/permission": { permission: "write", role_name: "write" },
|
|
36
|
+
"/repos/acme/db/collaborators/dave/permission": { permission: "read", role_name: "read" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test("GitHub: the author's own approval never counts", async () => {
|
|
40
|
+
const review = await githubReviews(opts(forge({ ...base, "/repos/acme/db/pulls/7/reviews": [{ user: { login: "carol" }, state: "APPROVED", commit_id: HEAD }] }))).mergedReview(MERGE);
|
|
41
|
+
expect(review).toMatchObject({ pr: 7, author: "github:carol", head: HEAD, approvers: [] });
|
|
42
|
+
expect(review!.refused[0]).toMatch(/github:carol is the author/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("GitHub: a writer's approval of the head counts; a reader's, or one of an older commit, does not", async () => {
|
|
46
|
+
const review = await githubReviews(
|
|
47
|
+
opts(
|
|
48
|
+
forge({
|
|
49
|
+
...base,
|
|
50
|
+
"/repos/acme/db/pulls/7/reviews": [
|
|
51
|
+
{ user: { login: "alice" }, state: "APPROVED", commit_id: "old" },
|
|
52
|
+
{ user: { login: "alice" }, state: "APPROVED", commit_id: HEAD },
|
|
53
|
+
{ user: { login: "dave" }, state: "APPROVED", commit_id: HEAD },
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
).mergedReview(MERGE);
|
|
58
|
+
expect(review!.approvers).toEqual(["github:alice"]);
|
|
59
|
+
expect(review!.refused).toEqual(["github:dave approved without write access"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("GitHub: a writer who requests changes holds the review back", async () => {
|
|
63
|
+
const review = await githubReviews(
|
|
64
|
+
opts(
|
|
65
|
+
forge({
|
|
66
|
+
...base,
|
|
67
|
+
"/repos/acme/db/collaborators/bob/permission": { permission: "write", role_name: "maintain" },
|
|
68
|
+
"/repos/acme/db/pulls/7/reviews": [
|
|
69
|
+
{ user: { login: "alice" }, state: "APPROVED", commit_id: HEAD },
|
|
70
|
+
{ user: { login: "bob" }, state: "CHANGES_REQUESTED", commit_id: HEAD },
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
).mergedReview(MERGE);
|
|
75
|
+
expect(review!.approvers).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("Forgejo: only an official review that is neither stale nor dismissed counts", async () => {
|
|
79
|
+
const review = await forgejoReviews(
|
|
80
|
+
opts(
|
|
81
|
+
forge({
|
|
82
|
+
"/api/v1/repos/acme/db/commits/mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm/pull": { number: 7 },
|
|
83
|
+
"/api/v1/repos/acme/db/pulls/7": pr,
|
|
84
|
+
"/api/v1/repos/acme/db/pulls/7/reviews": [
|
|
85
|
+
{ user: { login: "alice" }, state: "APPROVED", commit_id: HEAD, official: true },
|
|
86
|
+
{ user: { login: "dave" }, state: "APPROVED", commit_id: HEAD, official: false },
|
|
87
|
+
{ user: { login: "erin" }, state: "APPROVED", commit_id: HEAD, official: true, stale: true },
|
|
88
|
+
],
|
|
89
|
+
}),
|
|
90
|
+
"https://code.example.org/api/v1",
|
|
91
|
+
),
|
|
92
|
+
).mergedReview(MERGE);
|
|
93
|
+
expect(review!.approvers).toEqual(["forgejo@github.com:alice"]);
|
|
94
|
+
expect(review!.refused).toHaveLength(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("GitLab: an approval counts only where a push removes approvals, and never the author's", async () => {
|
|
98
|
+
const routes = {
|
|
99
|
+
"/api/v4/projects/7/repository/commits/mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm/merge_requests": [{ iid: 3, state: "merged", merge_commit_sha: MERGE, sha: HEAD, author: { username: "carol" } }],
|
|
100
|
+
"/api/v4/projects/7/merge_requests/3/approvals": { approved_by: [{ user: { id: 1, username: "carol" } }, { user: { id: 2, username: "alice" } }] },
|
|
101
|
+
"/api/v4/projects/7/members/all/2": { access_level: 30 },
|
|
102
|
+
};
|
|
103
|
+
const keeps = await gitlabReviews({ ...opts(forge({ ...routes, "/api/v4/projects/7/approvals": { reset_approvals_on_push: false } }), "https://gl/api/v4"), repo: "7", host: "gitlab.com" }).mergedReview(MERGE);
|
|
104
|
+
expect(keeps!.approvers).toEqual([]);
|
|
105
|
+
const resets = await gitlabReviews({ ...opts(forge({ ...routes, "/api/v4/projects/7/approvals": { reset_approvals_on_push: true } }), "https://gl/api/v4"), repo: "7", host: "gitlab.com" }).mergedReview(MERGE);
|
|
106
|
+
expect(resets).toMatchObject({ pr: 3, head: HEAD, approvers: ["gitlab:alice"] });
|
|
107
|
+
expect(resets!.refused[0]).toMatch(/gitlab:carol is the author/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const spec = (approval: "pr-review" | "sealed"): OpWavesSpec => ({
|
|
112
|
+
name: "migrations",
|
|
113
|
+
op: "migrate",
|
|
114
|
+
plan: ["tool", "plan", "{target}", "--out", "{plan}"],
|
|
115
|
+
apply: ["tool", "apply", "{target}"],
|
|
116
|
+
waves: [{ name: "prod", runs: [{ target: "prod" }], approval }],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function harness(plan: string, dir?: string) {
|
|
120
|
+
const cwd = dir ?? mkdtempSync(join(tmpdir(), "gate-review-"));
|
|
121
|
+
const applied: string[] = [];
|
|
122
|
+
const exec: OpWaveExec = (argv, at) => {
|
|
123
|
+
if (argv[1] === "plan") {
|
|
124
|
+
mkdirSync(join(at, argv[4]!, ".."), { recursive: true });
|
|
125
|
+
writeFileSync(join(at, argv[4]!), JSON.stringify({ planDigest: plan }));
|
|
126
|
+
}
|
|
127
|
+
if (argv[1] === "apply") applied.push(argv[2]!);
|
|
128
|
+
return 0;
|
|
129
|
+
};
|
|
130
|
+
return { cwd, exec, applied };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("a pr-review wave (#3684)", () => {
|
|
134
|
+
const set = (plan: string) => waveSetDigest([{ member: "prod", planDigest: plan }]);
|
|
135
|
+
const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(spec("pr-review")) });
|
|
136
|
+
const reviewed = (approvers: string[], refused: string[] = []): ReviewSource => ({
|
|
137
|
+
kind: "github",
|
|
138
|
+
async mergedReview(): Promise<MergedReview> {
|
|
139
|
+
return { pr: 7, author: "github:carol", head: HEAD, approvers, refused, url: "https://github.com/acme/db/pull/7" };
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const plans = (d: string) => async () => ({ version: 1 as const, name: "migrations", head: HEAD, timestamp: "t", waves: [{ wave: 1, name: "prod", digest: d }] });
|
|
143
|
+
|
|
144
|
+
test("the author's own review applies nothing", async () => {
|
|
145
|
+
const h = harness(digest("a"));
|
|
146
|
+
const result = await runOpWave({
|
|
147
|
+
spec: spec("pr-review"), specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show, head: MERGE,
|
|
148
|
+
gates: memoryGateLedgerPort(), reviews: reviewed([], ["github:carol is the author, whose own review never counts"]), headPlans: plans(set(digest("a"))),
|
|
149
|
+
});
|
|
150
|
+
expect(result).toMatchObject({ exitCode: 3, applied: [], decision: { approval: "pr-review", status: "waiting" } });
|
|
151
|
+
expect(result.decision!.review!.reason).toMatch(/is the author/);
|
|
152
|
+
expect(h.applied).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a writer's review of the head approves the digest the head planned, and the ledger records it", async () => {
|
|
156
|
+
const h = harness(digest("a"));
|
|
157
|
+
const gates = memoryGateLedgerPort();
|
|
158
|
+
const result = await runOpWave({
|
|
159
|
+
spec: spec("pr-review"), specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show, head: MERGE,
|
|
160
|
+
gates, reviews: reviewed(["github:alice"]), headPlans: plans(set(digest("a"))),
|
|
161
|
+
});
|
|
162
|
+
expect(result).toMatchObject({ exitCode: 0, applied: ["prod"], decision: { status: "approved", via: "pr-review", approvedBy: "github:alice" } });
|
|
163
|
+
expect(gates.resolved[0]).toMatchObject({ resolvedBy: "github:alice", planDigest: set(digest("a")), review: { pr: 7, head: HEAD, approvers: ["github:alice"] } });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a plan that moved after the review waits for a new approval", async () => {
|
|
167
|
+
const h = harness(digest("b"));
|
|
168
|
+
const result = await runOpWave({
|
|
169
|
+
spec: spec("pr-review"), specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show, head: MERGE,
|
|
170
|
+
gates: memoryGateLedgerPort(), reviews: reviewed(["github:alice"]), headPlans: plans(set(digest("a"))),
|
|
171
|
+
});
|
|
172
|
+
expect(result.exitCode).toBe(3);
|
|
173
|
+
expect(result.decision!.review!.reason).toMatch(/plan moved after the review/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("--record-plans records each wave's digest at the head", async () => {
|
|
177
|
+
const h = harness(digest("a"));
|
|
178
|
+
let written: unknown;
|
|
179
|
+
const recorded = await recordOpWaveHeadPlans({ spec: spec("pr-review"), cwd: h.cwd, exec: h.exec, head: HEAD, now: "t", write: async (p) => void (written = p) });
|
|
180
|
+
expect(recorded.waves).toEqual([{ wave: 1, name: "prod", digest: set(digest("a")) }]);
|
|
181
|
+
expect(written).toEqual(recorded);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe.skipIf(!hasSshKeygen)("a sealed wave (#3684)", () => {
|
|
186
|
+
const repo = new TestRepo("3684-sealed");
|
|
187
|
+
const alice = repo.key("alice");
|
|
188
|
+
const mallory = repo.key("mallory");
|
|
189
|
+
afterAll(() => rmSync(join(repo.dir, ".."), { recursive: true, force: true }));
|
|
190
|
+
|
|
191
|
+
// Base: the spec and a signers file listing alice. The applied change adds mallory's key.
|
|
192
|
+
writeFileSync(join(repo.dir, "waves.json"), JSON.stringify(spec("sealed")));
|
|
193
|
+
mkdirSync(join(repo.dir, ".chant"), { recursive: true });
|
|
194
|
+
writeFileSync(join(repo.dir, ".chant/allowed_signers"), `alice ${alice.pub}\n`);
|
|
195
|
+
repo.git(["add", "-A"]);
|
|
196
|
+
repo.git(["commit", "-q", "-m", "base"]);
|
|
197
|
+
writeFileSync(join(repo.dir, ".chant/allowed_signers"), `alice ${alice.pub}\nmallory ${mallory.pub}\n`);
|
|
198
|
+
repo.git(["add", "-A"]);
|
|
199
|
+
repo.git(["commit", "-q", "-m", "the change adds a signer"]);
|
|
200
|
+
|
|
201
|
+
const plan = digest("a");
|
|
202
|
+
const set = waveSetDigest([{ member: "prod", planDigest: plan }]);
|
|
203
|
+
const approval = (by: string, keyFile?: string): GateResolutionRecord => {
|
|
204
|
+
const a = { op: "migrations", gate: "migrations-wave-1", resolvedBy: by, timestamp: "2026-10-10T00:00:00Z", planDigest: set };
|
|
205
|
+
return { version: 1, ...a, ...(keyFile ? { seal: sealGateApproval(keyFile, a) } : {}) };
|
|
206
|
+
};
|
|
207
|
+
const run = (resolutions: GateResolutionRecord[]) => {
|
|
208
|
+
const h = harness(plan, repo.dir);
|
|
209
|
+
return runOpWave({ spec: spec("sealed"), specFile: "waves.json", wave: 1, cwd: repo.dir, exec: h.exec, gates: memoryGateLedgerPort({ resolutions }), now: "2026-10-10T01:00:00Z" });
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
test("an unsigned approval applies nothing, and the hint asks for --sign", async () => {
|
|
213
|
+
const result = await run([approval("alice")]);
|
|
214
|
+
expect(result).toMatchObject({ exitCode: 3, decision: { approval: "sealed", status: "waiting" } });
|
|
215
|
+
expect(result.decision!.approve).toMatch(/--sign$/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a seal by a key the change itself added applies nothing", async () => {
|
|
219
|
+
expect((await run([approval("mallory", mallory.file)])).exitCode).toBe(3);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a seal by a signer listed at base applies the wave", async () => {
|
|
223
|
+
expect(await run([approval("alice", alice.file)])).toMatchObject({ exitCode: 0, applied: ["prod"], decision: { status: "approved", approvedBy: "alice" } });
|
|
224
|
+
});
|
|
225
|
+
});
|