@intentius/chant-lexicon-gitlab 0.124.54 → 0.124.56
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/components/agent-jobs.d.ts +68 -0
- package/dist/components/agent-jobs.d.ts.map +1 -0
- package/dist/components/agent-jobs.js +135 -0
- package/dist/components/control-pipeline.d.ts +24 -0
- package/dist/components/control-pipeline.d.ts.map +1 -0
- package/dist/components/control-pipeline.js +28 -0
- package/dist/components/generate-op-pipeline.d.ts +9 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +147 -23
- package/dist/components/generate-op-waves-pipeline.d.ts +25 -2
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +110 -12
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +135 -11
- package/dist/components/review-jobs.d.ts +72 -0
- package/dist/components/review-jobs.d.ts.map +1 -0
- package/dist/components/review-jobs.js +124 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +3 -3
- package/src/components/__golden__/op-waves-jobs.yml +11 -1
- package/src/components/agent-jobs.ts +176 -0
- package/src/components/control-pipeline.ts +38 -0
- package/src/components/generate-op-pipeline.agent.test.ts +99 -0
- package/src/components/generate-op-pipeline.cloud.test.ts +40 -0
- package/src/components/generate-op-pipeline.review.test.ts +72 -7
- package/src/components/generate-op-pipeline.ts +157 -24
- package/src/components/generate-op-waves-pipeline.approval.test.ts +28 -0
- package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +63 -0
- package/src/components/generate-op-waves-pipeline.test.ts +22 -3
- package/src/components/generate-op-waves-pipeline.ts +123 -12
- package/src/components/generate-pipeline.ts +143 -9
- package/src/components/generate-pr-pipeline.comment-locks.test.ts +43 -0
- package/src/components/generate-pr-pipeline.comment-preview.test.ts +61 -0
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +20 -0
- package/src/components/generate-pr-pipeline.control.test.ts +81 -0
- package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +46 -0
- package/src/components/generate-pr-pipeline.respond.test.ts +27 -0
- package/src/components/generate-pr-pipeline.runners.test.ts +41 -0
- package/src/components/generate-pr-pipeline.stale-notes.test.ts +24 -0
- package/src/components/review-jobs.ts +163 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4568: with a control repo, the GitLab merge-request pipeline adds a
|
|
3
|
+
* `control-dispatch` job after the last apply on merge, and the plan job
|
|
4
|
+
* unsets the control repo's token variables with the forge tokens, since a
|
|
5
|
+
* CI/CD variable reaches every job. The control repo's own pipeline runs on
|
|
6
|
+
* a trigger. Generated YAML only; the request is tested against fake forges
|
|
7
|
+
* in packages/core/src/control/control.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
12
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
13
|
+
import { resolveControlTarget } from "@intentius/chant/control/dispatch";
|
|
14
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
15
|
+
import { generateControlPipeline } from "./control-pipeline";
|
|
16
|
+
|
|
17
|
+
const root = (name: string, dependsOn: string[] = []): DriverComponent => ({ name, dependsOn, deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }] });
|
|
18
|
+
const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
|
|
19
|
+
const control = { target: resolveControlTarget({ repo: "gitlab.com/acme/control", op: "deploy", tokenSecret: "TERRAGUCCI_CONTROL_TOKEN" }, "network") };
|
|
20
|
+
|
|
21
|
+
interface Job {
|
|
22
|
+
stage: string;
|
|
23
|
+
needs?: string[];
|
|
24
|
+
rules: Array<{ if: string }>;
|
|
25
|
+
script: string[];
|
|
26
|
+
resource_group?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("the GitLab pipeline's control dispatch (#4568)", () => {
|
|
30
|
+
test("one job after the apply, and the plan job drops the token variables", () => {
|
|
31
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control, words: { product: "terragucci" } } });
|
|
32
|
+
const doc = parseYAML(yaml) as Record<string, Job> & { stages: string[] };
|
|
33
|
+
const job = doc["control-dispatch"]!;
|
|
34
|
+
expect(job.needs).toEqual(["apply"]);
|
|
35
|
+
expect(doc.stages.at(-1)).toBe("control-dispatch");
|
|
36
|
+
expect(job.script[0]).toBe('export CHANT_CONTROL_SHA="$CI_COMMIT_SHA"');
|
|
37
|
+
expect(job.script[1]).toContain('CHANT_CONTROL_TOKEN="${TERRAGUCCI_CONTROL_TOKEN:-}"');
|
|
38
|
+
expect(job.script[1]).toContain("/trigger/pipeline");
|
|
39
|
+
expect(doc.plan!.script.some((l) => l.startsWith("unset ") && l.includes("TERRAGUCCI_CONTROL_TOKEN") && l.includes("CHANT_CONTROL_TOKEN"))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("with a gate per wave, the job follows the last wave and reads its GREEN_SHA", () => {
|
|
43
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control }, gatedWaves: { gate: "release" } });
|
|
44
|
+
const doc = parseYAML(yaml) as Record<string, Job>;
|
|
45
|
+
expect(doc["control-dispatch"]!.needs).toEqual(["apply-wave-2"]);
|
|
46
|
+
expect(doc["control-dispatch"]!.script[0]).toBe('export CHANT_CONTROL_SHA="$GREEN_SHA"');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("the control repo's pipeline on GitLab (#4568)", () => {
|
|
51
|
+
test("a trigger with the member and Op runs the dependents, one member at a time", () => {
|
|
52
|
+
const file = generateControlPipeline({ words: { product: "terragucci" } });
|
|
53
|
+
expect(file.path).toBe(".gitlab/terragucci-dependents.yml");
|
|
54
|
+
const doc = parseYAML(file.content) as Record<string, Job>;
|
|
55
|
+
expect(doc.dependents!.rules[0]!.if).toBe('$CI_PIPELINE_SOURCE == "trigger" && $CHANT_CONTROL_MEMBER && $CHANT_CONTROL_OP');
|
|
56
|
+
expect(doc.dependents!.resource_group).toBe("terragucci-dependents-$CHANT_CONTROL_MEMBER");
|
|
57
|
+
expect(doc.dependents!.script[0]).toContain("workspace run --dependents-of");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// #4619: the note job reads the control repo, read-only, so the note marks the cross-stack edges.
|
|
62
|
+
describe("the GitLab plan-note job's read of the control repo (#4619)", () => {
|
|
63
|
+
test("fetches the control repo's chant/lifecycle before it posts, and exports CHANT_CONTROL_DIR", () => {
|
|
64
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control, words: { product: "terragucci" } } });
|
|
65
|
+
const doc = parseYAML(yaml) as Record<string, Job>;
|
|
66
|
+
const script = doc["plan-note"]!.script;
|
|
67
|
+
const read = script.findIndex((l) => l.includes("refs/heads/chant/lifecycle"));
|
|
68
|
+
expect(read).toBeGreaterThan(-1);
|
|
69
|
+
expect(script[read + 1]).toContain("components pr-post");
|
|
70
|
+
expect(script[read]).toContain('CHANT_CONTROL_READ_TOKEN="${CHANT_CONTROL_READ_TOKEN:-}"');
|
|
71
|
+
expect(script[read]).toContain('export CHANT_CONTROL_DIR="$chant_control_dir"');
|
|
72
|
+
expect(script[read]).toContain("'https://gitlab.com/acme/control.git'");
|
|
73
|
+
// The dispatch token is not the note job's.
|
|
74
|
+
expect(script[read]).not.toContain("TERRAGUCCI_CONTROL_TOKEN");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("without a control repo the note job reads none", () => {
|
|
78
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
|
|
79
|
+
expect(yaml).not.toContain("CHANT_CONTROL_DIR");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4549: with a Pulumi Cloud identity, the merge-request pipeline's plan and
|
|
3
|
+
* record jobs trade their `id_tokens` for a preview token and the apply job
|
|
4
|
+
* for an up token. No job reads a Pulumi token from a CI/CD variable, and
|
|
5
|
+
* the plan job names no up token. Generated YAML only; the trade itself is
|
|
6
|
+
* tested against the fake exchange in packages/core/src/ci-identity-pulumi.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
11
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
12
|
+
import { gitlabTradeLines, resolveCloudIdentity, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
13
|
+
import { FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
|
|
14
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
15
|
+
|
|
16
|
+
const ESTATE: DriverComponent[] = [{ name: "app", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] }];
|
|
17
|
+
|
|
18
|
+
const IDENTITY = resolveCloudIdentity({ pulumi: { organization: "acme", rules: [{ plan: "team:preview", apply: "team:deploy" }] } }, "prod")!;
|
|
19
|
+
|
|
20
|
+
interface GitlabJob {
|
|
21
|
+
id_tokens?: Record<string, { aud: string }>;
|
|
22
|
+
variables?: Record<string, string>;
|
|
23
|
+
script: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("the GitLab pipeline's Pulumi Cloud tokens (#4549)", () => {
|
|
27
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: { applyOnMerge: true, identity: IDENTITY } });
|
|
28
|
+
const doc = parseYAML(yaml) as Record<string, GitlabJob>;
|
|
29
|
+
|
|
30
|
+
test("the plan job asks for the Pulumi id token and trades for the preview token before anything else", () => {
|
|
31
|
+
expect(doc.plan!.id_tokens).toEqual({ CHANT_ID_TOKEN_PULUMI: { aud: "urn:pulumi:org:acme" } });
|
|
32
|
+
expect(doc.plan!.script.slice(0, 3)).toEqual(gitlabTradeLines(FORGE_TOKEN_VARS));
|
|
33
|
+
expect(JSON.parse(doc.plan!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.plan);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the record job takes the preview token and the apply job the up token", () => {
|
|
37
|
+
expect(JSON.parse(doc.record!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.plan);
|
|
38
|
+
expect(JSON.parse(doc.apply!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.apply);
|
|
39
|
+
expect(doc.apply!.id_tokens).toEqual(doc.plan!.id_tokens);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the plan job names no up token, and nothing reads PULUMI_ACCESS_TOKEN from a CI/CD variable", () => {
|
|
43
|
+
expect(JSON.stringify(doc.plan)).not.toContain("team:deploy");
|
|
44
|
+
expect(yaml).not.toContain("$PULUMI_ACCESS_TOKEN");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pull request loop's responses and description check on GitLab
|
|
3
|
+
* (#4629), as `chant build --pr-loop --respond --decide-backend
|
|
4
|
+
* --decide-model` sets them: `pr-post --open` asks the decide service, and
|
|
5
|
+
* the apply before merge carries CHANT_RESPOND and the merge (#4636).
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from "vitest";
|
|
8
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
9
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
10
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
11
|
+
|
|
12
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
13
|
+
|
|
14
|
+
type Job = { variables?: Record<string, string>; script?: string[] };
|
|
15
|
+
|
|
16
|
+
test("the note and apply jobs carry the responses, the decide service and the merge (#4629, #4636)", () => {
|
|
17
|
+
const doc = parseYAML(generateGitlabPipeline(ESTATE, {
|
|
18
|
+
env: "prod",
|
|
19
|
+
prLoop: { applyBeforeMerge: true, applyMerge: { tokenEnv: "CHANT_MERGE_TOKEN" }, respond: { description: "check" }, decide: { backend: "review", model: "m-1" } },
|
|
20
|
+
}).yaml) as Record<string, Job>;
|
|
21
|
+
const note = doc["plan-note"]!;
|
|
22
|
+
expect(note.script!.at(-1)).toMatch(/pr-post --open .* --decide-backend "review" --decide-model "m-1"$/);
|
|
23
|
+
expect(note.variables!.CHANT_RESPOND).toBe("description=check");
|
|
24
|
+
const apply = Object.values(doc).find((j) => typeof j === "object" && j?.script?.some((l) => l.includes("--apply-before-merge")))!;
|
|
25
|
+
expect(apply.script!.join("\n")).toContain("--merge auto --merge-token-env CHANT_MERGE_TOKEN");
|
|
26
|
+
expect(apply.variables!.CHANT_RESPOND).toBe("description=check");
|
|
27
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge request pipeline's runners, variables and passed secrets on
|
|
3
|
+
* GitLab (#4625): each job's `tags` from its stage's runner, and a passed
|
|
4
|
+
* secret mapped from the CI/CD variable when the names differ. Written for
|
|
5
|
+
* the ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
10
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
11
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
12
|
+
|
|
13
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
14
|
+
|
|
15
|
+
type Job = { tags?: string[]; variables: Record<string, string>; script: string[] };
|
|
16
|
+
type Doc = Record<string, Job>;
|
|
17
|
+
|
|
18
|
+
describe("the GitLab merge request pipeline's runners and variables (#4625)", () => {
|
|
19
|
+
const doc = parseYAML(
|
|
20
|
+
generateGitlabPipeline(ESTATE, {
|
|
21
|
+
env: "prod",
|
|
22
|
+
prLoop: { applyOnMerge: true, runners: { plan: "planner", apply: ["deploy", "eu"], default: "small" }, variables: { REGION: "eu" }, secrets: { DB_PASSWORD: "PROD_DB", SAME: "SAME" } },
|
|
23
|
+
}).yaml,
|
|
24
|
+
) as Doc;
|
|
25
|
+
|
|
26
|
+
test("each job's tags come from its stage", () => {
|
|
27
|
+
expect(doc.plan!.tags).toEqual(["planner"]);
|
|
28
|
+
expect(doc.apply!.tags).toEqual(["deploy", "eu"]);
|
|
29
|
+
expect(doc.record!.tags).toEqual(["deploy", "eu"]);
|
|
30
|
+
expect(doc["plan-note"]!.tags).toEqual(["small"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the plan's variables carry the variable and the renamed secret; a same-named one needs no line", () => {
|
|
34
|
+
expect(doc.plan!.variables).toMatchObject({ REGION: "eu", DB_PASSWORD: "$PROD_DB" });
|
|
35
|
+
expect(doc.plan!.variables).not.toHaveProperty("SAME");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a runner group is refused", () => {
|
|
39
|
+
expect(() => generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { runners: { plan: { group: "g" } } } })).toThrow(/runner group/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale marking on GitLab (#4628): the note job's `pr-post --open` passes
|
|
3
|
+
* the target branch pipeline's commit, and a stale-notes job in the push
|
|
4
|
+
* pipeline of the target branch runs `pr-post --stale`. Written for the
|
|
5
|
+
* ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from "vitest";
|
|
8
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
9
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
10
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
11
|
+
|
|
12
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
13
|
+
|
|
14
|
+
test("the note job carries the tip and a push job marks the notes stale (#4628)", () => {
|
|
15
|
+
const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { staleNotes: true } }).yaml) as Record<string, unknown> & { stages: string[] };
|
|
16
|
+
const note = doc["plan-note"] as { variables: Record<string, string>; script: string[] };
|
|
17
|
+
expect(note.variables.TIP_SHA).toBe("$CI_COMMIT_SHA");
|
|
18
|
+
expect(note.script.at(-1)).toContain('--tip "$TIP_SHA"');
|
|
19
|
+
const stale = doc["stale-notes"] as { stage: string; rules: Array<{ if: string }>; variables: Record<string, string>; script: string[] };
|
|
20
|
+
expect(doc.stages).toContain("stale-notes");
|
|
21
|
+
expect(stale.rules[0]!.if).toContain("$CI_PIPELINE_SOURCE == \"push\"");
|
|
22
|
+
expect(stale.variables).toEqual({ TIP_SHA: "$CI_COMMIT_SHA" });
|
|
23
|
+
expect(stale.script.at(-1)).toBe('chant components pr-post --stale --tip "$TIP_SHA" --env prod --forge gitlab');
|
|
24
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two jobs of a split review on GitLab (#4544): `ScheduledOpSpec.reviewAgent`.
|
|
3
|
+
*
|
|
4
|
+
* An Op that runs a `reviewAgent` step in `handoff` mode runs the change's
|
|
5
|
+
* code (`chant run` loads the merge request's Op files) beside a model.
|
|
6
|
+
* GitHub and Forgejo keep the write token out of that job with per-job
|
|
7
|
+
* permissions (`lexicons/github/src/components/review-jobs.ts`). GitLab has
|
|
8
|
+
* none: a merge request pipeline is built from the merge request's own files
|
|
9
|
+
* and every job in it gets the project's unprotected variables. So the token
|
|
10
|
+
* stays out of that pipeline altogether, the way the plan note does (#4328):
|
|
11
|
+
*
|
|
12
|
+
* - the review job runs in the merge request pipeline. Its script starts by
|
|
13
|
+
* unsetting every forge token variable (`GITLAB_DROP_FORGE_TOKENS`), and it
|
|
14
|
+
* keeps the review directory as its artifact. `CHANT_FORGE_TOKEN` is a
|
|
15
|
+
* protected variable, which a merge request pipeline never gets.
|
|
16
|
+
* - the note job runs in a pipeline of the default branch, built from that
|
|
17
|
+
* branch's files, that a pipeline schedule (or a person, the API or a
|
|
18
|
+
* trigger) starts with `CHANT_REVIEW_NOTES` set to `1`. It checks nothing
|
|
19
|
+
* out (`GIT_STRATEGY: none`), installs the chant release this lexicon
|
|
20
|
+
* belongs to, and runs `postOpenReviewNotes` with the protected token: for
|
|
21
|
+
* each open merge request whose newest pipeline's review job succeeded it
|
|
22
|
+
* reads `review.json` from that job's artifacts as data and posts the note.
|
|
23
|
+
* The Op's name, the review job's name and the branches come from this
|
|
24
|
+
* file, never from the artifact.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
29
|
+
import type { OpTrigger } from "@intentius/chant/lexicon";
|
|
30
|
+
|
|
31
|
+
/** The variable `reviewAgent` reads its handoff directory from (`REVIEW_DIR_ENV`, review-agent.ts). */
|
|
32
|
+
export const REVIEW_DIR_ENV = "CHANT_REVIEW_DIR";
|
|
33
|
+
|
|
34
|
+
/** Where the review job writes the review, from the directory its script runs in. */
|
|
35
|
+
export const REVIEW_DIR = ".chant/review";
|
|
36
|
+
|
|
37
|
+
/** The variable a pipeline of the default branch is started with to run the note jobs. */
|
|
38
|
+
export const GITLAB_REVIEW_NOTES_VAR = "CHANT_REVIEW_NOTES";
|
|
39
|
+
|
|
40
|
+
/** How long a review artifact is kept: long enough for a schedule to come round and post it. */
|
|
41
|
+
const REVIEW_ARTIFACT_EXPIRY = "7 days";
|
|
42
|
+
|
|
43
|
+
/** The name of the note job beside the Op's own job. */
|
|
44
|
+
export function reviewNoteJobName(jobName: string): string {
|
|
45
|
+
return `${jobName}-note`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The note job's rule: a pipeline of the default branch that a pipeline
|
|
50
|
+
* schedule, a person, the API or a trigger started with `CHANT_REVIEW_NOTES`
|
|
51
|
+
* set to `1`. A merge request pipeline's own trigger job starts a pipeline
|
|
52
|
+
* whose source is `pipeline`, which this rule leaves out.
|
|
53
|
+
*/
|
|
54
|
+
export const GITLAB_REVIEW_NOTES_RULE = `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_REVIEW_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Refuse a spec whose review cannot be split the way this shape needs: a
|
|
58
|
+
* trigger with no merge request, a finding-mode that would post from the
|
|
59
|
+
* review job, or an additive scope other than `id-token: write`.
|
|
60
|
+
*/
|
|
61
|
+
export function assertReviewSplit(name: string, trigger: OpTrigger, findingMode: string, permissions: Record<string, string> | undefined): void {
|
|
62
|
+
if (trigger.kind !== "pull_request") {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Scheduled Op "${name}" sets reviewAgent, which posts the review as a note on the merge request that triggered the run, but its trigger is "${trigger.kind}". Give it a { kind: "pull_request" } trigger.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (findingMode !== "report") {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Scheduled Op "${name}" sets reviewAgent with findingMode "${findingMode}". The review job holds no forge token, and the note job posts from a pipeline of the default branch instead, so the Op's own finding-mode must be "report".`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
for (const [scope, level] of Object.entries(permissions ?? {})) {
|
|
73
|
+
if (level === "write" && scope !== "id-token") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Scheduled Op "${name}" sets reviewAgent and adds the permission "${scope}: write". The review job runs the change's code and may hold only "id-token: write".`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The chant release the note job installs: this lexicon's own, which releases in lockstep with `@intentius/chant`. */
|
|
82
|
+
function chantVersion(): string {
|
|
83
|
+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown };
|
|
84
|
+
if (typeof pkg.version !== "string" || pkg.version === "") {
|
|
85
|
+
throw new Error("the gitlab lexicon's package.json has no version to pin the review note job's chant to");
|
|
86
|
+
}
|
|
87
|
+
return pkg.version;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What the note job runs under node. The inputs are JSON literals from this file; nothing comes from the merge request. */
|
|
91
|
+
function noteModule(op: string, job: string, artifactDir: string, branches: readonly string[] | undefined): string {
|
|
92
|
+
return [
|
|
93
|
+
'import { forgeFromEnv } from "@intentius/chant/pr-forge";',
|
|
94
|
+
'import { postOpenReviewNotes } from "@intentius/chant/op/activities/review-agent";',
|
|
95
|
+
`const branches = ${JSON.stringify(branches ?? null)} ?? [process.env.CI_DEFAULT_BRANCH];`,
|
|
96
|
+
"const forge = forgeFromEnv(\"gitlab\");",
|
|
97
|
+
"let failed = false;",
|
|
98
|
+
"for (const branch of branches) {",
|
|
99
|
+
` const { posts, warnings } = await postOpenReviewNotes({ forge, branch, job: ${JSON.stringify(job)}, artifactDir: ${JSON.stringify(artifactDir)}, op: ${JSON.stringify(op)} });`,
|
|
100
|
+
" for (const p of posts) console.log(`!${p.pr} at ${p.head.slice(0, 12)}: ${p.skipped ?? `posted, risk ${p.risk}`}`);",
|
|
101
|
+
" for (const w of warnings) { console.error(w); failed = true; }",
|
|
102
|
+
"}",
|
|
103
|
+
"if (failed) process.exitCode = 1;",
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The note job's script: install the pinned chant outside any checkout and run {@link noteModule} with tsx. */
|
|
108
|
+
function noteScript(version: string, module: string): string[] {
|
|
109
|
+
return [
|
|
110
|
+
'dir="${TMPDIR:-/tmp}/chant-review-note"',
|
|
111
|
+
'mkdir -p "$dir"',
|
|
112
|
+
'cd "$dir"',
|
|
113
|
+
"echo '{\"private\":true}' > package.json",
|
|
114
|
+
`npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`,
|
|
115
|
+
["cat > note.mjs <<'NOTE'", module, "NOTE"].join("\n"),
|
|
116
|
+
"node --import tsx note.mjs",
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface GitlabReviewJobsInput {
|
|
121
|
+
/** The Op's name: what `chant run` targets and what keys the note. */
|
|
122
|
+
name: string;
|
|
123
|
+
/** The review job's name: the Op's job name. */
|
|
124
|
+
jobName: string;
|
|
125
|
+
stage: string;
|
|
126
|
+
image: string;
|
|
127
|
+
/** The review job's `rules:`, the Op's merge request rules. */
|
|
128
|
+
rules: unknown[];
|
|
129
|
+
/** The review job's script after the token unset: cd, setup, before-script, the invocation and the extra script. */
|
|
130
|
+
script: string[];
|
|
131
|
+
/** The Op's own per-job environment (`ScheduledOpSpec.variables`). */
|
|
132
|
+
variables?: Record<string, string>;
|
|
133
|
+
idTokens?: Record<string, unknown>;
|
|
134
|
+
/** The review directory from the repository root: `.chant/review`, under a workspace member's directory for a member. */
|
|
135
|
+
artifactDir: string;
|
|
136
|
+
/** The branches the merge requests merge into, from the trigger; the default branch when absent. */
|
|
137
|
+
branches?: readonly string[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The review job and the note job of one split review. */
|
|
141
|
+
export function gitlabReviewJobs(i: GitlabReviewJobsInput): { review: Record<string, unknown>; note: Record<string, unknown> } {
|
|
142
|
+
const review = {
|
|
143
|
+
stage: i.stage,
|
|
144
|
+
image: i.image,
|
|
145
|
+
resource_group: i.jobName,
|
|
146
|
+
...(i.idTokens ? { id_tokens: i.idTokens } : {}),
|
|
147
|
+
variables: { ...(i.variables ?? {}), [REVIEW_DIR_ENV]: REVIEW_DIR },
|
|
148
|
+
rules: i.rules,
|
|
149
|
+
// The merge request's code runs in this job, so its shell drops every forge token first.
|
|
150
|
+
script: [GITLAB_DROP_FORGE_TOKENS, ...i.script],
|
|
151
|
+
artifacts: { paths: [i.artifactDir], expire_in: REVIEW_ARTIFACT_EXPIRY },
|
|
152
|
+
};
|
|
153
|
+
const note = {
|
|
154
|
+
stage: i.stage,
|
|
155
|
+
image: i.image,
|
|
156
|
+
resource_group: reviewNoteJobName(i.jobName),
|
|
157
|
+
// Nothing of the repository is needed: the review is read from the review job's artifacts through the API.
|
|
158
|
+
variables: { GIT_STRATEGY: "none" },
|
|
159
|
+
rules: [{ if: GITLAB_REVIEW_NOTES_RULE }],
|
|
160
|
+
script: noteScript(chantVersion(), noteModule(i.name, i.jobName, i.artifactDir, i.branches)),
|
|
161
|
+
};
|
|
162
|
+
return { review, note };
|
|
163
|
+
}
|