@intentius/chant 0.117.0 → 0.118.1
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/activities/lifecycle.d.ts +6 -1
- package/dist/op/activities/lifecycle.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/activities/lifecycle-gate.test.ts +147 -0
- package/src/op/activities/lifecycle.ts +15 -6
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval modes for a gate (#3684): which approvals of a wave's digest
|
|
3
|
+
* count.
|
|
4
|
+
*
|
|
5
|
+
* - `ledger` (the default): any resolution on `chant/lifecycle` that names
|
|
6
|
+
* the digest, as `chant approve` writes it. Anyone who can push to the
|
|
7
|
+
* branch can approve, in anyone's name.
|
|
8
|
+
* - `sealed`: a resolution counts only when its seal (`chant approve --sign`)
|
|
9
|
+
* verifies against the signers file read at the base commit, for the
|
|
10
|
+
* approver it names. A key that the change being applied adds to the
|
|
11
|
+
* signers file is not at base, so it cannot approve that change.
|
|
12
|
+
* - `pr-review`: a resolution counts as under `ledger`, and so does the
|
|
13
|
+
* forge review of the pull request that merged the applied commit: an
|
|
14
|
+
* approving review of the pull request's head, by a writer other than its
|
|
15
|
+
* author. It approves the digest that head planned, which the pull
|
|
16
|
+
* request's pipeline records on `chant/lifecycle` with `chant run wave
|
|
17
|
+
* --record-plans` ({@link HeadPlans}). A plan that moved after the review
|
|
18
|
+
* waits for a `chant approve` of the new digest.
|
|
19
|
+
*
|
|
20
|
+
* What counts as an approving review, per forge:
|
|
21
|
+
*
|
|
22
|
+
* | Forge | Counts | Writer |
|
|
23
|
+
* |---|---|---|
|
|
24
|
+
* | GitHub | the reviewer's newest deciding review is `APPROVED` and names the head (`commit_id`) | `GET /repos/{repo}/collaborators/{login}/permission` is `admin`, `maintain` or `write` |
|
|
25
|
+
* | Forgejo | the same, not dismissed and not stale | the review is `official`, which Forgejo sets for a reviewer with write access |
|
|
26
|
+
* | GitLab | the merge request's approvals, when the project removes approvals on a new push (`reset_approvals_on_push`), since a GitLab approval names no commit | the approver's access level is Developer (30) or higher |
|
|
27
|
+
*
|
|
28
|
+
* A standing review that requests changes, from a writer, holds the review
|
|
29
|
+
* back. The author's own review never counts.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
33
|
+
import { checkGateSeal } from "../workspace/trust/seal";
|
|
34
|
+
import { readTrustPolicy, type TrustPolicy } from "../workspace/trust/policy";
|
|
35
|
+
import { gitRevisionSource } from "../workspace/record-source";
|
|
36
|
+
import { forgePrincipalOf, ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
|
|
37
|
+
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
38
|
+
import type { GateApprovalRule } from "./gate";
|
|
39
|
+
|
|
40
|
+
export { GATE_APPROVAL_SOURCES, type GateApprovalSource } from "./op-waves";
|
|
41
|
+
|
|
42
|
+
// ── sealed ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** The signers policy at `base`, read from the repository at `cwd`. */
|
|
45
|
+
export function trustPolicyAt(cwd: string, base: string): TrustPolicy {
|
|
46
|
+
return readTrustPolicy(gitRevisionSource(cwd, base), base);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The rule a `sealed` gate judges approvals by: a seal that verifies for the
|
|
51
|
+
* approver against `policy`, the signers at base. With no signers file at
|
|
52
|
+
* base nothing verifies, so nothing passes the gate.
|
|
53
|
+
*/
|
|
54
|
+
export function sealedApprovalRule(gate: string, policy: TrustPolicy): GateApprovalRule {
|
|
55
|
+
return {
|
|
56
|
+
requirement: { gate, class: null },
|
|
57
|
+
refuses(approval: GateResolutionRecord): string | null {
|
|
58
|
+
if (!policy.active) {
|
|
59
|
+
return `the gate is sealed, and there is no signers file (${policy.signersPath}) at base ${policy.base?.slice(0, 12) ?? ""}`.trim();
|
|
60
|
+
}
|
|
61
|
+
const check = checkGateSeal(policy, approval);
|
|
62
|
+
return check.attested === true ? null : check.message;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Both rules at once: an approval counts only when neither refuses it. */
|
|
68
|
+
export function bothRules(a: GateApprovalRule | null, b: GateApprovalRule | null): GateApprovalRule | null {
|
|
69
|
+
if (!a) return b;
|
|
70
|
+
if (!b) return a;
|
|
71
|
+
return { requirement: a.requirement, refuses: (r) => a.refuses(r) ?? b.refuses(r) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── pr-review ─────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** The digests each wave planned at one commit, recorded for a review of it to approve. */
|
|
77
|
+
export interface HeadPlans {
|
|
78
|
+
version: 1;
|
|
79
|
+
/** The Op waves spec's name. */
|
|
80
|
+
name: string;
|
|
81
|
+
/** The commit the waves were planned at: a pull request's head. */
|
|
82
|
+
head: string;
|
|
83
|
+
timestamp: string;
|
|
84
|
+
waves: Array<{ wave: number; name: string; digest: string }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Where {@link HeadPlans} for `head` live on `chant/lifecycle`: `_wave-plans/<name>/<head>.json`. */
|
|
88
|
+
export const HEAD_PLANS_DIR = "_wave-plans";
|
|
89
|
+
|
|
90
|
+
export function headPlansPath(name: string, head: string): { dir: string; file: string } {
|
|
91
|
+
return { dir: `${HEAD_PLANS_DIR}/${name}`, file: `${head}.json` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The merged pull request's review, as a `pr-review` gate reads it. */
|
|
95
|
+
export interface MergedReview {
|
|
96
|
+
pr: number;
|
|
97
|
+
url?: string;
|
|
98
|
+
/** The author, as a principal (`github:<login>`). */
|
|
99
|
+
author: string;
|
|
100
|
+
/** The pull request's head commit. */
|
|
101
|
+
head: string;
|
|
102
|
+
/** Writers other than the author whose approval of the head stands, as principals. */
|
|
103
|
+
approvers: string[];
|
|
104
|
+
/** Why a review did not count, one line each. */
|
|
105
|
+
refused: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What a `pr-review` gate asks of the forge. */
|
|
109
|
+
export interface ReviewSource {
|
|
110
|
+
kind: ForgeKind;
|
|
111
|
+
/** The merged pull request whose merge produced `sha`, with its review, or null when none did. */
|
|
112
|
+
mergedReview(sha: string): Promise<MergedReview | null>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
116
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
117
|
+
|
|
118
|
+
function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
|
|
119
|
+
return async (path: string): Promise<unknown> => {
|
|
120
|
+
const url = `${trimSlash(base)}${path}`;
|
|
121
|
+
const res = await fetchImpl(url, { method: "GET", headers });
|
|
122
|
+
const text = await res.text();
|
|
123
|
+
if (!res.ok) throw new ForgeApiError("GET", url, res.status, text);
|
|
124
|
+
return text ? JSON.parse(text) : null;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hostOf = (url: string | undefined, fallback: string): string => {
|
|
129
|
+
try {
|
|
130
|
+
return url ? new URL(url).host.toLowerCase() : fallback;
|
|
131
|
+
} catch {
|
|
132
|
+
return fallback;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export interface ReviewSourceOptions {
|
|
137
|
+
apiBase: string;
|
|
138
|
+
/** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
|
|
139
|
+
repo: string;
|
|
140
|
+
token: string;
|
|
141
|
+
/** The forge's host, for naming people. */
|
|
142
|
+
host: string;
|
|
143
|
+
fetch?: ForgeFetch;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** GitHub or Forgejo, which share the pull request and review shapes. */
|
|
147
|
+
function githubShapedReviews(kind: "github" | "forgejo", opts: ReviewSourceOptions): ReviewSource {
|
|
148
|
+
const get = client(
|
|
149
|
+
opts.apiBase,
|
|
150
|
+
kind === "github"
|
|
151
|
+
? { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" }
|
|
152
|
+
: { Authorization: `token ${opts.token}`, Accept: "application/json" },
|
|
153
|
+
opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch),
|
|
154
|
+
);
|
|
155
|
+
const repo = `/repos/${opts.repo}`;
|
|
156
|
+
const who = (login: string) => forgePrincipalOf(kind, opts.host, login);
|
|
157
|
+
const page = kind === "github" ? "per_page=100" : "limit=50";
|
|
158
|
+
async function prFor(sha: string): Promise<number | null> {
|
|
159
|
+
if (kind === "forgejo") {
|
|
160
|
+
try {
|
|
161
|
+
const pr = await get(`${repo}/commits/${sha}/pull`);
|
|
162
|
+
return isObject(pr) && typeof pr.number === "number" ? pr.number : null;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof ForgeApiError && err.status === 404) return null;
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const prs = await get(`${repo}/commits/${sha}/pulls`);
|
|
169
|
+
if (!Array.isArray(prs)) return null;
|
|
170
|
+
const merged = prs.filter((p) => isObject(p) && p.merged_at);
|
|
171
|
+
const pick = (merged.find((p) => isObject(p) && p.merge_commit_sha === sha) ?? merged[0]) as Record<string, unknown> | undefined;
|
|
172
|
+
return pick && typeof pick.number === "number" ? pick.number : null;
|
|
173
|
+
}
|
|
174
|
+
async function writer(login: string): Promise<boolean> {
|
|
175
|
+
const p = await get(`${repo}/collaborators/${encodeURIComponent(login)}/permission`);
|
|
176
|
+
return isObject(p) && ["admin", "maintain", "write", "owner"].includes(String(p.role_name ?? p.permission));
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
kind,
|
|
180
|
+
async mergedReview(sha) {
|
|
181
|
+
const number = await prFor(sha);
|
|
182
|
+
if (number === null) return null;
|
|
183
|
+
const pr = await get(`${repo}/pulls/${number}`);
|
|
184
|
+
if (!isObject(pr) || !isObject(pr.user) || !isObject(pr.head)) throw new Error(`pull request #${number} came back without its author or head`);
|
|
185
|
+
const authorLogin = String(pr.user.login);
|
|
186
|
+
const head = String(pr.head.sha);
|
|
187
|
+
const reviews = await get(`${repo}/pulls/${number}/reviews?${page}`);
|
|
188
|
+
// A reviewer's newest deciding review is their standing one.
|
|
189
|
+
const standing = new Map<string, Record<string, unknown>>();
|
|
190
|
+
for (const r of Array.isArray(reviews) ? reviews : []) {
|
|
191
|
+
if (!isObject(r) || !isObject(r.user) || typeof r.state !== "string") continue;
|
|
192
|
+
const state = r.state.toUpperCase();
|
|
193
|
+
if (state === "COMMENTED" || state === "COMMENT" || state === "PENDING") continue;
|
|
194
|
+
standing.set(String(r.user.login), r);
|
|
195
|
+
}
|
|
196
|
+
const approvers: string[] = [];
|
|
197
|
+
const refused: string[] = [];
|
|
198
|
+
let changesRequested = false;
|
|
199
|
+
for (const [login, r] of standing) {
|
|
200
|
+
const state = r.dismissed === true ? "DISMISSED" : String(r.state).toUpperCase();
|
|
201
|
+
if (login.toLowerCase() === authorLogin.toLowerCase()) {
|
|
202
|
+
if (state === "APPROVED") refused.push(`${who(login)} is the author, whose own review never counts`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const isWriter = kind === "forgejo" ? r.official === true : await writer(login);
|
|
206
|
+
if (state === "REQUEST_CHANGES" || state === "CHANGES_REQUESTED") {
|
|
207
|
+
if (isWriter) changesRequested = true;
|
|
208
|
+
refused.push(`${who(login)} requests changes`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (state !== "APPROVED") continue;
|
|
212
|
+
if (!isWriter) refused.push(`${who(login)} approved without write access${kind === "forgejo" ? " (the review is not official)" : ""}`);
|
|
213
|
+
else if (r.commit_id !== head || r.stale === true) refused.push(`${who(login)} approved ${String(r.commit_id ?? "another commit").slice(0, 12)}, not the head ${head.slice(0, 12)}`);
|
|
214
|
+
else approvers.push(who(login));
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
pr: number,
|
|
218
|
+
...(typeof pr.html_url === "string" ? { url: pr.html_url } : {}),
|
|
219
|
+
author: who(authorLogin),
|
|
220
|
+
head,
|
|
221
|
+
approvers: changesRequested ? [] : approvers.sort(),
|
|
222
|
+
refused,
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A GitHub reader of pull request reviews. The token needs `pull-requests: read`. */
|
|
229
|
+
export function githubReviews(opts: ReviewSourceOptions): ReviewSource {
|
|
230
|
+
return githubShapedReviews("github", opts);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A Forgejo (or Gitea) reader of pull request reviews. */
|
|
234
|
+
export function forgejoReviews(opts: ReviewSourceOptions): ReviewSource {
|
|
235
|
+
return githubShapedReviews("forgejo", opts);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A GitLab reader of merge request approvals. The token needs `read_api`. */
|
|
239
|
+
export function gitlabReviews(opts: ReviewSourceOptions): ReviewSource {
|
|
240
|
+
const get = client(opts.apiBase, { "PRIVATE-TOKEN": opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
|
|
241
|
+
const project = `/projects/${encodeURIComponent(decodeURIComponent(opts.repo))}`;
|
|
242
|
+
const who = (login: string) => forgePrincipalOf("gitlab", opts.host, login);
|
|
243
|
+
return {
|
|
244
|
+
kind: "gitlab",
|
|
245
|
+
async mergedReview(sha) {
|
|
246
|
+
const mrs = await get(`${project}/repository/commits/${sha}/merge_requests`);
|
|
247
|
+
const merged = Array.isArray(mrs)
|
|
248
|
+
? mrs.find((m) => isObject(m) && m.state === "merged" && (m.merge_commit_sha === sha || m.squash_commit_sha === sha)) ??
|
|
249
|
+
mrs.find((m) => isObject(m) && m.state === "merged")
|
|
250
|
+
: undefined;
|
|
251
|
+
if (!isObject(merged) || typeof merged.iid !== "number") return null;
|
|
252
|
+
const iid = merged.iid;
|
|
253
|
+
const author = isObject(merged.author) ? String(merged.author.username) : "";
|
|
254
|
+
const head = String(merged.sha);
|
|
255
|
+
const settings = await get(`${project}/approvals`);
|
|
256
|
+
const resets = isObject(settings) && settings.reset_approvals_on_push === true;
|
|
257
|
+
const approvals = await get(`${project}/merge_requests/${iid}/approvals`);
|
|
258
|
+
const by = isObject(approvals) && Array.isArray(approvals.approved_by) ? approvals.approved_by : [];
|
|
259
|
+
const approvers: string[] = [];
|
|
260
|
+
const refused: string[] = [];
|
|
261
|
+
for (const a of by) {
|
|
262
|
+
const user = isObject(a) && isObject(a.user) ? a.user : undefined;
|
|
263
|
+
if (!user || typeof user.username !== "string") continue;
|
|
264
|
+
const login = user.username;
|
|
265
|
+
if (login.toLowerCase() === author.toLowerCase()) {
|
|
266
|
+
refused.push(`${who(login)} is the author, whose own approval never counts`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!resets) {
|
|
270
|
+
refused.push(`${who(login)}'s approval names no commit, and the project keeps approvals after a new push (turn on "Remove all approvals when commits are added")`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const member = await get(`${project}/members/all/${String(user.id)}`).catch((err) => {
|
|
274
|
+
if (err instanceof ForgeApiError && err.status === 404) return null;
|
|
275
|
+
throw err;
|
|
276
|
+
});
|
|
277
|
+
if (!isObject(member) || typeof member.access_level !== "number" || member.access_level < 30) {
|
|
278
|
+
refused.push(`${who(login)} approved without the Developer role`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
approvers.push(who(login));
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
pr: iid,
|
|
285
|
+
...(typeof merged.web_url === "string" ? { url: merged.web_url } : {}),
|
|
286
|
+
author: who(author),
|
|
287
|
+
head,
|
|
288
|
+
approvers: approvers.sort(),
|
|
289
|
+
refused,
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The review source for the CI job this runs in: GitLab CI, Forgejo Actions
|
|
297
|
+
* or GitHub Actions, told apart as `./gate-resume.ts` does. The token is
|
|
298
|
+
* `CHANT_FORGE_TOKEN`, else `GITHUB_TOKEN`/`GH_TOKEN` or `GITLAB_TOKEN`.
|
|
299
|
+
* Throws naming what is missing.
|
|
300
|
+
*/
|
|
301
|
+
export function reviewSourceFromEnv(env: Record<string, string | undefined> = process.env, fetchImpl?: ForgeFetch): ReviewSource {
|
|
302
|
+
const f = fetchImpl ? { fetch: fetchImpl } : {};
|
|
303
|
+
if (env.GITLAB_CI === "true") {
|
|
304
|
+
const token = env.CHANT_FORGE_TOKEN ?? env.GITLAB_TOKEN;
|
|
305
|
+
if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !token) {
|
|
306
|
+
throw new Error("a pr-review gate on GitLab needs CI_API_V4_URL, CI_PROJECT_ID and CHANT_FORGE_TOKEN (a token with read_api)");
|
|
307
|
+
}
|
|
308
|
+
return gitlabReviews({ apiBase: env.CI_API_V4_URL, repo: env.CI_PROJECT_ID, token, host: env.CI_SERVER_HOST ?? hostOf(env.CI_SERVER_URL, "gitlab.com"), ...f });
|
|
309
|
+
}
|
|
310
|
+
const token = env.CHANT_FORGE_TOKEN ?? env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
311
|
+
if (!env.GITHUB_REPOSITORY || !token) {
|
|
312
|
+
throw new Error("a pr-review gate needs GITHUB_REPOSITORY and a token (GITHUB_TOKEN with pull-requests: read, or CHANT_FORGE_TOKEN)");
|
|
313
|
+
}
|
|
314
|
+
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
|
|
315
|
+
const host = hostOf(env.GITHUB_SERVER_URL, "github.com");
|
|
316
|
+
const opts = { apiBase: env.GITHUB_API_URL ?? (forgejo ? `${trimSlash(env.GITHUB_SERVER_URL ?? "")}/api/v1` : "https://api.github.com"), repo: env.GITHUB_REPOSITORY, token, host, ...f };
|
|
317
|
+
return forgejo ? forgejoReviews(opts) : githubReviews(opts);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The head of the pull request a CI job runs for, from its environment: the
|
|
322
|
+
* event payload's `pull_request.head.sha` on GitHub and Forgejo (whose
|
|
323
|
+
* checkout is a merge preview, not the head), and on GitLab the merge
|
|
324
|
+
* request's source commit. Undefined outside a pull request's pipeline.
|
|
325
|
+
*/
|
|
326
|
+
export function pullRequestHeadFromEnv(env: Record<string, string | undefined> = process.env): string | undefined {
|
|
327
|
+
if (env.GITLAB_CI === "true") {
|
|
328
|
+
if (!env.CI_MERGE_REQUEST_IID) return undefined;
|
|
329
|
+
return env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA || env.CI_COMMIT_SHA || undefined;
|
|
330
|
+
}
|
|
331
|
+
if (!env.GITHUB_EVENT_PATH) return undefined;
|
|
332
|
+
try {
|
|
333
|
+
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8")) as { pull_request?: { head?: { sha?: unknown } } };
|
|
334
|
+
const sha = event.pull_request?.head?.sha;
|
|
335
|
+
return typeof sha === "string" && sha !== "" ? sha : undefined;
|
|
336
|
+
} catch {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
}
|