@intentius/chant-lexicon-gitlab 0.124.54 → 0.124.55

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 (34) 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 +107 -22
  10. package/dist/components/generate-op-waves-pipeline.d.ts +12 -0
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +59 -5
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +112 -10
  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/agent-jobs.ts +176 -0
  22. package/src/components/control-pipeline.ts +38 -0
  23. package/src/components/generate-op-pipeline.agent.test.ts +99 -0
  24. package/src/components/generate-op-pipeline.review.test.ts +72 -7
  25. package/src/components/generate-op-pipeline.ts +117 -23
  26. package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
  27. package/src/components/generate-op-waves-pipeline.test.ts +17 -0
  28. package/src/components/generate-op-waves-pipeline.ts +68 -5
  29. package/src/components/generate-pipeline.ts +117 -9
  30. package/src/components/generate-pr-pipeline.comment-locks.test.ts +43 -0
  31. package/src/components/generate-pr-pipeline.comment-preview.test.ts +61 -0
  32. package/src/components/generate-pr-pipeline.control.test.ts +81 -0
  33. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +46 -0
  34. package/src/components/review-jobs.ts +163 -0
@@ -35,9 +35,11 @@ import { assertGitlabBranch, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTO
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, } 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,42 @@ 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
+ const stages = ["plan", PR_PLAN_NOTE_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
378
  const noteJob = named(PR_PLAN_NOTE_JOB);
348
- const post = prLoopPostJob("gitlab", env, member?.name);
379
+ const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined);
380
+ // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
381
+ const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
349
382
  const doc = {
350
383
  // Included files merge their top-level keys, so a member's file carries
351
384
  // 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 }] : [])] },
385
+ 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
386
  stages,
354
387
  variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
355
388
  [planJob]: {
@@ -360,7 +393,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
360
393
  variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
361
394
  // #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
362
395
  // #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)],
396
+ script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...dropControl, ...script(plan)],
364
397
  artifacts: { when: "always", paths: [reportPath] },
365
398
  },
366
399
  // #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 +401,61 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
368
401
  stage: PR_PLAN_NOTE_JOB,
369
402
  image,
370
403
  rules: [{ if: onPlanNotes }],
371
- script: [...enter, ...beforeScript, post.command],
404
+ script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
372
405
  },
406
+ ...Object.fromEntries(commentLocks.map(({ job, name, rule }) => [
407
+ name,
408
+ // The target branch's commit, so none of the merge request's code runs here.
409
+ { stage: job.jobName, image, rules: [{ if: rule }], variables: job.env, script: [...enter, ...beforeScript, job.line] },
410
+ ])),
411
+ ...(preview && previewName && previewNoteName && previewRule
412
+ ? {
413
+ [previewName]: {
414
+ stage: preview.jobName,
415
+ image,
416
+ rules: [{ if: previewRule }],
417
+ ...trading(identity?.plan),
418
+ variables: { ...preview.plan.run.env, ...tradeVars(identity?.plan) },
419
+ // A pipeline of the target branch holds the protected tokens, so the job drops them before it checks out the merge request's head.
420
+ script: [
421
+ ...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]),
422
+ ...dropControl,
423
+ "unset CHANT_RUNNER_KEY",
424
+ ...("line" in preview.head ? [preview.head.line] : []),
425
+ // The ledger while the clone can still fetch it: the plan reads the gate ledger from the local branch.
426
+ "git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true",
427
+ // The runner's clone keeps the job token in origin's URL; the merge request's code never sees it.
428
+ GITLAB_SCRUB_ORIGIN,
429
+ preview.base,
430
+ ...prLoopSetup(image, loop.engineSetup),
431
+ ...enter,
432
+ ...beforeScript,
433
+ ...(preview.plan.setup ?? []),
434
+ ...(preview.plan.build ?? []),
435
+ preview.plan.run.command,
436
+ ...extraScript,
437
+ ],
438
+ artifacts: { when: "always", paths: [reportPath] },
439
+ },
440
+ [previewNoteName]: {
441
+ stage: preview.noteJobName,
442
+ image,
443
+ needs: [{ job: previewName, artifacts: true }],
444
+ rules: [{ if: previewRule, when: "always" }],
445
+ variables: preview.post.env,
446
+ // The target branch's commit: the job's own clone, where none of the merge request's code runs.
447
+ script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), ...(preview.post.setup ?? []), preview.post.command],
448
+ },
449
+ }
450
+ : {}),
373
451
  ...Object.fromEntries(applies.map(({ job, name, stage }, i) => [
374
452
  name,
375
453
  {
376
454
  stage,
377
455
  image,
378
456
  ...(i > 1 ? { needs: [applies[i - 1].name] } : {}),
379
- resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
457
+ // #4552: on the repo grain every apply of the repository shares one resource group.
458
+ resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name),
380
459
  // #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
381
460
  rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
382
461
  ...trading(applySpec(job)),
@@ -397,20 +476,43 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
397
476
  artifacts: { when: "always", paths: [reportPath], ...(job.wave ? { reports: { dotenv: WAVE_GREEN_DOTENV } } : {}) },
398
477
  },
399
478
  ])),
479
+ ...(control && controlJob
480
+ ? {
481
+ [controlJob]: {
482
+ stage: control.name,
483
+ image,
484
+ needs: [lastGreen],
485
+ rules: [{ if: onGreen }],
486
+ // The runner sets a needed job's dotenv values when the script starts, so the commit is read there.
487
+ script: [
488
+ ...Object.entries(control.env).map(([k, v]) => `export ${k}="${v}"`),
489
+ `bash <<'CONTROL_DISPATCH' || exit $?\n${control.script}\nCONTROL_DISPATCH`,
490
+ ],
491
+ },
492
+ }
493
+ : {}),
400
494
  ...(prEnv ? prEnv.jobs : {}),
401
495
  };
402
496
  const jobs = [
403
497
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
404
498
  { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
499
+ ...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
500
+ ...(preview && previewName && previewNoteName
501
+ ? [
502
+ { jobName: previewName, component: "merge request preview comment", stage: preview.jobName, needs: [] },
503
+ { jobName: previewNoteName, component: "merge request preview comment note", stage: preview.noteJobName, needs: [previewName] },
504
+ ]
505
+ : []),
405
506
  ...applies.map(({ job, name, stage }, i) => ({
406
507
  jobName: name,
407
508
  component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
408
509
  stage,
409
510
  needs: i > 1 ? [applies[i - 1].name] : [],
410
511
  })),
512
+ ...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control.name, needs: [lastGreen] }] : []),
411
513
  ...(prEnv ? prEnv.listed : []),
412
514
  ];
413
- const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...applies.map((a) => a.name), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]]));
515
+ const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...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
516
  return { yaml, stages, jobs, env };
415
517
  }
416
518
  /** The stage a pull request environment's jobs run in (#4298). */
@@ -434,7 +536,7 @@ function prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member,
434
536
  const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
435
537
  const environment = "pr-$CI_MERGE_REQUEST_IID";
436
538
  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];
539
+ const script = (j) => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
438
540
  const artifacts = { when: "always", paths: [reportPath] };
439
541
  // #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity first.
440
542
  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
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "c8ef64c664ec8553272336feca077fab045ddab3d6c84cc50180ff99e603526d",
4
+ "manifest.json": "f5a242ce00bc716bcb44314a1301000b9ecad9187d4591c776829885535369e2",
5
5
  "meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
6
6
  "types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
7
7
  "rules/artifact-no-expiry.ts": "0ca5bb972d4a542126e8a0229d43b55658394cb7dd9b9923b2c590bec1c2ad7d",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "91961711687524b02f38e71c0e81e7ee5a06fed36d763fa9b1296184fc0be51f"
58
+ "composite": "91aa3c4a176252cd5cd8ab7f02ecef7274e79a95aa85a1bfc31270ef32021897"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.54",
3
+ "version": "0.124.55",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.124.54",
3
+ "version": "0.124.55",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^5.9.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.124.54",
71
- "@intentius/chant-lexicon-github": "^0.124.54"
70
+ "@intentius/chant": "^0.124.55",
71
+ "@intentius/chant-lexicon-github": "^0.124.55"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@intentius/chant-lexicon-github": {
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The agent jobs on GitLab (#4616): `ScheduledOpSpec.agent`. What each job
3
+ * does is @intentius/chant/ci-agent's; this file lays them out in GitLab's
4
+ * terms.
5
+ *
6
+ * GitLab starts no pipeline on a note, and every job in a pipeline gets the
7
+ * same protected variables. So:
8
+ *
9
+ * - a comment agent runs in a pipeline of the default branch that the API, a
10
+ * trigger or a person starts with `CHANT_AGENT_PR` (the merge request's
11
+ * iid) and `CHANT_AGENT_ASK` (the ask). Whoever starts that pipeline is
12
+ * who asked. A drift agent follows the drift Op's job in its scheduled
13
+ * pipeline and runs when that job's run exited with chant's `drift` code.
14
+ * - the agent job unsets every forge token variable and drops the job token
15
+ * from the remote before the model runs, and keeps the agent directory as
16
+ * an artifact. It holds the model's key.
17
+ * - the push job runs in the same pipeline under environment
18
+ * `<agent>-push`, takes the artifact, and pushes with `tokenEnv`
19
+ * (`CHANT_FORGE_TOKEN` by default). Scope that variable to the
20
+ * `<agent>-push` environment, protected and masked, so the agent job never
21
+ * holds it.
22
+ *
23
+ * A workspace member's drift Op runs in the member's directory, so its
24
+ * artifact holds `<member>/.chant/agent` (#4619). Both agent jobs run at
25
+ * the repository root and take `dir`, that path, as their agent directory.
26
+ */
27
+
28
+ import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
29
+ import { gitlabTags, type CiRunner } from "@intentius/chant/ci-jobs";
30
+ import {
31
+ AGENT_DECISION_FILE,
32
+ AGENT_DIR,
33
+ AGENT_DRIFT_FILE,
34
+ AGENT_GITLAB_TOKEN_ENV,
35
+ AGENT_HEAD_REF,
36
+ agentCommandLines,
37
+ agentGitAuthLines,
38
+ agentHeadFetchLine,
39
+ agentSettingsEnv,
40
+ GITLAB_AGENT_ASK_VAR,
41
+ GITLAB_AGENT_PR_VAR,
42
+ type AgentSettings,
43
+ } from "@intentius/chant/ci-agent";
44
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
45
+
46
+ /** The dotenv the drift Op's job leaves, which says whether it found drift. */
47
+ const DRIFT_DOTENV = `${AGENT_DIR}/drift.env`;
48
+ const AGENT_ARTIFACT_EXPIRY = "7 days";
49
+
50
+ /** The comment agent's rule: a pipeline of the default branch started with the merge request and the ask. */
51
+ export const GITLAB_AGENT_RULE = `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger|chat)$/ && $${GITLAB_AGENT_PR_VAR} =~ /^[0-9]+$/ && $${GITLAB_AGENT_ASK_VAR} && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`;
52
+
53
+ export function gitlabAgentPushJobName(jobName: string): string {
54
+ return `${jobName}-push`;
55
+ }
56
+
57
+ /** The environment the push job runs under, which the push token is scoped to. */
58
+ export function gitlabAgentEnvironment(jobName: string): string {
59
+ return `${jobName}-push`;
60
+ }
61
+
62
+ /** The remote without the job token GitLab put in its URL. */
63
+ const PLAIN_REMOTE = 'git remote set-url origin "$CI_SERVER_URL/$CI_PROJECT_PATH.git"';
64
+ /** The ledger branch, for the run count and the record. */
65
+ const LEDGER_FETCH = "git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true";
66
+
67
+ /**
68
+ * The drift Op's script lines (#4616): the run with its output kept for the
69
+ * agent, and `CHANT_AGENT_DRIFT=1` in a dotenv when it exits with chant's
70
+ * `drift` code. The job still exits with the run's code.
71
+ */
72
+ export function gitlabDriftRunLines(invocation: string): string[] {
73
+ return [
74
+ `mkdir -p ${AGENT_DIR}`,
75
+ `set +e; { ${invocation}; echo "$?" > ${AGENT_DIR}/code; } 2>&1 | tee ${AGENT_DIR}/${AGENT_DRIFT_FILE}; code=$(cat ${AGENT_DIR}/code); set -e`,
76
+ `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "CHANT_AGENT_DRIFT=1" > ${DRIFT_DOTENV}; else echo "CHANT_AGENT_DRIFT=0" > ${DRIFT_DOTENV}; fi`,
77
+ 'exit "$code"',
78
+ ];
79
+ }
80
+
81
+ /** The drift Op job's artifacts: the output for the agent and the dotenv. */
82
+ export function gitlabDriftArtifacts(dir: string): Record<string, unknown> {
83
+ return { when: "always", paths: [dir], reports: { dotenv: `${dir}/drift.env` }, expire_in: AGENT_ARTIFACT_EXPIRY };
84
+ }
85
+
86
+ export interface GitlabAgentJobsInput {
87
+ jobName: string;
88
+ stage: string;
89
+ image: string;
90
+ settings: AgentSettings;
91
+ runner?: CiRunner;
92
+ /** Setup and before-script lines, run before chant in both jobs. */
93
+ script: readonly string[];
94
+ /** The variable holding the push token. Default {@link AGENT_GITLAB_TOKEN_ENV}. */
95
+ tokenEnv?: string;
96
+ bin?: string;
97
+ /** For a drift agent: the Op's job and its rules. */
98
+ after?: { job: string; rules: unknown[] };
99
+ /** The agent directory from the repository root: where the drift Op's artifact put the drift. Default {@link AGENT_DIR}. */
100
+ dir?: string;
101
+ }
102
+
103
+ /** The agent job and the push job. */
104
+ export function gitlabAgentJobs(i: GitlabAgentJobsInput): { agent: Record<string, unknown>; push: Record<string, unknown> } {
105
+ const s = i.settings;
106
+ const dir = i.dir ?? AGENT_DIR;
107
+ const lines = agentCommandLines("gitlab", i.bin, dir);
108
+ const comment = s.on === "comment";
109
+ const tags = gitlabTags(i.runner);
110
+ const variables = { ...agentSettingsEnv(s), GIT_DEPTH: "0" };
111
+ const rules = comment ? [{ if: GITLAB_AGENT_RULE }] : i.after!.rules.map((r) => ({ ...(r as Record<string, unknown>), when: "always" }));
112
+ const head = comment ? [agentHeadFetchLine("gitlab", GITLAB_AGENT_PR_VAR)] : [];
113
+ // A drift agent's job runs whatever the drift check found, and stops here when it found none.
114
+ const driftGate = comment ? [] : [`if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the agent does not run"; exit 0; fi`];
115
+ const agent: Record<string, unknown> = {
116
+ stage: i.stage,
117
+ image: i.image,
118
+ ...(tags ? { tags } : {}),
119
+ resource_group: i.jobName,
120
+ timeout: `${s.timeout + 15} minutes`,
121
+ variables,
122
+ rules,
123
+ ...(comment ? {} : { needs: [{ job: i.after!.job, artifacts: true }] }),
124
+ script: [
125
+ ...driftGate,
126
+ ...head,
127
+ LEDGER_FETCH,
128
+ PLAIN_REMOTE,
129
+ ...i.script,
130
+ // No forge token reaches the model: the decision needs none on GitLab, and the push job checks the merge request.
131
+ GITLAB_DROP_FORGE_TOKENS,
132
+ lines.prepare,
133
+ lines.run,
134
+ ],
135
+ artifacts: { when: "always", paths: [dir], expire_in: AGENT_ARTIFACT_EXPIRY },
136
+ };
137
+ const tokenVar = i.tokenEnv ?? AGENT_GITLAB_TOKEN_ENV;
138
+ const push: Record<string, unknown> = {
139
+ stage: i.stage,
140
+ image: i.image,
141
+ ...(tags ? { tags } : {}),
142
+ resource_group: gitlabAgentPushJobName(i.jobName),
143
+ environment: { name: gitlabAgentEnvironment(i.jobName), action: "access" },
144
+ variables,
145
+ rules,
146
+ needs: [{ job: i.jobName, artifacts: true }],
147
+ script: [
148
+ // The agent job leaves a decision whenever it ran; with none, the drift check found nothing.
149
+ ...(comment ? [] : [`if [ ! -f ${dir}/${AGENT_DECISION_FILE} ]; then echo "the agent did not run"; exit 0; fi`]),
150
+ ...head,
151
+ LEDGER_FETCH,
152
+ PLAIN_REMOTE,
153
+ ...i.script,
154
+ `export CHANT_GITLAB_TOKEN="$${tokenVar}"`,
155
+ ...agentGitAuthLines(tokenVar, "oauth2"),
156
+ lines.land,
157
+ ],
158
+ };
159
+ return { agent, push };
160
+ }
161
+
162
+ /** The header lines a file with agent jobs carries: how to start a comment agent and where the token goes. */
163
+ export function gitlabAgentHeader(jobName: string, s: AgentSettings, tokenVar = AGENT_GITLAB_TOKEN_ENV): string[] {
164
+ return [
165
+ comment(s)
166
+ ? `# ${jobName}: answers ${s.comment} from a pipeline of the default branch started with ${GITLAB_AGENT_PR_VAR}=<iid> and ${GITLAB_AGENT_ASK_VAR}=<ask>`
167
+ : `# ${jobName}: after the drift check exits ${CHANT_EXIT.drift}, opens or updates a merge request from ${s.branch}`,
168
+ `# ${gitlabAgentPushJobName(jobName)} pushes with ${tokenVar}: scope it to environment ${gitlabAgentEnvironment(jobName)}, protected and masked; the model's key is ${s.keyEnv}`,
169
+ `# the fetched head is ${AGENT_HEAD_REF}; runs are limited to ${s.maxRuns ?? "no fixed number of"} a day on the ledger` +
170
+ (s.maxCostUsd !== undefined ? `, and to $${s.maxCostUsd} a day of recorded cost` : ""),
171
+ ];
172
+ }
173
+
174
+ function comment(s: AgentSettings): boolean {
175
+ return s.on === "comment";
176
+ }