@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.
Files changed (42) hide show
  1. package/dist/components/agent-jobs.d.ts +68 -0
  2. package/dist/components/agent-jobs.d.ts.map +1 -0
  3. package/dist/components/agent-jobs.js +135 -0
  4. package/dist/components/control-pipeline.d.ts +24 -0
  5. package/dist/components/control-pipeline.d.ts.map +1 -0
  6. package/dist/components/control-pipeline.js +28 -0
  7. package/dist/components/generate-op-pipeline.d.ts +9 -0
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +147 -23
  10. package/dist/components/generate-op-waves-pipeline.d.ts +25 -2
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +110 -12
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +135 -11
  15. package/dist/components/review-jobs.d.ts +72 -0
  16. package/dist/components/review-jobs.d.ts.map +1 -0
  17. package/dist/components/review-jobs.js +124 -0
  18. package/dist/integrity.json +2 -2
  19. package/dist/manifest.json +1 -1
  20. package/package.json +3 -3
  21. package/src/components/__golden__/op-waves-jobs.yml +11 -1
  22. package/src/components/agent-jobs.ts +176 -0
  23. package/src/components/control-pipeline.ts +38 -0
  24. package/src/components/generate-op-pipeline.agent.test.ts +99 -0
  25. package/src/components/generate-op-pipeline.cloud.test.ts +40 -0
  26. package/src/components/generate-op-pipeline.review.test.ts +72 -7
  27. package/src/components/generate-op-pipeline.ts +157 -24
  28. package/src/components/generate-op-waves-pipeline.approval.test.ts +28 -0
  29. package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
  30. package/src/components/generate-op-waves-pipeline.guard.test.ts +63 -0
  31. package/src/components/generate-op-waves-pipeline.test.ts +22 -3
  32. package/src/components/generate-op-waves-pipeline.ts +123 -12
  33. package/src/components/generate-pipeline.ts +143 -9
  34. package/src/components/generate-pr-pipeline.comment-locks.test.ts +43 -0
  35. package/src/components/generate-pr-pipeline.comment-preview.test.ts +61 -0
  36. package/src/components/generate-pr-pipeline.comment-targets.test.ts +20 -0
  37. package/src/components/generate-pr-pipeline.control.test.ts +81 -0
  38. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +46 -0
  39. package/src/components/generate-pr-pipeline.respond.test.ts +27 -0
  40. package/src/components/generate-pr-pipeline.runners.test.ts +41 -0
  41. package/src/components/generate-pr-pipeline.stale-notes.test.ts +24 -0
  42. package/src/components/review-jobs.ts +163 -0
@@ -24,8 +24,9 @@
24
24
  * arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
25
25
  * scope and the Developer role; the job token cannot retry jobs).
26
26
  *
27
- * A `pr-review` wave (#3684) adds `<name>-record-plans`, run in merge request
28
- * pipelines only, which records the digests the merge request's head plans.
27
+ * A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
28
+ * `<name>-record-plans`, run in merge request pipelines only, which records
29
+ * the digests the merge request's head plans.
29
30
  *
30
31
  * `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
31
32
  * GitHub runner group is a spec error here. A wave's `jobs` renders its
@@ -37,14 +38,40 @@
37
38
  * entry per cloud, and its script starts by trading them for those
38
39
  * identities and unsetting the token variables, before any line of the
39
40
  * checkout runs.
41
+ *
42
+ * With `control` on the spec (#4617), a `control-dispatch` job in a
43
+ * `control` stage needs the last wave's jobs, whose artifacts hold every
44
+ * job's outcome file. It adds them up with `chant run wave --waves-outcome`
45
+ * (through the product's bin) into `CHANT_CONTROL_OUTCOME` and sends the
46
+ * control repo one dispatch carrying it and `$CI_COMMIT_SHA`. A last wave
47
+ * whose runs failed (exit 1) still lets it run; a wave that waits at its
48
+ * gate stops it with the waves, and it runs once the retried job carries
49
+ * on. A CI/CD variable reaches every job, so every other job starts by
50
+ * unsetting the control repo's token variables and the product's two
51
+ * secret names; the dispatch step reads the token from the product's
52
+ * variable alone and hands it to curl on its stdin (#4568).
53
+ *
54
+ * Each wave job's script starts with the apply-job guard (#4624,
55
+ * `@intentius/chant/ci-job-guard`), before the trade: it stops the job
56
+ * unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
57
+ * `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
58
+ * holds each of the job's tags. A refused guard exits chant's `misplaced`
59
+ * (9), outside the job's allowed failure, so the job fails and the waves
60
+ * after it stop.
40
61
  */
41
62
  import { assertGitlabBranch, assertGitlabEnvironment, assertRunnerLabels, CI_IMAGE, environmentDoc, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, GITLAB_SCHEDULE_SOURCE, gitlabIdTokensDoc, gitlabPushRule, gitlabSetupScript, gitlabTags, nonEmpty, pushBranches, } from "@intentius/chant/ci-jobs";
42
- import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand } from "@intentius/chant/op/op-waves";
63
+ import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, OP_WAVES_APPROVAL_STATUS_VAR, opWavesApprovalStatusCommand, opWavesPostsApprovalStatus, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
64
+ import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
43
65
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
66
+ import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
67
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
68
+ import { resolveProductWords } from "@intentius/chant/ci-product-words";
44
69
  import { emitGitlabFile } from "./ci-jobs.js";
70
+ /** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
71
+ export const OP_WAVE_ALLOWED_EXIT = CHANT_EXIT.failed;
45
72
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
46
73
  export function generateGitlabOpWavesPipeline(spec, options) {
47
- const jobs = opWaveJobs(spec, options.specFile);
74
+ const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
48
75
  const image = options.image ?? CI_IMAGE;
49
76
  const subject = `Op waves "${spec.name}"`;
50
77
  const rules = pushBranches(spec.branches).map((branch) => {
@@ -69,8 +96,15 @@ export function generateGitlabOpWavesPipeline(spec, options) {
69
96
  }
70
97
  }
71
98
  const specTags = gitlabTags(spec.runner);
72
- const byReview = spec.waves.some((w) => w.approval === "pr-review");
99
+ const product = resolveProductWords(options.words ?? {}).product;
100
+ // #4630: a pr-review or pull-request wave records each head's plans and posts the approval status.
101
+ const byReview = opWavesPostsApprovalStatus(spec);
73
102
  const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
103
+ // #4617: the control repo's token variables, which every job but the dispatch drops first.
104
+ const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
105
+ const dropControl = control ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, control.tokenSecret, control.readTokenSecret])].join(" ")}`] : [];
106
+ if (control)
107
+ stages.push("control");
74
108
  if (spec.resume)
75
109
  stages.push("resume");
76
110
  const entries = [["stages", stages]];
@@ -86,7 +120,19 @@ export function generateGitlabOpWavesPipeline(spec, options) {
86
120
  ...(specTags ? { tags: specTags } : {}),
87
121
  variables: { ...GITLAB_FULL_HISTORY },
88
122
  rules: [{ if: GITLAB_MERGE_REQUEST_SOURCE }],
89
- script: [...(options.beforeScript ?? []), ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ")],
123
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
124
+ },
125
+ ]);
126
+ // #4630: a GitLab approval starts no pipeline, so a schedule of the target branch posts every open merge request's status.
127
+ entries.push([
128
+ `${spec.name}-approval-status`,
129
+ {
130
+ stage: "plans",
131
+ image,
132
+ ...(specTags ? { tags: specTags } : {}),
133
+ variables: { ...GITLAB_FULL_HISTORY },
134
+ rules: [{ if: `${GITLAB_SCHEDULE_SOURCE} && $${OP_WAVES_APPROVAL_STATUS_VAR} == "1"` }],
135
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesApprovalStatusCommand(options.specFile, "gitlab", options.bin).join(" ")],
90
136
  },
91
137
  ]);
92
138
  }
@@ -100,6 +146,16 @@ export function generateGitlabOpWavesPipeline(spec, options) {
100
146
  const cloud = group?.cloud ?? wave.cloud;
101
147
  const declared = gitlabIdTokensDoc(group?.idTokens ?? wave.idTokens);
102
148
  const idTokens = cloud ? { ...declared, ...gitlabTradeIdTokens(cloud) } : declared;
149
+ // #4624: the apply-job guard, the script's first line, before the trade.
150
+ const runner = group?.runner ?? wave.runner ?? spec.runner;
151
+ const guard = jobGuardSpec({
152
+ forge: "gitlab",
153
+ job: opWaveJobLabel(spec.name, job),
154
+ product,
155
+ ...(environment ? { environment: environment.name } : {}),
156
+ branches: pushBranches(spec.branches),
157
+ ...(runner !== undefined ? { runner } : {}),
158
+ });
103
159
  entries.push([
104
160
  job.jobName,
105
161
  {
@@ -109,16 +165,52 @@ export function generateGitlabOpWavesPipeline(spec, options) {
109
165
  resource_group: `${spec.name}-${wave.name}`,
110
166
  ...(environment ? { environment: environmentDoc(environment) } : {}),
111
167
  ...(idTokens ? { id_tokens: idTokens } : {}),
112
- variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
168
+ variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}), [JOB_GUARD_ENV]: JSON.stringify(guard) },
113
169
  rules,
114
170
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
115
- script: [...(cloud ? gitlabTradeLines() : []), ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
116
- // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
117
- allow_failure: { exit_codes: [1] },
171
+ script: [jobGuardLine(), ...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
172
+ // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it,
173
+ // a refused guard (9, #4624) included.
174
+ allow_failure: { exit_codes: [OP_WAVE_ALLOWED_EXIT] },
118
175
  artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
119
176
  },
120
177
  ]);
121
178
  }
179
+ if (control) {
180
+ const dispatch = controlDispatchJob("gitlab", control, "$CI_COMMIT_SHA", {
181
+ ...(options.words ? { words: options.words } : {}),
182
+ ...(spec.control.sentCheck ? { sentCheck: spec.control.sentCheck } : {}),
183
+ ...(spec.control.markSent ? { markSent: spec.control.markSent } : {}),
184
+ outcome: true,
185
+ });
186
+ entries.push([
187
+ dispatch.name,
188
+ {
189
+ stage: "control",
190
+ image,
191
+ ...(specTags ? { tags: specTags } : {}),
192
+ rules,
193
+ // The last wave's jobs hand on every job's outcome file through their artifacts.
194
+ needs: opWavesLastJobs(jobs),
195
+ // The setup and the outcome run in a subshell without the control token, and only the outcome reaches stdout.
196
+ script: [
197
+ [
198
+ 'outcome="$(',
199
+ ...dropControl,
200
+ "{ :",
201
+ ...setupOf[0],
202
+ ...(options.beforeScript ?? []),
203
+ "} 1>&2",
204
+ opWavesOutcomeCommand(options.specFile, options.bin).join(" "),
205
+ ')" || exit 1',
206
+ ].join("\n"),
207
+ `export ${CONTROL_OUTCOME_VAR}="$outcome"`,
208
+ ...Object.entries(dispatch.env).map(([k, v]) => `export ${k}="${v}"`),
209
+ `bash <<'CONTROL_DISPATCH' || exit $?\n${dispatch.script}\nCONTROL_DISPATCH`,
210
+ ],
211
+ },
212
+ ]);
213
+ }
122
214
  if (spec.resume) {
123
215
  entries.push([
124
216
  `${spec.name}-resume`,
@@ -127,7 +219,7 @@ export function generateGitlabOpWavesPipeline(spec, options) {
127
219
  image,
128
220
  ...(specTags ? { tags: specTags } : {}),
129
221
  rules: [{ if: GITLAB_SCHEDULE_SOURCE }],
130
- script: [...(options.beforeScript ?? []), opWavesResumeCommand(spec).join(" ")],
222
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesResumeCommand(spec, options.bin).join(" ")],
131
223
  },
132
224
  ]);
133
225
  }
@@ -135,10 +227,16 @@ export function generateGitlabOpWavesPipeline(spec, options) {
135
227
  `# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
136
228
  "# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
137
229
  "# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed",
138
- "# failure, and the waves after it run what does not depend on those runs.",
230
+ "# failure, and the waves after it run what does not depend on those runs. A job",
231
+ "# whose guard finds it running where the spec did not set it to run exits 9 and",
232
+ "# stops the waves after it.",
139
233
  ...(spec.resume
140
234
  ? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
141
235
  : []),
236
+ ...(byReview
237
+ ? [`# The ${spec.name}-approval-status job posts each open merge request's approval status from a pipeline schedule that sets ${OP_WAVES_APPROVAL_STATUS_VAR}=1.`]
238
+ : []),
239
+ ...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
142
240
  ];
143
241
  const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
144
242
  return { files: [{ name, yaml: emitGitlabFile(entries, header) }], jobs };
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA0BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAaH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAkCjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}
@@ -31,13 +31,15 @@
31
31
  * demonstration: one generator-option edit reflects in every job without
32
32
  * touching the component declarations.
33
33
  */
34
- import { assertGitlabBranch, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, gitlabPushRule, } from "@intentius/chant/ci-jobs";
34
+ import { assertGitlabBranch, assertRunnerLabels, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, gitlabPushRule, gitlabTags, } from "@intentius/chant/ci-jobs";
35
35
  import { resolveComponentGraph } from "@intentius/chant/components/driver";
36
36
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
37
37
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
38
- import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, GITLAB_APPLY_PR_VAR, GITLAB_PLAN_NOTES_VAR, gitlabPlanJobName, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, PR_ENV_REPORT_DIR, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
38
+ import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, GITLAB_APPLY_PR_VAR, GITLAB_PLAN_NOTES_VAR, gitlabPlanJobName, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, prCommentLockJobs, prCommentPreviewJob, PR_ENV_REPORT_DIR, PR_ENV_POST_JOB, PR_STALE_JOB, prStaleJob, prLoopJobStage, prLoopRunner, } from "@intentius/chant/components/pr-pipeline";
39
+ import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
39
40
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
40
41
  import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
42
+ import { CONTROL_TOKEN_VARS, controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
41
43
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
42
44
  import { emitGitlabFile } from "./ci-jobs.js";
43
45
  /**
@@ -303,6 +305,8 @@ function gitlabApplyPrRule(branch) {
303
305
  function gitlabPlanNotesRule(branch) {
304
306
  return `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_PLAN_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == ${branch}`;
305
307
  }
308
+ /** Drops the credentials GitLab's runner writes into origin's URL (#4620), before a merge request's code runs in a pipeline of the target branch. */
309
+ const GITLAB_SCRUB_ORIGIN = `git remote set-url origin "$(git remote get-url origin | sed -E 's#//[^@/]*@#//#')"`;
306
310
  /** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
307
311
  const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
308
312
  function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript, options) {
@@ -335,7 +339,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
335
339
  // The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
336
340
  // #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
337
341
  const handOff = (job) => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
338
- const script = (job) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
342
+ const script = (job) => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
339
343
  // #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
340
344
  const identity = loop.identity;
341
345
  const trading = (spec) => spec ? { id_tokens: gitlabTradeIdTokens(spec) } : {};
@@ -343,13 +347,45 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
343
347
  const applySpec = (job) => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
344
348
  // #4298: a pull request environment's jobs, in a stage of their own after the plan.
345
349
  const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
346
- const stages = ["plan", PR_PLAN_NOTE_JOB, ...applies.map((a) => a.stage), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
350
+ // #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
351
+ const lastGreen = loop.control && !loop.applyBeforeMerge && greens.length > 0 ? greens[greens.length - 1].name : undefined;
352
+ const control = loop.control && lastGreen
353
+ ? controlDispatchJob("gitlab", loop.control.target, loop.waveGate ? "$GREEN_SHA" : "$CI_COMMIT_SHA", {
354
+ ...(loop.words ? { words: loop.words } : {}),
355
+ ...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
356
+ ...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
357
+ })
358
+ : undefined;
359
+ const controlJob = control ? named(control.name) : undefined;
360
+ // A CI/CD variable reaches every job, so the plan job drops the control repo's tokens with the forge tokens.
361
+ const dropControl = loop.control
362
+ ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, loop.control.target.tokenSecret, loop.control.target.readTokenSecret])].join(" ")}`]
363
+ : [];
364
+ // #4588: `/<product> lock` and `/<product> unlock`: GitLab starts no pipeline on a note, so each runs in a
365
+ // pipeline of the target branch started with CHANT_LOCK_PR or CHANT_UNLOCK_PR set, as the apply before merge does.
366
+ const targetBranch = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
367
+ const commentLocks = prCommentLockJobs("gitlab", loop).map((job) => ({
368
+ job,
369
+ name: named(job.jobName),
370
+ rule: `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${job.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}`,
371
+ }));
372
+ // #4620: `/<product> <plan word>`: a pipeline of the target branch started with CHANT_PREVIEW_PR set previews the merge request's head again.
373
+ const preview = prCommentPreviewJob("gitlab", env, loop, member?.name);
374
+ const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
375
+ const previewName = preview ? named(preview.jobName) : undefined;
376
+ const previewNoteName = preview ? named(preview.noteJobName) : undefined;
377
+ // #4628: a push to the target branch marks the open merge requests' notes posted against another commit stale.
378
+ const stale = prStaleJob("gitlab", env, loop, member?.name);
379
+ const staleName = stale ? named(PR_STALE_JOB) : undefined;
380
+ const stages = ["plan", PR_PLAN_NOTE_JOB, ...(stale ? [PR_STALE_JOB] : []), ...commentLocks.map((c) => c.job.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...applies.map((a) => a.stage), ...(control ? [control.name] : []), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
347
381
  const noteJob = named(PR_PLAN_NOTE_JOB);
348
- const post = prLoopPostJob("gitlab", env, member?.name);
382
+ const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined, { ...(loop.staleNotes ? { staleNotes: true } : {}), ...(loop.respond ? { respond: loop.respond } : {}), ...(loop.decide ? { decide: loop.decide } : {}) });
383
+ // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
384
+ const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
349
385
  const doc = {
350
386
  // Included files merge their top-level keys, so a member's file carries
351
387
  // the rules every member shares and no name: two names would collide.
352
- workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
388
+ workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...commentLocks.map((c) => ({ if: c.rule })), ...(previewRule ? [{ if: previewRule }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
353
389
  stages,
354
390
  variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
355
391
  [planJob]: {
@@ -360,7 +396,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
360
396
  variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
361
397
  // #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
362
398
  // #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
363
- script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
399
+ script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...dropControl, ...script(plan)],
364
400
  artifacts: { when: "always", paths: [reportPath] },
365
401
  },
366
402
  // #4295, #4328: a job of the target branch's pipeline posts each open merge request's note and status from its plan job's report.
@@ -368,15 +404,68 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
368
404
  stage: PR_PLAN_NOTE_JOB,
369
405
  image,
370
406
  rules: [{ if: onPlanNotes }],
371
- script: [...enter, ...beforeScript, post.command],
407
+ ...(Object.keys(post.env).length > 0 ? { variables: post.env } : {}),
408
+ script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
372
409
  },
410
+ ...(stale && staleName
411
+ ? {
412
+ // The pushed commit of the target branch: none of a merge request's code is here.
413
+ [staleName]: { stage: PR_STALE_JOB, image, rules: [{ if: onMerge }], variables: stale.env, script: [...enter, ...beforeScript, stale.command] },
414
+ }
415
+ : {}),
416
+ ...Object.fromEntries(commentLocks.map(({ job, name, rule }) => [
417
+ name,
418
+ // The target branch's commit, so none of the merge request's code runs here.
419
+ { stage: job.jobName, image, rules: [{ if: rule }], variables: job.env, script: [...enter, ...beforeScript, job.line] },
420
+ ])),
421
+ ...(preview && previewName && previewNoteName && previewRule
422
+ ? {
423
+ [previewName]: {
424
+ stage: preview.jobName,
425
+ image,
426
+ rules: [{ if: previewRule }],
427
+ ...trading(identity?.plan),
428
+ variables: { ...preview.plan.run.env, ...tradeVars(identity?.plan) },
429
+ // A pipeline of the target branch holds the protected tokens, so the job drops them before it checks out the merge request's head.
430
+ script: [
431
+ ...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]),
432
+ ...dropControl,
433
+ "unset CHANT_RUNNER_KEY",
434
+ ...("line" in preview.head ? [preview.head.line] : []),
435
+ // The ledger while the clone can still fetch it: the plan reads the gate ledger from the local branch.
436
+ "git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true",
437
+ // The runner's clone keeps the job token in origin's URL; the merge request's code never sees it.
438
+ GITLAB_SCRUB_ORIGIN,
439
+ preview.base,
440
+ ...prLoopSetup(image, loop.engineSetup),
441
+ ...enter,
442
+ ...beforeScript,
443
+ ...(preview.plan.setup ?? []),
444
+ ...(preview.plan.build ?? []),
445
+ preview.plan.run.command,
446
+ ...extraScript,
447
+ ],
448
+ artifacts: { when: "always", paths: [reportPath] },
449
+ },
450
+ [previewNoteName]: {
451
+ stage: preview.noteJobName,
452
+ image,
453
+ needs: [{ job: previewName, artifacts: true }],
454
+ rules: [{ if: previewRule, when: "always" }],
455
+ variables: preview.post.env,
456
+ // The target branch's commit: the job's own clone, where none of the merge request's code runs.
457
+ script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), ...(preview.post.setup ?? []), preview.post.command],
458
+ },
459
+ }
460
+ : {}),
373
461
  ...Object.fromEntries(applies.map(({ job, name, stage }, i) => [
374
462
  name,
375
463
  {
376
464
  stage,
377
465
  image,
378
466
  ...(i > 1 ? { needs: [applies[i - 1].name] } : {}),
379
- resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
467
+ // #4552: on the repo grain every apply of the repository shares one resource group.
468
+ resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name),
380
469
  // #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
381
470
  rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
382
471
  ...trading(applySpec(job)),
@@ -397,20 +486,55 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
397
486
  artifacts: { when: "always", paths: [reportPath], ...(job.wave ? { reports: { dotenv: WAVE_GREEN_DOTENV } } : {}) },
398
487
  },
399
488
  ])),
489
+ ...(control && controlJob
490
+ ? {
491
+ [controlJob]: {
492
+ stage: control.name,
493
+ image,
494
+ needs: [lastGreen],
495
+ rules: [{ if: onGreen }],
496
+ // The runner sets a needed job's dotenv values when the script starts, so the commit is read there.
497
+ script: [
498
+ ...Object.entries(control.env).map(([k, v]) => `export ${k}="${v}"`),
499
+ `bash <<'CONTROL_DISPATCH' || exit $?\n${control.script}\nCONTROL_DISPATCH`,
500
+ ],
501
+ },
502
+ }
503
+ : {}),
400
504
  ...(prEnv ? prEnv.jobs : {}),
401
505
  };
402
506
  const jobs = [
403
507
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
404
508
  { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
509
+ ...(stale && staleName ? [{ jobName: staleName, component: "merge request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
510
+ ...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
511
+ ...(preview && previewName && previewNoteName
512
+ ? [
513
+ { jobName: previewName, component: "merge request preview comment", stage: preview.jobName, needs: [] },
514
+ { jobName: previewNoteName, component: "merge request preview comment note", stage: preview.noteJobName, needs: [previewName] },
515
+ ]
516
+ : []),
405
517
  ...applies.map(({ job, name, stage }, i) => ({
406
518
  jobName: name,
407
519
  component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
408
520
  stage,
409
521
  needs: i > 1 ? [applies[i - 1].name] : [],
410
522
  })),
523
+ ...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control.name, needs: [lastGreen] }] : []),
411
524
  ...(prEnv ? prEnv.listed : []),
412
525
  ];
413
- const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...applies.map((a) => a.name), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]]));
526
+ // #4625: each job on its stage's runner tags, the plan side's, the apply side's, or the default.
527
+ if (loop.runners) {
528
+ for (const stage of ["plan", "apply", "default"])
529
+ assertRunnerLabels("The merge-request pipeline", `runners.${stage}`, loop.runners[stage], "GitLab");
530
+ for (const job of jobs) {
531
+ const tags = gitlabTags(prLoopRunner(loop, prLoopJobStage(job.jobName, member?.name)));
532
+ const entry = doc[job.jobName];
533
+ if (tags && entry)
534
+ doc[job.jobName] = { ...entry, tags };
535
+ }
536
+ }
537
+ const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...(staleName ? [staleName] : []), ...commentLocks.map((c) => c.name), ...(previewName && previewNoteName ? [previewName, previewNoteName] : []), ...applies.map((a) => a.name), ...(controlJob ? [controlJob] : []), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]]));
414
538
  return { yaml, stages, jobs, env };
415
539
  }
416
540
  /** The stage a pull request environment's jobs run in (#4298). */
@@ -434,7 +558,7 @@ function prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member,
434
558
  const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
435
559
  const environment = "pr-$CI_MERGE_REQUEST_IID";
436
560
  const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
437
- const script = (j) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
561
+ const script = (j) => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
438
562
  const artifacts = { when: "always", paths: [reportPath] };
439
563
  // #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity first.
440
564
  const identity = loop.prEnvIdentity;
@@ -0,0 +1,72 @@
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
+ import type { OpTrigger } from "@intentius/chant/lexicon";
27
+ /** The variable `reviewAgent` reads its handoff directory from (`REVIEW_DIR_ENV`, review-agent.ts). */
28
+ export declare const REVIEW_DIR_ENV = "CHANT_REVIEW_DIR";
29
+ /** Where the review job writes the review, from the directory its script runs in. */
30
+ export declare const REVIEW_DIR = ".chant/review";
31
+ /** The variable a pipeline of the default branch is started with to run the note jobs. */
32
+ export declare const GITLAB_REVIEW_NOTES_VAR = "CHANT_REVIEW_NOTES";
33
+ /** The name of the note job beside the Op's own job. */
34
+ export declare function reviewNoteJobName(jobName: string): string;
35
+ /**
36
+ * The note job's rule: a pipeline of the default branch that a pipeline
37
+ * schedule, a person, the API or a trigger started with `CHANT_REVIEW_NOTES`
38
+ * set to `1`. A merge request pipeline's own trigger job starts a pipeline
39
+ * whose source is `pipeline`, which this rule leaves out.
40
+ */
41
+ export declare const GITLAB_REVIEW_NOTES_RULE = "$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_REVIEW_NOTES == \"1\" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH";
42
+ /**
43
+ * Refuse a spec whose review cannot be split the way this shape needs: a
44
+ * trigger with no merge request, a finding-mode that would post from the
45
+ * review job, or an additive scope other than `id-token: write`.
46
+ */
47
+ export declare function assertReviewSplit(name: string, trigger: OpTrigger, findingMode: string, permissions: Record<string, string> | undefined): void;
48
+ export interface GitlabReviewJobsInput {
49
+ /** The Op's name: what `chant run` targets and what keys the note. */
50
+ name: string;
51
+ /** The review job's name: the Op's job name. */
52
+ jobName: string;
53
+ stage: string;
54
+ image: string;
55
+ /** The review job's `rules:`, the Op's merge request rules. */
56
+ rules: unknown[];
57
+ /** The review job's script after the token unset: cd, setup, before-script, the invocation and the extra script. */
58
+ script: string[];
59
+ /** The Op's own per-job environment (`ScheduledOpSpec.variables`). */
60
+ variables?: Record<string, string>;
61
+ idTokens?: Record<string, unknown>;
62
+ /** The review directory from the repository root: `.chant/review`, under a workspace member's directory for a member. */
63
+ artifactDir: string;
64
+ /** The branches the merge requests merge into, from the trigger; the default branch when absent. */
65
+ branches?: readonly string[];
66
+ }
67
+ /** The review job and the note job of one split review. */
68
+ export declare function gitlabReviewJobs(i: GitlabReviewJobsInput): {
69
+ review: Record<string, unknown>;
70
+ note: Record<string, unknown>;
71
+ };
72
+ //# sourceMappingURL=review-jobs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review-jobs.d.ts","sourceRoot":"","sources":["../../src/components/review-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D,uGAAuG;AACvG,eAAO,MAAM,cAAc,qBAAqB,CAAC;AAEjD,qFAAqF;AACrF,eAAO,MAAM,UAAU,kBAAkB,CAAC;AAE1C,0FAA0F;AAC1F,eAAO,MAAM,uBAAuB,uBAAuB,CAAC;AAK5D,wDAAwD;AACxD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,qIAA2I,CAAC;AAEjL;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,GAAG,IAAI,CAkB9I;AAyCD,MAAM,WAAW,qBAAqB;IACpC,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,oHAAoH;IACpH,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,yHAAyH;IACzH,WAAW,EAAE,MAAM,CAAC;IACpB,oGAAoG;IACpG,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAED,2DAA2D;AAC3D,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,qBAAqB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsB7H"}
@@ -0,0 +1,124 @@
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
+ import { readFileSync } from "node:fs";
27
+ import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
28
+ /** The variable `reviewAgent` reads its handoff directory from (`REVIEW_DIR_ENV`, review-agent.ts). */
29
+ export const REVIEW_DIR_ENV = "CHANT_REVIEW_DIR";
30
+ /** Where the review job writes the review, from the directory its script runs in. */
31
+ export const REVIEW_DIR = ".chant/review";
32
+ /** The variable a pipeline of the default branch is started with to run the note jobs. */
33
+ export const GITLAB_REVIEW_NOTES_VAR = "CHANT_REVIEW_NOTES";
34
+ /** How long a review artifact is kept: long enough for a schedule to come round and post it. */
35
+ const REVIEW_ARTIFACT_EXPIRY = "7 days";
36
+ /** The name of the note job beside the Op's own job. */
37
+ export function reviewNoteJobName(jobName) {
38
+ return `${jobName}-note`;
39
+ }
40
+ /**
41
+ * The note job's rule: a pipeline of the default branch that a pipeline
42
+ * schedule, a person, the API or a trigger started with `CHANT_REVIEW_NOTES`
43
+ * set to `1`. A merge request pipeline's own trigger job starts a pipeline
44
+ * whose source is `pipeline`, which this rule leaves out.
45
+ */
46
+ export const GITLAB_REVIEW_NOTES_RULE = `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_REVIEW_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`;
47
+ /**
48
+ * Refuse a spec whose review cannot be split the way this shape needs: a
49
+ * trigger with no merge request, a finding-mode that would post from the
50
+ * review job, or an additive scope other than `id-token: write`.
51
+ */
52
+ export function assertReviewSplit(name, trigger, findingMode, permissions) {
53
+ if (trigger.kind !== "pull_request") {
54
+ throw new Error(`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.`);
55
+ }
56
+ if (findingMode !== "report") {
57
+ throw new Error(`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".`);
58
+ }
59
+ for (const [scope, level] of Object.entries(permissions ?? {})) {
60
+ if (level === "write" && scope !== "id-token") {
61
+ throw new Error(`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".`);
62
+ }
63
+ }
64
+ }
65
+ /** The chant release the note job installs: this lexicon's own, which releases in lockstep with `@intentius/chant`. */
66
+ function chantVersion() {
67
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
68
+ if (typeof pkg.version !== "string" || pkg.version === "") {
69
+ throw new Error("the gitlab lexicon's package.json has no version to pin the review note job's chant to");
70
+ }
71
+ return pkg.version;
72
+ }
73
+ /** What the note job runs under node. The inputs are JSON literals from this file; nothing comes from the merge request. */
74
+ function noteModule(op, job, artifactDir, branches) {
75
+ return [
76
+ 'import { forgeFromEnv } from "@intentius/chant/pr-forge";',
77
+ 'import { postOpenReviewNotes } from "@intentius/chant/op/activities/review-agent";',
78
+ `const branches = ${JSON.stringify(branches ?? null)} ?? [process.env.CI_DEFAULT_BRANCH];`,
79
+ "const forge = forgeFromEnv(\"gitlab\");",
80
+ "let failed = false;",
81
+ "for (const branch of branches) {",
82
+ ` const { posts, warnings } = await postOpenReviewNotes({ forge, branch, job: ${JSON.stringify(job)}, artifactDir: ${JSON.stringify(artifactDir)}, op: ${JSON.stringify(op)} });`,
83
+ " for (const p of posts) console.log(`!${p.pr} at ${p.head.slice(0, 12)}: ${p.skipped ?? `posted, risk ${p.risk}`}`);",
84
+ " for (const w of warnings) { console.error(w); failed = true; }",
85
+ "}",
86
+ "if (failed) process.exitCode = 1;",
87
+ ].join("\n");
88
+ }
89
+ /** The note job's script: install the pinned chant outside any checkout and run {@link noteModule} with tsx. */
90
+ function noteScript(version, module) {
91
+ return [
92
+ 'dir="${TMPDIR:-/tmp}/chant-review-note"',
93
+ 'mkdir -p "$dir"',
94
+ 'cd "$dir"',
95
+ "echo '{\"private\":true}' > package.json",
96
+ `npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`,
97
+ ["cat > note.mjs <<'NOTE'", module, "NOTE"].join("\n"),
98
+ "node --import tsx note.mjs",
99
+ ];
100
+ }
101
+ /** The review job and the note job of one split review. */
102
+ export function gitlabReviewJobs(i) {
103
+ const review = {
104
+ stage: i.stage,
105
+ image: i.image,
106
+ resource_group: i.jobName,
107
+ ...(i.idTokens ? { id_tokens: i.idTokens } : {}),
108
+ variables: { ...(i.variables ?? {}), [REVIEW_DIR_ENV]: REVIEW_DIR },
109
+ rules: i.rules,
110
+ // The merge request's code runs in this job, so its shell drops every forge token first.
111
+ script: [GITLAB_DROP_FORGE_TOKENS, ...i.script],
112
+ artifacts: { paths: [i.artifactDir], expire_in: REVIEW_ARTIFACT_EXPIRY },
113
+ };
114
+ const note = {
115
+ stage: i.stage,
116
+ image: i.image,
117
+ resource_group: reviewNoteJobName(i.jobName),
118
+ // Nothing of the repository is needed: the review is read from the review job's artifacts through the API.
119
+ variables: { GIT_STRATEGY: "none" },
120
+ rules: [{ if: GITLAB_REVIEW_NOTES_RULE }],
121
+ script: noteScript(chantVersion(), noteModule(i.name, i.jobName, i.artifactDir, i.branches)),
122
+ };
123
+ return { review, note };
124
+ }