@intentius/chant 0.124.41 → 0.124.43
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/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +3 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -3
- package/dist/cli/handlers/pr.d.ts +3 -1
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +113 -2
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +11 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +8 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +15 -0
- package/dist/cli/registry.d.ts +19 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability.d.ts +47 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +44 -0
- package/dist/components/cli-support.d.ts +6 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +8 -1
- package/dist/components/driver.d.ts +10 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +52 -2
- package/dist/components/fan-out-run.d.ts +9 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +17 -1
- package/dist/components/gate-plan.d.ts +2 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +1 -0
- package/dist/components/pr-pipeline.d.ts +20 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +54 -16
- package/dist/components/pr-run.d.ts +6 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +3 -0
- package/dist/components/wave-plan.d.ts +14 -1
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +34 -2
- package/dist/op/activities/pr-refs.d.ts +10 -0
- package/dist/op/activities/pr-refs.d.ts.map +1 -0
- package/dist/op/activities/pr-refs.js +26 -0
- package/dist/op/activities/predict-behaviour.d.ts +2 -4
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.js +2 -21
- package/dist/op/activities/review-agent.js +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/op-waves-run.d.ts +3 -79
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +4 -133
- package/dist/op/stage-hooks.d.ts +152 -0
- package/dist/op/stage-hooks.d.ts.map +1 -0
- package/dist/op/stage-hooks.js +199 -0
- package/dist/pr-forge.d.ts +56 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +320 -45
- package/dist/pr-loop.d.ts +18 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- package/dist/pr-requires.d.ts +47 -0
- package/dist/pr-requires.d.ts.map +1 -0
- package/dist/pr-requires.js +53 -0
- package/dist/wave-runtime.mjs +213 -213
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +19 -4
- package/dist/workspace/ci-init.d.ts.map +1 -1
- package/dist/workspace/ci-init.js +5 -4
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-workflow-at.d.ts +14 -0
- package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
- package/dist/workspace/ci-workflow-at.js +17 -0
- package/dist/workspace/ci-workflow.d.ts +11 -3
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +10 -2
- package/dist/workspace/declaration.d.ts +6 -3
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +2 -2
- package/dist/workspace/pr-report.schema.json +23 -3
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/build.ts +3 -0
- package/src/cli/handlers/fan-out.ts +27 -3
- package/src/cli/handlers/pr-before-merge.test.ts +276 -0
- package/src/cli/handlers/pr.ts +110 -2
- package/src/cli/handlers/run.ts +10 -0
- package/src/cli/main.ts +8 -2
- package/src/cli/parse-args.ts +11 -0
- package/src/cli/registry.ts +19 -0
- package/src/components/capability-targets.test.ts +162 -0
- package/src/components/capability.ts +71 -0
- package/src/components/cli-support.ts +14 -1
- package/src/components/driver.ts +54 -3
- package/src/components/fan-out-run.ts +19 -1
- package/src/components/gate-plan.ts +3 -0
- package/src/components/pr-pipeline.ts +64 -16
- package/src/components/pr-run.ts +9 -0
- package/src/components/wave-plan.ts +38 -3
- package/src/op/activities/pr-refs.ts +28 -0
- package/src/op/activities/predict-behaviour.ts +3 -23
- package/src/op/activities/review-agent.ts +1 -1
- package/src/op/index.ts +4 -0
- package/src/op/op-waves-run.ts +24 -228
- package/src/op/stage-hooks-async.test.ts +79 -0
- package/src/op/stage-hooks.ts +346 -0
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.ts +334 -36
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.ts +25 -1
- package/src/pr-requires.test.ts +248 -0
- package/src/pr-requires.ts +93 -0
- package/src/review-agent-bundle.test.ts +35 -0
- package/src/stage-hooks-bundle.test.ts +38 -0
- package/src/workspace/checks/ci-workflow.test.ts +9 -0
- package/src/workspace/checks/ci-workflow.ts +18 -4
- package/src/workspace/ci-init.ts +5 -4
- package/src/workspace/ci-run.ts +2 -1
- package/src/workspace/ci-workflow-at.ts +23 -0
- package/src/workspace/ci-workflow.ts +13 -2
- package/src/workspace/declaration.schema.json +2 -2
- package/src/workspace/declaration.ts +6 -3
- package/src/workspace/pr-report.schema.json +23 -3
- package/src/workspace/reason-codes.ts +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The requires checks of an apply before merge (#4301, ws-106): the four
|
|
3
|
+
* PrForge methods on GitHub, Forgejo and GitLab, driven through a recording
|
|
4
|
+
* fake forge, and checkPrRequires over them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { forgejoForge, githubForge, gitlabForge, type ForgeFetch, type PrForge } from "./pr-forge";
|
|
9
|
+
import { checkPrRequires, PR_APPLY_REQUIRES, unmetRequires } from "./pr-requires";
|
|
10
|
+
|
|
11
|
+
/** A fake forge: `routes` maps `METHOD path-without-query` to a body, a status, or a function of the query. */
|
|
12
|
+
function fakeFetch(routes: Record<string, unknown | ((q: URLSearchParams) => unknown)>): ForgeFetch {
|
|
13
|
+
return async (url, init) => {
|
|
14
|
+
const u = new URL(url);
|
|
15
|
+
let found = routes[`${init.method} ${u.pathname}`];
|
|
16
|
+
if (typeof found === "function") found = (found as (q: URLSearchParams) => unknown)(u.searchParams);
|
|
17
|
+
if (found === undefined) return { ok: false, status: 404, text: async () => '{"message":"Not Found"}' };
|
|
18
|
+
if (typeof found === "number") return { ok: false, status: found, text: async () => "" };
|
|
19
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(found) };
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const HEAD = "a".repeat(40);
|
|
24
|
+
const TIP = "b".repeat(40);
|
|
25
|
+
const OTHER = "c".repeat(40);
|
|
26
|
+
|
|
27
|
+
describe("github", () => {
|
|
28
|
+
const repo = "/repos/acme/infra";
|
|
29
|
+
const pull = (over: Record<string, unknown> = {}) => ({ number: 7, state: "open", user: { login: "alice" }, head: { ref: "f", sha: HEAD }, base: { ref: "main" }, mergeable: true, mergeable_state: "clean", ...over });
|
|
30
|
+
const forge = (routes: Record<string, unknown>) =>
|
|
31
|
+
githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch: fakeFetch({ [`GET ${repo}/pulls/7`]: pull(), [`GET ${repo}/pulls/7/commits`]: [{ author: { login: "alice" }, committer: { login: "alice" } }], ...routes }) });
|
|
32
|
+
|
|
33
|
+
test("approved: a reviewer other than the authors approved this head", async () => {
|
|
34
|
+
const f = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }] });
|
|
35
|
+
expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("approved: an approval of an older head, the author's own, or a request for changes does not pass", async () => {
|
|
39
|
+
const older = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: OTHER }, { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD }] });
|
|
40
|
+
expect((await older.prApproved!(7, HEAD)).passed).toBe(false);
|
|
41
|
+
const changes = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "carol" }, state: "CHANGES_REQUESTED", commit_id: HEAD }] });
|
|
42
|
+
expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("approved: forgeRule counts only writers, exclude leaves a login out, and the refusal says why (#4337)", async () => {
|
|
46
|
+
const reviews = [
|
|
47
|
+
{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD },
|
|
48
|
+
{ user: { login: "Carol" }, state: "APPROVED", commit_id: HEAD },
|
|
49
|
+
{ user: { login: "alice" }, state: "APPROVED", commit_id: HEAD },
|
|
50
|
+
];
|
|
51
|
+
const f = forge({
|
|
52
|
+
[`GET ${repo}/pulls/7/reviews`]: reviews,
|
|
53
|
+
[`GET ${repo}/collaborators/bob/permission`]: { permission: "read", role_name: "triage" },
|
|
54
|
+
[`GET ${repo}/collaborators/Carol/permission`]: { permission: "write", role_name: "maintain" },
|
|
55
|
+
});
|
|
56
|
+
expect(await f.prApproved!(7, HEAD, { forgeRule: true })).toEqual({ passed: true, reason: "approved at aaaaaaaa by Carol" });
|
|
57
|
+
expect(await f.prApproved!(7, HEAD, { forgeRule: true, exclude: [{ login: "carol", reason: "carol asked for aaaaaaa" }] })).toEqual({
|
|
58
|
+
passed: false,
|
|
59
|
+
reason: "no reviewer other than its authors approved its head aaaaaaaa: carol asked for aaaaaaa; alice is one of its authors; bob has no write access",
|
|
60
|
+
});
|
|
61
|
+
expect((await f.prApproved!(7, HEAD, { exclude: ["bob"] })).reason).toBe("approved at aaaaaaaa by Carol");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("every check fails when the forge's head is another commit", async () => {
|
|
65
|
+
const f = forge({ [`GET ${repo}/pulls/7`]: pull({ head: { ref: "f", sha: OTHER } }) });
|
|
66
|
+
const r = await f.prMergeable!(7, HEAD);
|
|
67
|
+
expect(r.passed).toBe(false);
|
|
68
|
+
expect(r.reason).toContain("head is cccccccc, not aaaaaaaa");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("mergeable: conflicts and branch protection refuse, an unknown answer is pending", async () => {
|
|
72
|
+
expect((await forge({}).prMergeable!(7, HEAD)).passed).toBe(true);
|
|
73
|
+
expect((await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable: false, mergeable_state: "dirty" }) }).prMergeable!(7, HEAD)).reason).toContain("conflicts with main");
|
|
74
|
+
expect((await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable_state: "blocked" }) }).prMergeable!(7, HEAD)).reason).toContain("branch protection");
|
|
75
|
+
expect(await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable: null }) }).prMergeable!(7, HEAD)).toMatchObject({ passed: false, pending: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("undiverged: reads behind_by from the compare of the base branch with the head", async () => {
|
|
79
|
+
expect((await forge({ [`GET ${repo}/compare/main...${HEAD}`]: { behind_by: 0 } }).prUndiverged!(7, HEAD)).passed).toBe(true);
|
|
80
|
+
expect((await forge({ [`GET ${repo}/compare/main...${HEAD}`]: { behind_by: 2 } }).prUndiverged!(7, HEAD)).reason).toBe("aaaaaaaa is 2 commits behind main");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("checks: statuses and check runs, the apply's own left out", async () => {
|
|
84
|
+
const statuses = (state: string) => ({ statuses: [{ context: "ci/test", state }, { context: "chant/apply", state: "failure" }] });
|
|
85
|
+
const runs = (conclusion: string | null, status = "completed") => ({ check_runs: [{ name: "lint", status, conclusion }] });
|
|
86
|
+
const f = (state: string, run: unknown) => forge({ [`GET ${repo}/commits/${HEAD}/status`]: statuses(state), [`GET ${repo}/commits/${HEAD}/check-runs`]: run });
|
|
87
|
+
expect(await f("success", runs("success")).prChecksPassed!(7, HEAD, ["chant/apply"])).toEqual({ passed: true, reason: "2 checks passed on aaaaaaaa" });
|
|
88
|
+
expect((await f("success", runs("success")).prChecksPassed!(7, HEAD)).reason).toBe("chant/apply failed on aaaaaaaa");
|
|
89
|
+
expect(await f("pending", runs("success")).prChecksPassed!(7, HEAD, ["chant/apply"])).toMatchObject({ passed: false, pending: true });
|
|
90
|
+
expect((await f("success", runs(null, "in_progress")).prChecksPassed!(7, HEAD, ["chant/apply"])).pending).toBe(true);
|
|
91
|
+
expect((await f("success", runs("failure")).prChecksPassed!(7, HEAD, ["chant/apply"])).reason).toBe("lint failed on aaaaaaaa");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("checks: nothing reported is pending", async () => {
|
|
95
|
+
const f = forge({ [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [] }, [`GET ${repo}/commits/${HEAD}/check-runs`]: { check_runs: [] } });
|
|
96
|
+
expect(await f.prChecksPassed!(7, HEAD)).toMatchObject({ passed: false, pending: true });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("forgejo", () => {
|
|
101
|
+
const repo = "/api/v1/repos/acme/infra";
|
|
102
|
+
const pull = (over: Record<string, unknown> = {}) => ({ number: 4, user: { login: "alice" }, head: { ref: "f", sha: HEAD }, base: { ref: "main" }, mergeable: true, merge_base: TIP, ...over });
|
|
103
|
+
const forge = (routes: Record<string, unknown>) =>
|
|
104
|
+
forgejoForge({ apiBase: "https://code.example/api/v1", repo: "acme/infra", host: "code.example", token: "t", fetch: fakeFetch({ [`GET ${repo}/pulls/4`]: pull(), [`GET ${repo}/pulls/4/commits`]: [], [`GET ${repo}/branches/main`]: { commit: { id: TIP } }, ...routes }) });
|
|
105
|
+
|
|
106
|
+
test("approved: Forgejo's REQUEST_CHANGES state refuses", async () => {
|
|
107
|
+
const f = forge({ [`GET ${repo}/pulls/4/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "dave" }, state: "REQUEST_CHANGES" }] });
|
|
108
|
+
expect((await f.prApproved!(4, HEAD)).reason).toBe("dave asked for changes");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("approved: forgeRule counts an official review only, through checkPrRequires' approvers (#4337)", async () => {
|
|
112
|
+
const f = forge({ [`GET ${repo}/pulls/4/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD, official: false }, { user: { login: "erin" }, state: "APPROVED", commit_id: HEAD, official: true }] });
|
|
113
|
+
expect((await f.prApproved!(4, HEAD, { forgeRule: true })).reason).toBe("approved at aaaaaaaa by erin");
|
|
114
|
+
const out = await checkPrRequires(f, 4, HEAD, { only: ["approved"], approvers: { forgeRule: true, exclude: ["erin"] } });
|
|
115
|
+
expect(out[0]!.reason).toBe("no reviewer other than its authors approved its head aaaaaaaa: bob's review is not official; erin is excluded");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("undiverged: the pull request's merge base is the base branch's tip", async () => {
|
|
119
|
+
expect((await forge({}).prUndiverged!(4, HEAD)).passed).toBe(true);
|
|
120
|
+
expect((await forge({ [`GET ${repo}/pulls/4`]: pull({ merge_base: OTHER }) }).prUndiverged!(4, HEAD)).reason).toBe("aaaaaaaa does not contain main at bbbbbbbb");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("mergeable: a false while Forgejo still checks is pending, and passes once it settles (#4335)", async () => {
|
|
124
|
+
expect(await forge({ [`GET ${repo}/pulls/4`]: pull({ mergeable: false }) }).prMergeable!(4, HEAD)).toMatchObject({ passed: false, pending: true });
|
|
125
|
+
expect(await forge({ [`GET ${repo}/pulls/4`]: pull({ mergeable: false, draft: true }) }).prMergeable!(4, HEAD)).toEqual({ passed: false, reason: "it is a draft" });
|
|
126
|
+
let reads = 0;
|
|
127
|
+
const flips = forge({ [`GET ${repo}/pulls/4`]: () => pull({ mergeable: ++reads > 2 }), [`GET ${repo}/pulls/4/reviews`]: [], [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [{ context: "ci/test", status: "success" }] } });
|
|
128
|
+
const out = await checkPrRequires(flips, 4, HEAD, { wait: async () => {} });
|
|
129
|
+
expect(out.find((o) => o.name === "mergeable")).toEqual({ name: "mergeable", passed: true, reason: "it merges into main as it stands" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("mergeable: no branch protection reading, and checks from the combined status alone", async () => {
|
|
133
|
+
expect((await forge({ [`GET ${repo}/pulls/4`]: pull({ mergeable_state: "blocked" }) }).prMergeable!(4, HEAD)).passed).toBe(true);
|
|
134
|
+
const f = forge({ [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [{ context: "ci/test", status: "success" }] } });
|
|
135
|
+
expect(await f.prChecksPassed!(4, HEAD)).toEqual({ passed: true, reason: "1 check passed on aaaaaaaa" });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("gitlab", () => {
|
|
140
|
+
const project = "/api/v4/projects/9";
|
|
141
|
+
const mr = (over: Record<string, unknown> = {}) => ({ iid: 3, sha: HEAD, author: { username: "alice" }, target_branch: "main", detailed_merge_status: "mergeable", diverged_commits_count: 0, ...over });
|
|
142
|
+
const note = (who: string, body: string, at: string) => ({ system: true, author: { username: who }, body, created_at: at });
|
|
143
|
+
const forge = (routes: Record<string, unknown>) =>
|
|
144
|
+
gitlabForge({
|
|
145
|
+
apiBase: "https://gitlab.example/api/v4",
|
|
146
|
+
project: "9",
|
|
147
|
+
token: "t",
|
|
148
|
+
host: "gitlab.example",
|
|
149
|
+
fetch: fakeFetch({
|
|
150
|
+
[`GET ${project}/merge_requests/3`]: mr(),
|
|
151
|
+
[`GET ${project}/merge_requests/3/commits`]: [],
|
|
152
|
+
"GET /api/v4/user": { id: 1, username: "chant-bot" },
|
|
153
|
+
[`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z" }],
|
|
154
|
+
...routes,
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("approved: an approval after the latest push, not the token user's or the author's", async () => {
|
|
159
|
+
const f = forge({
|
|
160
|
+
[`GET ${project}/merge_requests/3/notes`]: [
|
|
161
|
+
note("erin", "approved this merge request", "2026-10-01T09:00:00Z"),
|
|
162
|
+
note("chant-bot", "approved this merge request", "2026-10-01T11:00:00Z"),
|
|
163
|
+
note("alice", "approved this merge request", "2026-10-01T11:00:00Z"),
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
expect((await f.prApproved!(3, HEAD)).passed).toBe(false);
|
|
167
|
+
const g = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z")] });
|
|
168
|
+
expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob" });
|
|
169
|
+
const h = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z"), note("carol", "requested changes", "2026-10-01T12:00:00Z")] });
|
|
170
|
+
expect((await h.prApproved!(3, HEAD)).reason).toBe("carol requested changes");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("approved: forgeRule reads each approver's access level, Developer by default (#4337)", async () => {
|
|
174
|
+
const at = "2026-10-01T11:00:00Z";
|
|
175
|
+
const f = forge({
|
|
176
|
+
[`GET ${project}/merge_requests/3/notes`]: [
|
|
177
|
+
{ ...note("bob", "approved this merge request", at), author: { username: "bob", id: 5 } },
|
|
178
|
+
{ ...note("dan", "approved this merge request", at), author: { username: "dan", id: 6 } },
|
|
179
|
+
{ ...note("eve", "approved this merge request", at), author: { username: "eve", id: 7 } },
|
|
180
|
+
],
|
|
181
|
+
[`GET ${project}/members/all/5`]: { access_level: 20 },
|
|
182
|
+
[`GET ${project}/members/all/6`]: { access_level: 40 },
|
|
183
|
+
});
|
|
184
|
+
expect((await f.prApproved!(3, HEAD, { forgeRule: true })).reason).toBe("approved after aaaaaaaa by dan");
|
|
185
|
+
expect((await f.prApproved!(3, HEAD, { forgeRule: true, minAccessLevel: 50 })).reason).toBe(
|
|
186
|
+
"no member other than its authors approved it after its latest push, aaaaaaaa: bob has access level 20, under 50; dan has access level 40, under 50; eve is not a member",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("mergeable and undiverged read the merge request", async () => {
|
|
191
|
+
expect((await forge({}).prMergeable!(3, HEAD)).passed).toBe(true);
|
|
192
|
+
expect(await forge({ [`GET ${project}/merge_requests/3`]: mr({ detailed_merge_status: "checking" }) }).prMergeable!(3, HEAD)).toMatchObject({ passed: false, pending: true });
|
|
193
|
+
expect((await forge({ [`GET ${project}/merge_requests/3`]: mr({ has_conflicts: true, detailed_merge_status: "conflict" }) }).prMergeable!(3, HEAD)).reason).toContain("conflicts");
|
|
194
|
+
expect((await forge({}).prUndiverged!(3, HEAD)).passed).toBe(true);
|
|
195
|
+
expect((await forge({ [`GET ${project}/merge_requests/3`]: mr({ diverged_commits_count: 1 }) }).prUndiverged!(3, HEAD)).reason).toBe("aaaaaaaa is 1 commit behind main");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("checks: each name's newest status, a job allowed to fail holding nothing back", async () => {
|
|
199
|
+
const f = forge({
|
|
200
|
+
[`GET ${project}/repository/commits/${HEAD}/statuses`]: [
|
|
201
|
+
{ id: 1, name: "test", status: "failed" },
|
|
202
|
+
{ id: 2, name: "test", status: "success" },
|
|
203
|
+
{ id: 3, name: "flaky", status: "failed", allow_failure: true },
|
|
204
|
+
{ id: 4, name: "apply", status: "running" },
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
expect(await f.prChecksPassed!(3, HEAD, ["apply"])).toEqual({ passed: true, reason: "2 checks passed on aaaaaaaa" });
|
|
208
|
+
expect((await f.prChecksPassed!(3, HEAD)).pending).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("checkPrRequires", () => {
|
|
213
|
+
const ok = { passed: true, reason: "ok" };
|
|
214
|
+
test("asks each check once in order, and a pending one again until it settles", async () => {
|
|
215
|
+
let reads = 0;
|
|
216
|
+
const forge = {
|
|
217
|
+
kind: "github",
|
|
218
|
+
prApproved: async () => ok,
|
|
219
|
+
prMergeable: async () => (++reads < 3 ? { passed: false, pending: true, reason: "working it out" } : ok),
|
|
220
|
+
prUndiverged: async () => ok,
|
|
221
|
+
prChecksPassed: async (_pr: number, _head: string, ignore?: readonly string[]) => ({ passed: true, reason: `ignored ${ignore?.join(",")}` }),
|
|
222
|
+
} as unknown as PrForge;
|
|
223
|
+
const out = await checkPrRequires(forge, 7, HEAD, { ignore: ["chant/apply"], wait: async () => {} });
|
|
224
|
+
expect(out.map((o) => o.name)).toEqual([...PR_APPLY_REQUIRES]);
|
|
225
|
+
expect(reads).toBe(3);
|
|
226
|
+
expect(out[3]!.reason).toBe("ignored chant/apply");
|
|
227
|
+
expect(unmetRequires(out)).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("only: a check left out passes as not required and asks the forge nothing (#4336)", async () => {
|
|
231
|
+
const calls: string[] = [];
|
|
232
|
+
const ask = (name: string) => async () => (calls.push(name), { passed: false, reason: `${name} unmet` });
|
|
233
|
+
const forge = { kind: "github", prApproved: ask("approved"), prMergeable: ask("mergeable"), prUndiverged: ask("undiverged"), prChecksPassed: ask("checks") } as unknown as PrForge;
|
|
234
|
+
const out = await checkPrRequires(forge, 7, HEAD, { only: ["approved", "checks"], reads: 1 });
|
|
235
|
+
expect(calls).toEqual(["approved", "checks"]);
|
|
236
|
+
expect(out.map((o) => o.name)).toEqual([...PR_APPLY_REQUIRES]);
|
|
237
|
+
expect(out[1]).toEqual({ name: "mergeable", passed: true, reason: "not required" });
|
|
238
|
+
expect(unmetRequires(out)).toBe("approved: approved unmet; checks: checks unmet");
|
|
239
|
+
expect(unmetRequires(await checkPrRequires(forge, 7, HEAD, { only: [] }))).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("a client without a method, or a call that throws, is unmet", async () => {
|
|
243
|
+
const forge = { kind: "gitlab", prApproved: async () => { throw new Error("401"); } } as unknown as PrForge;
|
|
244
|
+
const out = await checkPrRequires(forge, 3, HEAD, { reads: 1 });
|
|
245
|
+
expect(out.every((o) => !o.passed)).toBe(true);
|
|
246
|
+
expect(unmetRequires(out)).toBe("approved: could not ask gitlab: 401; mergeable: this gitlab client cannot say; undiverged: this gitlab client cannot say; checks: this gitlab client cannot say");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The requires checks of an apply before merge (#4301, ws-106): the pull
|
|
3
|
+
* request is approved, mergeable, undiverged and its checks passed, each
|
|
4
|
+
* asked of the forge through its {@link PrForge} method. All four must pass.
|
|
5
|
+
*
|
|
6
|
+
* The checks are terragucci's `apply.requires` (comment-apply.ts:781-860 at
|
|
7
|
+
* INTENTIUS/terragucci e6cb431). A product may leave one out with `only`
|
|
8
|
+
* (#4336); chant's own `pr-apply --before-merge` passes no `only`, so it asks
|
|
9
|
+
* all four, as ws-106 requires. A check the
|
|
10
|
+
* forge has not settled, such as GitHub working out whether a pull request
|
|
11
|
+
* can merge, is asked again a few times before it counts as unmet.
|
|
12
|
+
*
|
|
13
|
+
* Like ./pr-forge.ts this imports nothing beyond it, so a product bundles it
|
|
14
|
+
* without the TypeScript toolchain.
|
|
15
|
+
*/
|
|
16
|
+
import type { PrApproverFilter, PrForge, PrRequireResult } from "./pr-forge";
|
|
17
|
+
|
|
18
|
+
/** The four checks, in the order they run and the report lists them. */
|
|
19
|
+
export const PR_APPLY_REQUIRES = ["approved", "mergeable", "undiverged", "checks"] as const;
|
|
20
|
+
|
|
21
|
+
export type PrApplyRequire = (typeof PR_APPLY_REQUIRES)[number];
|
|
22
|
+
|
|
23
|
+
/** One check as the pull-request report's `beforeMerge.requires` holds it. */
|
|
24
|
+
export interface PrRequireOutcome extends PrRequireResult {
|
|
25
|
+
name: PrApplyRequire;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PrRequiresOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The checks to ask (#4336). One left out passes with the reason "not
|
|
31
|
+
* required" and makes no forge call. Default: all four.
|
|
32
|
+
*/
|
|
33
|
+
only?: readonly PrApplyRequire[];
|
|
34
|
+
/** Who counts as an approver for `approved` (#4337). Default: anyone other than the authors. */
|
|
35
|
+
approvers?: PrApproverFilter;
|
|
36
|
+
/** Status names the `checks` require leaves out: the apply's own. */
|
|
37
|
+
ignore?: readonly string[];
|
|
38
|
+
/** How many times a pending check is asked in all. Default 5. */
|
|
39
|
+
reads?: number;
|
|
40
|
+
/** The wait between reads. Default 3 seconds. */
|
|
41
|
+
wait?: (ms: number) => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const READS = 5;
|
|
45
|
+
const WAIT_MS = 3000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ask `forge` each of the four checks for pull request `pr` at `head`. A
|
|
49
|
+
* forge client without a method, or a call that throws, gives an unmet
|
|
50
|
+
* check with the reason, so nothing applies on a check nobody made.
|
|
51
|
+
*/
|
|
52
|
+
export async function checkPrRequires(forge: PrForge, pr: number, head: string, opts: PrRequiresOptions = {}): Promise<PrRequireOutcome[]> {
|
|
53
|
+
const wait = opts.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
54
|
+
const reads = Math.max(1, opts.reads ?? READS);
|
|
55
|
+
const ask: Record<PrApplyRequire, (() => Promise<PrRequireResult>) | undefined> = {
|
|
56
|
+
approved: forge.prApproved ? () => forge.prApproved!(pr, head, opts.approvers) : undefined,
|
|
57
|
+
mergeable: forge.prMergeable ? () => forge.prMergeable!(pr, head) : undefined,
|
|
58
|
+
undiverged: forge.prUndiverged ? () => forge.prUndiverged!(pr, head) : undefined,
|
|
59
|
+
checks: forge.prChecksPassed ? () => forge.prChecksPassed!(pr, head, opts.ignore ?? []) : undefined,
|
|
60
|
+
};
|
|
61
|
+
const out: PrRequireOutcome[] = [];
|
|
62
|
+
const asked = new Set<PrApplyRequire>(opts.only ?? PR_APPLY_REQUIRES);
|
|
63
|
+
for (const name of PR_APPLY_REQUIRES) {
|
|
64
|
+
if (!asked.has(name)) {
|
|
65
|
+
out.push({ name, passed: true, reason: "not required" });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const call = ask[name];
|
|
69
|
+
if (!call) {
|
|
70
|
+
out.push({ name, passed: false, reason: `this ${forge.kind} client cannot say` });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
let result: PrRequireResult;
|
|
74
|
+
for (let read = 1; ; read++) {
|
|
75
|
+
try {
|
|
76
|
+
result = await call();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
result = { passed: false, reason: `could not ask ${forge.kind}: ${err instanceof Error ? err.message : String(err)}` };
|
|
79
|
+
}
|
|
80
|
+
if (result.passed || !result.pending || read >= reads) break;
|
|
81
|
+
await wait(WAIT_MS);
|
|
82
|
+
}
|
|
83
|
+
out.push({ name, ...result });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The unmet checks as one line, or null when all four passed. */
|
|
89
|
+
export function unmetRequires(outcomes: readonly PrRequireOutcome[]): string | null {
|
|
90
|
+
const unmet = outcomes.filter((o) => !o.passed);
|
|
91
|
+
if (unmet.length === 0) return null;
|
|
92
|
+
return unmet.map((o) => `${o.name}: ${o.reason}`).join("; ");
|
|
93
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review agent bundles alone (#4299): a product such as terragucci
|
|
3
|
+
* imports `@intentius/chant/op/activities/review-agent` into one bundled
|
|
4
|
+
* file, so it must not reach predict-behaviour's estate code. This bundles
|
|
5
|
+
* the module and reads esbuild's list of what went in.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { build } from "esbuild";
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
12
|
+
|
|
13
|
+
describe("the review-agent bundle", () => {
|
|
14
|
+
test("holds the review agent, the note it posts through and nothing else", async () => {
|
|
15
|
+
const result = await build({
|
|
16
|
+
entryPoints: [join(REPO, "packages", "core", "src", "op", "activities", "review-agent.ts")],
|
|
17
|
+
bundle: true,
|
|
18
|
+
platform: "node",
|
|
19
|
+
format: "esm",
|
|
20
|
+
write: false,
|
|
21
|
+
metafile: true,
|
|
22
|
+
outdir: "out",
|
|
23
|
+
logLevel: "silent",
|
|
24
|
+
});
|
|
25
|
+
expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
|
|
26
|
+
"packages/core/src/op/activities/forge-pull-request.ts",
|
|
27
|
+
"packages/core/src/op/activities/gh-host.ts",
|
|
28
|
+
"packages/core/src/op/activities/pr-refs.ts",
|
|
29
|
+
"packages/core/src/op/activities/reconcile.ts",
|
|
30
|
+
"packages/core/src/op/activities/review-agent.ts",
|
|
31
|
+
"packages/core/src/pr-forge.ts",
|
|
32
|
+
]);
|
|
33
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(96 * 1024);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stage-hook runner bundles alone (#4288): a product that runs
|
|
3
|
+
* `ci.hooks` around its own stages imports
|
|
4
|
+
* `@intentius/chant/op/stage-hooks` into one bundled file, without the wave
|
|
5
|
+
* loop, the gate or the ledger. This bundles the module and reads esbuild's
|
|
6
|
+
* list of what went in.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { build } from "esbuild";
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
13
|
+
|
|
14
|
+
describe("the stage-hooks bundle", () => {
|
|
15
|
+
test("holds the hook runner and the Op waves spec, and no wave loop, gate or ledger", async () => {
|
|
16
|
+
const result = await build({
|
|
17
|
+
entryPoints: [join(REPO, "packages", "core", "src", "op", "stage-hooks.ts")],
|
|
18
|
+
bundle: true,
|
|
19
|
+
platform: "node",
|
|
20
|
+
format: "esm",
|
|
21
|
+
write: false,
|
|
22
|
+
metafile: true,
|
|
23
|
+
outdir: "out",
|
|
24
|
+
logLevel: "silent",
|
|
25
|
+
});
|
|
26
|
+
expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
|
|
27
|
+
"packages/core/src/lifecycle/gate-seal.ts",
|
|
28
|
+
"packages/core/src/notify-verify.ts",
|
|
29
|
+
"packages/core/src/notify.ts",
|
|
30
|
+
"packages/core/src/op/gate-point.ts",
|
|
31
|
+
"packages/core/src/op/gate-quorum.ts",
|
|
32
|
+
"packages/core/src/op/op-waves.ts",
|
|
33
|
+
"packages/core/src/op/stage-hooks.ts",
|
|
34
|
+
"packages/core/src/wave-layout.ts",
|
|
35
|
+
]);
|
|
36
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(48 * 1024);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -120,6 +120,15 @@ describe("ci.workflow (WSP151)", () => {
|
|
|
120
120
|
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": noRecord })))).toEqual([["ci-workflow-pipeline-missing", null]]);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
test("apply-before-merge needs a job that applies before merge, and the other workflows refuse one (#4301)", async () => {
|
|
124
|
+
const early = `${PIPELINE} apply:\n steps:\n - run: chant ci run apply --in-place --base "$BASE_SHA" --pr "$PR_NUMBER" --apply-before-merge --env prod --gate pr-apply --forge github\n`;
|
|
125
|
+
const decl = declaration({ ci: { workflow: "apply-before-merge", green: green() } });
|
|
126
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl })))).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
127
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": early })))).toEqual([]);
|
|
128
|
+
expect(await found(repo(complete({ ".github/workflows/chant-pr.yml": early })))).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
129
|
+
expect(workflowOf({ ci: { green: null, reportUrl: null, workflow: "apply-before-merge" } } as never, null)).toBe("apply-before-merge");
|
|
130
|
+
});
|
|
131
|
+
|
|
123
132
|
test("a member's own workflow wins, and a top-level workflow skips members that don't build with chant", async () => {
|
|
124
133
|
const root = repo({
|
|
125
134
|
...complete(),
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
|
|
16
16
|
import type { CiWorkflow, Member } from "../declaration";
|
|
17
|
-
import { appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
|
|
17
|
+
import { appliesBeforeMerge, appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
|
|
18
18
|
import { matchesCheckRun } from "../ci-green";
|
|
19
19
|
import { staticPromotionOrder } from "../promotion-order";
|
|
20
20
|
import { joinPath } from "../tree";
|
|
@@ -27,6 +27,8 @@ const memberDir = (dir: string) => (dir === "." ? "" : dir);
|
|
|
27
27
|
const PLAN = /\bci\s+run\s+plan\b|\bcomponents\s+pr-plan\b/;
|
|
28
28
|
const RECORD = /\bci\s+run\s+record\b|\bcomponents\s+pr-record\b/;
|
|
29
29
|
const APPLY = /\bci\s+run\s+apply\b|\bcomponents\s+pr-apply\b/;
|
|
30
|
+
/** An apply before merge (#4301): `pr-apply --before-merge`, or `ci run apply --apply-before-merge`. */
|
|
31
|
+
const BEFORE_MERGE = /--(?:apply-)?before-merge\b/;
|
|
30
32
|
const TICK = /\bci\s+tick\b/;
|
|
31
33
|
const PUBLISH = /\brelease\s+publish\b/;
|
|
32
34
|
|
|
@@ -113,16 +115,28 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
113
115
|
if (missing.length > 0) {
|
|
114
116
|
finding("ci-workflow-pipeline-missing", `no pipeline file runs ${missing.join(", ")}; generate them with chant build --components --generate <forge> --pr-loop and chant ci workflow`);
|
|
115
117
|
}
|
|
118
|
+
// #4301: an apply before merge is its own job, allowed only under apply-before-merge.
|
|
119
|
+
const beforeMerge = under.some((u) => appliesBeforeMerge(u.workflow)) || appliesBeforeMerge(d.ci?.workflow ?? null);
|
|
120
|
+
const earlyLines = commandLines(ctx, APPLY).filter((c) => BEFORE_MERGE.test(c.text));
|
|
121
|
+
if (!beforeMerge) {
|
|
122
|
+
for (const l of earlyLines) {
|
|
123
|
+
finding("ci-workflow-pipeline-differs", `${l.path} applies before merge, and no member's workflow is apply-before-merge; regenerate without --apply-before-merge, or set ci.workflow to apply-before-merge`, { treeFile: l.path, line: l.line });
|
|
124
|
+
}
|
|
125
|
+
} else if (earlyLines.length === 0 && has(PLAN)) {
|
|
126
|
+
const l = commandLines(ctx, PLAN)[0]!;
|
|
127
|
+
finding("ci-workflow-pipeline-differs", `${l.path} has no job that applies before merge, and the workflow is apply-before-merge; regenerate with --apply-before-merge`, { treeFile: l.path, line: l.line });
|
|
128
|
+
}
|
|
129
|
+
const mergeApply = (l: { text: string }) => !BEFORE_MERGE.test(l.text);
|
|
116
130
|
if (!applyOnMerge) {
|
|
117
|
-
for (const l of commandLines(ctx, APPLY)) {
|
|
131
|
+
for (const l of commandLines(ctx, APPLY).filter(mergeApply)) {
|
|
118
132
|
finding("ci-workflow-pipeline-differs", `${l.path} applies on merge, and the workflow is trunk-curated, where a merge records the plan and deploys nothing; regenerate without --apply-on-merge, or set ci.workflow to trunk-curated+apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
119
133
|
}
|
|
120
|
-
} else if (!
|
|
134
|
+
} else if (!commandLines(ctx, APPLY).some(mergeApply) && has(RECORD)) {
|
|
121
135
|
const l = commandLines(ctx, RECORD)[0]!;
|
|
122
136
|
finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
123
137
|
} else {
|
|
124
138
|
// #4168 step 9: an apply measured from a push's base applies each merge; the workflow applies green commits.
|
|
125
|
-
for (const l of commandLines(ctx, APPLY).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
|
|
139
|
+
for (const l of commandLines(ctx, APPLY).filter(mergeApply).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
|
|
126
140
|
finding("ci-workflow-pipeline-differs", `${l.path} applies each merge, and under trunk-curated+apply-on-merge the apply applies the newest green commit; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
127
141
|
}
|
|
128
142
|
}
|
package/src/workspace/ci-init.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { formatError, formatSuccess } from "../cli/format";
|
|
|
37
37
|
import { parseYAMLDocument } from "../yaml";
|
|
38
38
|
import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow";
|
|
39
39
|
import type { WorkspaceDiagnostic } from "./checks";
|
|
40
|
-
import { CI_WORKFLOWS, STUB_MARKER, appliesOnMerge } from "./ci-workflow";
|
|
40
|
+
import { CI_WORKFLOWS, STUB_MARKER, appliesBeforeMerge, appliesOnMerge } from "./ci-workflow";
|
|
41
41
|
|
|
42
42
|
export { STUB_MARKER };
|
|
43
43
|
import { CI_GREEN_WORKFLOW_FILE, jobCheckRuns, readWorkflowSources, workflowsHolding } from "./ci-green-workflow";
|
|
@@ -48,7 +48,7 @@ import { SECURITY_GROUPS, type SecurityGroup } from "./security";
|
|
|
48
48
|
import { gitTop, workingTree, type WorkspaceTree } from "./tree";
|
|
49
49
|
import { RELEASE_WORKFLOW_FORGES, type ReleaseWorkflowForge } from "./release-workflow";
|
|
50
50
|
|
|
51
|
-
const USAGE = "chant ci init --workflow trunk-curated|trunk-curated+apply-on-merge [--forge github|gitlab|forgejo] [--security <group>]... [--yes]";
|
|
51
|
+
const USAGE = "chant ci init --workflow trunk-curated|trunk-curated+apply-on-merge|apply-before-merge [--forge github|gitlab|forgejo] [--security <group>]... [--yes]";
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
/** The environments a config with none is given. */
|
|
@@ -280,11 +280,12 @@ export function proposeCiInit(root: string, options: CiInitOptions): CiInitPropo
|
|
|
280
280
|
if (reported("ci-workflow-pipeline-missing") || reported("ci-workflow-pipeline-differs")) {
|
|
281
281
|
for (const m of releasing) {
|
|
282
282
|
const env = firstEnv.get(m.name);
|
|
283
|
+
const workflow = m.ciWorkflow ?? proposed.ci!.workflow!;
|
|
283
284
|
commands.push({
|
|
284
285
|
cwd: memberDir(m.dir) || ".",
|
|
285
286
|
// The root member builds alone: at a workspace root, chant build refuses without --root-only (WSP000).
|
|
286
|
-
args: ["build", "--components", "--generate", forge, "--pr-loop", ...(m.dir === "." ? ["--root-only"] : []), ...(env ? ["--env", env] : []), ...(appliesOnMerge(
|
|
287
|
-
why: `the pull-request pipeline for ${m.name}: plan on a pull request, ${
|
|
287
|
+
args: ["build", "--components", "--generate", forge, "--pr-loop", ...(m.dir === "." ? ["--root-only"] : []), ...(env ? ["--env", env] : []), ...(appliesOnMerge(workflow) ? ["--apply-on-merge"] : []), ...(appliesBeforeMerge(workflow) ? ["--apply-before-merge"] : [])],
|
|
288
|
+
why: `the pull-request pipeline for ${m.name}: plan on a pull request, ${appliesBeforeMerge(workflow) ? "apply on request before merge, record" : appliesOnMerge(workflow) ? "apply" : "record"} on merge`,
|
|
288
289
|
});
|
|
289
290
|
}
|
|
290
291
|
if (forge === "github") {
|
package/src/workspace/ci-run.ts
CHANGED
|
@@ -1040,13 +1040,14 @@ export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptio
|
|
|
1040
1040
|
...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
|
|
1041
1041
|
...(args.waveGate ? { waveGate: args.waveGate } : {}),
|
|
1042
1042
|
...(args.requireReview === true ? { requireReview: true } : {}),
|
|
1043
|
+
...(args.applyBeforeMerge ? { applyBeforeMerge: true } : {}),
|
|
1043
1044
|
};
|
|
1044
1045
|
}
|
|
1045
1046
|
|
|
1046
1047
|
const USAGE =
|
|
1047
1048
|
"chant ci run plan|record|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
|
|
1048
1049
|
"[--gate <name>] [--wave-gate <name>] [--wave <k>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
|
|
1049
|
-
"[--pr-build] [--pr-build-command <line>] [--require-review] [--no-status] [--status-url <url template>] " +
|
|
1050
|
+
"[--pr-build] [--pr-build-command <line>] [--require-review] [--apply-before-merge] [--no-status] [--status-url <url template>] " +
|
|
1050
1051
|
"[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] [--in-place | --local] [--no-push] [--verbose] | chant ci run --at <sha> | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
|
|
1051
1052
|
|
|
1052
1053
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ci.workflow` read at base (#4301, ws-106), as `ci.locks` is
|
|
3
|
+
* (./ci-locks.ts): the target branch's tip, so a pull request that sets
|
|
4
|
+
* `apply-before-merge` in its own copy of the declaration gets nothing from it.
|
|
5
|
+
*/
|
|
6
|
+
import type { CiWorkflow } from "./declaration";
|
|
7
|
+
import { workflowOf } from "./ci-workflow";
|
|
8
|
+
import { memberAtCwd } from "./security";
|
|
9
|
+
import { scopeSource } from "./write-scope";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The workflow the member holding `cwd` follows, or `member` when given:
|
|
13
|
+
* its own `ci.workflow`, else the top level's, read at `at` when given, else
|
|
14
|
+
* at base, else from the working tree. Null outside a workspace and when
|
|
15
|
+
* neither sets one.
|
|
16
|
+
*/
|
|
17
|
+
export function ciWorkflowAt(cwd: string, at?: string, member?: string): CiWorkflow | null {
|
|
18
|
+
const source = scopeSource(cwd, at);
|
|
19
|
+
const d = source.declaration;
|
|
20
|
+
if (!d) return null;
|
|
21
|
+
const name = member ?? memberAtCwd(source, cwd);
|
|
22
|
+
return workflowOf(d, d.members.find((m) => m.name === name) ?? null);
|
|
23
|
+
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* from a green commit and promoted through the member's environments, gated
|
|
8
8
|
* in the last (4.2). `trunk-curated+apply-on-merge` is the same, and each
|
|
9
9
|
* newest green commit is also applied (section 5, step 9).
|
|
10
|
+
* `apply-before-merge` (#4301, ws-106) is `trunk-curated`, and a person may
|
|
11
|
+
* also apply an open pull request's plan from its head before it merges.
|
|
10
12
|
*
|
|
11
13
|
* A workflow requires no security group: those are opt-in on their own
|
|
12
14
|
* (./security.ts). `chant workspace check` reports what a member under a
|
|
@@ -19,13 +21,22 @@ import type { CiWorkflow, Declaration, Member } from "./declaration";
|
|
|
19
21
|
/** The marker a component stub `chant ci init` writes carries until someone fills it in. WSP151 reports a member whose only components carry it. */
|
|
20
22
|
export const STUB_MARKER = "TODO(chant ci init)";
|
|
21
23
|
|
|
22
|
-
export const CI_WORKFLOWS = ["trunk-curated", "trunk-curated+apply-on-merge"] as const satisfies readonly CiWorkflow[];
|
|
24
|
+
export const CI_WORKFLOWS = ["trunk-curated", "trunk-curated+apply-on-merge", "apply-before-merge"] as const satisfies readonly CiWorkflow[];
|
|
23
25
|
|
|
24
26
|
/** Whether `workflow` turns the apply on merge back on. */
|
|
25
27
|
export function appliesOnMerge(workflow: CiWorkflow): boolean {
|
|
26
28
|
return workflow === "trunk-curated+apply-on-merge";
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether `workflow` lets an open pull request's plan apply from its head
|
|
33
|
+
* before it merges (#4301, ws-106). Only `apply-before-merge` does, so with
|
|
34
|
+
* no workflow or either trunk-curated one nothing applies before merge.
|
|
35
|
+
*/
|
|
36
|
+
export function appliesBeforeMerge(workflow: CiWorkflow | null): boolean {
|
|
37
|
+
return workflow === "apply-before-merge";
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/** The workflow `member` follows: its own `ci.workflow`, else the top level's, else null. */
|
|
30
41
|
export function workflowOf(declaration: Pick<Declaration, "ci">, member: Pick<Member, "ciWorkflow"> | null): CiWorkflow | null {
|
|
31
42
|
return member?.ciWorkflow ?? declaration.ci?.workflow ?? null;
|
|
@@ -56,7 +67,7 @@ export const CI_WORKFLOW_REQUIREMENTS = [
|
|
|
56
67
|
{ code: "ci-workflow-no-environments", requirement: "the member's `chant.config.ts` declares `environments`, the promotion order", fix: "`environments` in `chant.config.ts`", perMember: true },
|
|
57
68
|
{ code: "ci-workflow-ungated-env", requirement: "each component has a gate, so a promote to the last environment waits on an approval", fix: "a `gate()` step in the component", perMember: true },
|
|
58
69
|
{ code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the record job (`ci run record`) and `chant ci tick`", fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`", perMember: false },
|
|
59
|
-
{ code: "ci-workflow-pipeline-differs", requirement: "the pipeline matches the workflow: no apply job under `trunk-curated`, and under `trunk-curated+apply-on-merge` an apply job that applies the newest green commit, not each merge", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge", perMember: false },
|
|
70
|
+
{ code: "ci-workflow-pipeline-differs", requirement: "the pipeline matches the workflow: no apply job under `trunk-curated`, and under `trunk-curated+apply-on-merge` an apply job that applies the newest green commit, not each merge; a job that applies before merge only, and always, under `apply-before-merge`", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge and `--apply-before-merge` only under apply before merge", perMember: false },
|
|
60
71
|
{ code: "ci-workflow-publish-missing", requirement: "a pipeline file runs `chant release publish`", fix: "`chant release workflow`", perMember: false },
|
|
61
72
|
] as const satisfies readonly CiWorkflowRequirement[];
|
|
62
73
|
|
|
@@ -1718,8 +1718,8 @@
|
|
|
1718
1718
|
"additionalProperties": false
|
|
1719
1719
|
},
|
|
1720
1720
|
"ciWorkflow": {
|
|
1721
|
-
"enum": ["trunk-curated", "trunk-curated+apply-on-merge"],
|
|
1722
|
-
"description": "A prescribed delivery workflow (#4168). trunk-curated: a merge records the plan and deploys nothing; releases are cut from green commits and promoted through each member's environments, gated in the last. trunk-curated+apply-on-merge: the same, with the apply on merge turned back on. chant workspace check reports what a member under one still lacks (WSP151), and chant ci init writes it. At the top level for every member, or on a member, which wins. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer."
|
|
1721
|
+
"enum": ["trunk-curated", "trunk-curated+apply-on-merge", "apply-before-merge"],
|
|
1722
|
+
"description": "A prescribed delivery workflow (#4168). trunk-curated: a merge records the plan and deploys nothing; releases are cut from green commits and promoted through each member's environments, gated in the last. trunk-curated+apply-on-merge: the same, with the apply on merge turned back on. apply-before-merge (#4301, ws-106): the same as trunk-curated, and a person may also apply an open pull request's plan from its head under its gate, once it is approved, mergeable, undiverged and its checks passed (chant components pr-apply --before-merge); added by chant 0.124.42, so a declaration that uses it sets minReader to 0.124.42 or newer. chant workspace check reports what a member under one still lacks (WSP151), and chant ci init writes it. At the top level for every member, or on a member, which wins. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer."
|
|
1723
1723
|
},
|
|
1724
1724
|
"ciHooks": {
|
|
1725
1725
|
"type": "object",
|