@intentius/chant 0.116.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/gate-resume.d.ts +44 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -0
- package/dist/cli/handlers/operator.d.ts +1 -0
- package/dist/cli/handlers/operator.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 +8 -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 +18 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +104 -0
- package/dist/op/gate-resume.d.ts.map +1 -0
- package/dist/op/gate-review.d.ts +113 -0
- package/dist/op/gate-review.d.ts.map +1 -0
- package/dist/op/gate.d.ts.map +1 -1
- 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 +39 -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/gate-resume.test.ts +51 -0
- package/src/cli/handlers/gate-resume.ts +166 -0
- package/src/cli/handlers/operator.ts +8 -2
- package/src/cli/handlers/run-wave.ts +13 -1
- package/src/cli/main.ts +21 -3
- package/src/cli/plugins.ts +1 -1
- package/src/cli/registry.ts +8 -0
- package/src/discovery/files.ts +18 -0
- package/src/lexicon.ts +10 -1
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/op/gate-resume.e2e.test.ts +135 -0
- package/src/op/gate-resume.test.ts +177 -0
- package/src/op/gate-resume.ts +267 -0
- package/src/op/gate-review.test.ts +225 -0
- package/src/op/gate-review.ts +339 -0
- package/src/op/gate.ts +4 -0
- package/src/op/op-waves-run.ts +191 -13
- package/src/op/op-waves.ts +51 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume the job that waits at a gate (#3683).
|
|
3
|
+
*
|
|
4
|
+
* A CI job that reaches a gate nobody approved records a pending fact on
|
|
5
|
+
* `chant/lifecycle` and exits 3. Nothing re-runs it when the approval lands,
|
|
6
|
+
* because a push to the ledger branch starts no pipeline. This module closes
|
|
7
|
+
* that loop through the forge's API.
|
|
8
|
+
*
|
|
9
|
+
* When the job runs in CI, the pending fact it records carries a
|
|
10
|
+
* {@link GateRunLocator}, read from the job's environment by
|
|
11
|
+
* {@link resolveGateRunLocator}: which forge, the API base, the repository,
|
|
12
|
+
* the run, and what the forge needs to start it again. After an approval,
|
|
13
|
+
* {@link resumeGateRun} starts it again:
|
|
14
|
+
*
|
|
15
|
+
* | Forge | Call | Token |
|
|
16
|
+
* |---|---|---|
|
|
17
|
+
* | GitHub | `POST /repos/{repo}/actions/runs/{run}/rerun-failed-jobs` | `actions: write` (the job's `GITHUB_TOKEN` with that permission, or a fine-grained token with Actions read and write) |
|
|
18
|
+
* | GitLab | `POST /projects/{id}/jobs/{job}/retry` | a project or personal access token with the `api` scope and the Developer role; `CI_JOB_TOKEN` cannot retry jobs |
|
|
19
|
+
* | Forgejo | `POST /repos/{repo}/actions/workflows/{file}/dispatches` on the run's branch | a token with the `write:repository` scope; Forgejo has no API to re-run a run, so it starts a new run of the workflow |
|
|
20
|
+
*
|
|
21
|
+
* GitHub re-runs the failed job and the jobs that need it, at the same
|
|
22
|
+
* commit. GitLab retries the job, and the jobs it skipped run after it. A
|
|
23
|
+
* Forgejo dispatch runs the whole workflow again at the branch's head, so the
|
|
24
|
+
* jobs before the gate run again too; they must be safe to repeat (a wave
|
|
25
|
+
* whose runs already applied plans nothing and applies nothing new).
|
|
26
|
+
*
|
|
27
|
+
* Resuming approves nothing. The resumed job decides the gate again from the
|
|
28
|
+
* ledger, so resuming a run whose approval does not count only makes it wait
|
|
29
|
+
* again. {@link resumeGateRun} skips a run that is still going, that ended
|
|
30
|
+
* well, or that was already resumed (a later GitHub attempt, a newer GitLab
|
|
31
|
+
* job of the same name, a newer Forgejo run of the workflow), so a scheduled
|
|
32
|
+
* resume job can call it every few minutes without starting a run twice.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
|
|
36
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
37
|
+
import { latestResolutionForPlan } from "../lifecycle/gate-ledger";
|
|
38
|
+
import { tallyGateApprovals } from "./gate";
|
|
39
|
+
|
|
40
|
+
/** Where the job that recorded a pending fact runs, and what its forge needs to start it again. */
|
|
41
|
+
export interface GateRunLocator {
|
|
42
|
+
forge: ForgeKind;
|
|
43
|
+
/** The forge's API base: `$GITHUB_API_URL` (GitHub and Forgejo) or `$CI_API_V4_URL` (GitLab). */
|
|
44
|
+
api: string;
|
|
45
|
+
/** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
|
|
46
|
+
repo: string;
|
|
47
|
+
/** The run: GitHub's and Forgejo's run id, GitLab's pipeline id. */
|
|
48
|
+
run: string;
|
|
49
|
+
/** GitHub: the run attempt that waited. A later attempt means it was already re-run. */
|
|
50
|
+
attempt?: number;
|
|
51
|
+
/** GitLab: the job that waited. */
|
|
52
|
+
job?: string;
|
|
53
|
+
/** GitLab: that job's name, to find a retry of it. */
|
|
54
|
+
jobName?: string;
|
|
55
|
+
/** Forgejo: the workflow file the run came from, which a dispatch names. */
|
|
56
|
+
workflow?: string;
|
|
57
|
+
/** Forgejo: the branch a dispatch runs on. */
|
|
58
|
+
ref?: string;
|
|
59
|
+
/** The run's page, for a person. */
|
|
60
|
+
url?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The locator of the CI job this process runs in, or undefined outside CI or
|
|
67
|
+
* when the environment lacks a part a resume needs. Forgejo Actions sets the
|
|
68
|
+
* `GITHUB_*` variables too, and is told apart by `FORGEJO_ACTIONS` or
|
|
69
|
+
* `GITEA_ACTIONS`.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveGateRunLocator(env: Record<string, string | undefined> = process.env): GateRunLocator | undefined {
|
|
72
|
+
if (env.GITLAB_CI === "true") {
|
|
73
|
+
if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !env.CI_PIPELINE_ID || !env.CI_JOB_ID) return undefined;
|
|
74
|
+
return {
|
|
75
|
+
forge: "gitlab",
|
|
76
|
+
api: trimSlash(env.CI_API_V4_URL),
|
|
77
|
+
repo: env.CI_PROJECT_ID,
|
|
78
|
+
run: env.CI_PIPELINE_ID,
|
|
79
|
+
job: env.CI_JOB_ID,
|
|
80
|
+
...(env.CI_JOB_NAME ? { jobName: env.CI_JOB_NAME } : {}),
|
|
81
|
+
...(env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_REPOSITORY || !env.GITHUB_RUN_ID) return undefined;
|
|
85
|
+
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
|
|
86
|
+
const server = env.GITHUB_SERVER_URL ? trimSlash(env.GITHUB_SERVER_URL) : undefined;
|
|
87
|
+
const url = server ? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined;
|
|
88
|
+
if (forgejo) {
|
|
89
|
+
const workflow = forgejoWorkflowFile(env);
|
|
90
|
+
const api = env.GITHUB_API_URL ?? (server ? `${server}/api/v1` : undefined);
|
|
91
|
+
if (!api || !workflow || !env.GITHUB_REF_NAME) return undefined;
|
|
92
|
+
return {
|
|
93
|
+
forge: "forgejo",
|
|
94
|
+
api: trimSlash(api),
|
|
95
|
+
repo: env.GITHUB_REPOSITORY,
|
|
96
|
+
run: env.GITHUB_RUN_ID,
|
|
97
|
+
workflow,
|
|
98
|
+
ref: env.GITHUB_REF_NAME,
|
|
99
|
+
...(url ? { url } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const attempt = Number(env.GITHUB_RUN_ATTEMPT);
|
|
103
|
+
return {
|
|
104
|
+
forge: "github",
|
|
105
|
+
api: trimSlash(env.GITHUB_API_URL ?? "https://api.github.com"),
|
|
106
|
+
repo: env.GITHUB_REPOSITORY,
|
|
107
|
+
run: env.GITHUB_RUN_ID,
|
|
108
|
+
...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}),
|
|
109
|
+
...(url ? { url } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The workflow file a Forgejo run came from, by its base name. Forgejo names
|
|
115
|
+
* a workflow by its file (`GITHUB_WORKFLOW`, `deploy.yml`); `GITHUB_WORKFLOW_REF`
|
|
116
|
+
* (`owner/repo/.forgejo/workflows/deploy.yml@refs/heads/main`) says it too.
|
|
117
|
+
*/
|
|
118
|
+
function forgejoWorkflowFile(env: Record<string, string | undefined>): string | undefined {
|
|
119
|
+
const fromRef = env.GITHUB_WORKFLOW_REF?.split("@")[0]?.split("/").pop();
|
|
120
|
+
if (fromRef && /\.ya?ml$/.test(fromRef)) return fromRef;
|
|
121
|
+
const name = env.GITHUB_WORKFLOW;
|
|
122
|
+
return name && /\.ya?ml$/.test(name) ? name.split("/").pop() : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether `value` is a {@link GateRunLocator} a resume can use. */
|
|
126
|
+
export function isGateRunLocator(value: unknown): value is GateRunLocator {
|
|
127
|
+
if (!value || typeof value !== "object") return false;
|
|
128
|
+
const v = value as Record<string, unknown>;
|
|
129
|
+
if (typeof v.api !== "string" || typeof v.repo !== "string" || typeof v.run !== "string") return false;
|
|
130
|
+
if (v.forge === "github") return true;
|
|
131
|
+
if (v.forge === "gitlab") return typeof v.job === "string";
|
|
132
|
+
if (v.forge === "forgejo") return typeof v.workflow === "string" && typeof v.ref === "string";
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What {@link resumeGateRun} did. */
|
|
137
|
+
export type GateResumeOutcome =
|
|
138
|
+
| { status: "resumed"; forge: ForgeKind; how: string; url?: string }
|
|
139
|
+
| { status: "skipped"; forge: ForgeKind; reason: string; url?: string };
|
|
140
|
+
|
|
141
|
+
export interface ResumeGateRunOptions {
|
|
142
|
+
token: string;
|
|
143
|
+
/** The approval's time. A Forgejo run of the workflow created after it means the run was already resumed. */
|
|
144
|
+
since?: string;
|
|
145
|
+
fetch?: ForgeFetch;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
149
|
+
|
|
150
|
+
function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
|
|
151
|
+
return async (method: string, path: string, body?: unknown): Promise<unknown> => {
|
|
152
|
+
const url = `${trimSlash(base)}${path}`;
|
|
153
|
+
const res = await fetchImpl(url, {
|
|
154
|
+
method,
|
|
155
|
+
headers: { ...headers, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
156
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
157
|
+
});
|
|
158
|
+
const text = await res.text();
|
|
159
|
+
if (!res.ok) throw new ForgeApiError(method, url, res.status, text);
|
|
160
|
+
if (!text) return null;
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(text);
|
|
163
|
+
} catch {
|
|
164
|
+
return text;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Start the job a pending fact's locator names again, or say why not. Throws
|
|
171
|
+
* {@link ForgeApiError} when the forge refuses a call, such as a token
|
|
172
|
+
* without the access the table in the module doc lists.
|
|
173
|
+
*/
|
|
174
|
+
export async function resumeGateRun(loc: GateRunLocator, opts: ResumeGateRunOptions): Promise<GateResumeOutcome> {
|
|
175
|
+
const fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch);
|
|
176
|
+
const url = loc.url ? { url: loc.url } : {};
|
|
177
|
+
const skip = (reason: string): GateResumeOutcome => ({ status: "skipped", forge: loc.forge, reason, ...url });
|
|
178
|
+
|
|
179
|
+
if (loc.forge === "github") {
|
|
180
|
+
const call = client(loc.api, { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" }, fetchImpl);
|
|
181
|
+
const path = `/repos/${loc.repo}/actions/runs/${loc.run}`;
|
|
182
|
+
const run = await call("GET", path);
|
|
183
|
+
if (!isObject(run)) throw new Error(`GET ${path} returned no run`);
|
|
184
|
+
if (run.status !== "completed") return skip(`run ${loc.run} is ${String(run.status)}, not finished`);
|
|
185
|
+
const attempt = typeof run.run_attempt === "number" ? run.run_attempt : undefined;
|
|
186
|
+
if (loc.attempt !== undefined && attempt !== undefined && attempt > loc.attempt) {
|
|
187
|
+
return skip(`run ${loc.run} was already re-run (attempt ${attempt}; attempt ${loc.attempt} waited)`);
|
|
188
|
+
}
|
|
189
|
+
if (run.conclusion === "success") return skip(`run ${loc.run} succeeded`);
|
|
190
|
+
await call("POST", `${path}/rerun-failed-jobs`, {});
|
|
191
|
+
return { status: "resumed", forge: "github", how: `re-ran the failed jobs of run ${loc.run}`, ...url };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (loc.forge === "gitlab") {
|
|
195
|
+
const call = client(loc.api, { "PRIVATE-TOKEN": opts.token }, fetchImpl);
|
|
196
|
+
const project = `/projects/${encodeURIComponent(decodeURIComponent(loc.repo))}`;
|
|
197
|
+
const job = await call("GET", `${project}/jobs/${loc.job}`);
|
|
198
|
+
if (!isObject(job)) throw new Error(`GET ${project}/jobs/${loc.job} returned no job`);
|
|
199
|
+
const status = String(job.status);
|
|
200
|
+
if (status === "success") return skip(`job ${loc.job} succeeded`);
|
|
201
|
+
if (!["failed", "canceled", "skipped"].includes(status)) return skip(`job ${loc.job} is ${status}, not finished`);
|
|
202
|
+
const name = loc.jobName ?? (typeof job.name === "string" ? job.name : undefined);
|
|
203
|
+
if (name) {
|
|
204
|
+
// The pipeline's jobs list leaves retried jobs out, so the job of this name is its newest try.
|
|
205
|
+
const jobs = await call("GET", `${project}/pipelines/${loc.run}/jobs?per_page=100`);
|
|
206
|
+
const newest = Array.isArray(jobs) ? jobs.find((j) => isObject(j) && j.name === name) : undefined;
|
|
207
|
+
if (isObject(newest) && String(newest.id) !== String(loc.job)) {
|
|
208
|
+
return skip(`job ${loc.job} (${name}) was already retried as job ${String(newest.id)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const retried = await call("POST", `${project}/jobs/${loc.job}/retry`);
|
|
212
|
+
const id = isObject(retried) && retried.id !== undefined ? ` as job ${String(retried.id)}` : "";
|
|
213
|
+
return { status: "resumed", forge: "gitlab", how: `retried job ${loc.job}${id}`, ...url };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const call = client(loc.api, { Authorization: `token ${opts.token}`, Accept: "application/json" }, fetchImpl);
|
|
217
|
+
const repo = `/repos/${loc.repo}`;
|
|
218
|
+
const run = await call("GET", `${repo}/actions/runs/${loc.run}`);
|
|
219
|
+
if (!isObject(run)) throw new Error(`GET ${repo}/actions/runs/${loc.run} returned no run`);
|
|
220
|
+
const status = String(run.status);
|
|
221
|
+
if (status === "success") return skip(`run ${loc.run} succeeded`);
|
|
222
|
+
if (["waiting", "running", "blocked", "unknown"].includes(status)) return skip(`run ${loc.run} is ${status}, not finished`);
|
|
223
|
+
const listed = await call("GET", `${repo}/actions/runs?workflow_id=${encodeURIComponent(loc.workflow!)}&limit=50`);
|
|
224
|
+
const runs = isObject(listed) && Array.isArray(listed.workflow_runs) ? listed.workflow_runs : Array.isArray(listed) ? listed : [];
|
|
225
|
+
// Forgejo reports `created` in whole seconds.
|
|
226
|
+
const since = opts.since ? Math.floor(new Date(opts.since).getTime() / 1000) * 1000 : undefined;
|
|
227
|
+
const later = runs.find(
|
|
228
|
+
(r) =>
|
|
229
|
+
isObject(r) && Number(r.id) > Number(loc.run) &&
|
|
230
|
+
(r.prettyref === undefined || r.prettyref === loc.ref) &&
|
|
231
|
+
(since === undefined || (typeof r.created === "string" && new Date(r.created).getTime() >= since)),
|
|
232
|
+
);
|
|
233
|
+
if (isObject(later)) return skip(`run ${String(later.id)} of ${loc.workflow} started after the approval`);
|
|
234
|
+
await call("POST", `${repo}/actions/workflows/${encodeURIComponent(loc.workflow!)}/dispatches`, { ref: loc.ref });
|
|
235
|
+
return { status: "resumed", forge: "forgejo", how: `dispatched ${loc.workflow} on ${loc.ref}`, ...url };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The resolution that answers `standing`, read the way a run decides the gate
|
|
240
|
+
* (the plan, the environment, the quorum), or undefined. A workspace's
|
|
241
|
+
* `identity.gates` rule is not applied here: the resumed job applies it, and
|
|
242
|
+
* waits again when the approval does not count.
|
|
243
|
+
*/
|
|
244
|
+
export function resolutionAnswering(
|
|
245
|
+
resolutions: readonly GateResolutionRecord[],
|
|
246
|
+
standing: PendingGateRecord,
|
|
247
|
+
): GateResolutionRecord | undefined {
|
|
248
|
+
const env = standing.environment;
|
|
249
|
+
const mine = resolutions.filter((r) => env === undefined || r.environment === env);
|
|
250
|
+
if (standing.approval) {
|
|
251
|
+
const tally = tallyGateApprovals(mine, standing.gate, standing.timestamp, standing.planDigest, standing.approval);
|
|
252
|
+
if (tally.permit) return tally.permit;
|
|
253
|
+
return tally.counted.length >= tally.need ? tally.counted[tally.counted.length - 1] : undefined;
|
|
254
|
+
}
|
|
255
|
+
return latestResolutionForPlan(mine, standing.gate, standing.timestamp, standing.planDigest).resolution;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The token a resume uses: `CHANT_FORGE_TOKEN`, else the forge's own
|
|
260
|
+
* (`GITHUB_TOKEN` or `GH_TOKEN` on GitHub and Forgejo, `GITLAB_TOKEN` on
|
|
261
|
+
* GitLab), the same order the pull-request loop reads (`../pr-forge.ts`).
|
|
262
|
+
*/
|
|
263
|
+
export function resumeTokenFromEnv(forge: ForgeKind, env: Record<string, string | undefined> = process.env): string | undefined {
|
|
264
|
+
if (env.CHANT_FORGE_TOKEN) return env.CHANT_FORGE_TOKEN;
|
|
265
|
+
return forge === "gitlab" ? env.GITLAB_TOKEN : env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
266
|
+
}
|
|
267
|
+
|
|
@@ -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
|
+
});
|