@intentius/chant 0.59.0 → 0.60.0

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/build-params.d.ts +2 -2
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/lint.d.ts.map +1 -1
  4. package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
  5. package/dist/config.d.ts +4 -4
  6. package/dist/lexicon.d.ts +48 -5
  7. package/dist/lexicon.d.ts.map +1 -1
  8. package/dist/lifecycle/observe.d.ts +4 -4
  9. package/dist/op/activities/index.d.ts +1 -1
  10. package/dist/op/activities/index.d.ts.map +1 -1
  11. package/dist/op/activities/reconcile.d.ts +79 -7
  12. package/dist/op/activities/reconcile.d.ts.map +1 -1
  13. package/dist/op/gate-summary.d.ts +16 -4
  14. package/dist/op/gate-summary.d.ts.map +1 -1
  15. package/dist/params.d.ts +1 -1
  16. package/dist/project-root.d.ts +2 -2
  17. package/package.json +1 -1
  18. package/src/build-params.ts +2 -2
  19. package/src/cli/commands/build.ts +8 -8
  20. package/src/cli/commands/lint.test.ts +151 -0
  21. package/src/cli/commands/lint.ts +37 -4
  22. package/src/cli/handlers/graph.test.ts +4 -4
  23. package/src/cli/handlers/graph.ts +11 -11
  24. package/src/cli/handlers/lint.test.ts +107 -0
  25. package/src/cli/handlers/lint.ts +30 -0
  26. package/src/components/SPRAWL-VALIDATION.md +5 -5
  27. package/src/components/pilots/README.md +1 -1
  28. package/src/components/pilots/alb-ecs.pilot.ts +2 -2
  29. package/src/config.ts +4 -4
  30. package/src/discovery/fold-import.test.ts +1 -1
  31. package/src/discovery/fold-import.ts +3 -3
  32. package/src/lexicon.ts +49 -5
  33. package/src/lifecycle/observe.test.ts +2 -2
  34. package/src/lifecycle/observe.ts +8 -8
  35. package/src/lifecycle/release-ledger.test.ts +2 -2
  36. package/src/op/activities/index.ts +8 -1
  37. package/src/op/activities/reconcile.test.ts +238 -0
  38. package/src/op/activities/reconcile.ts +267 -13
  39. package/src/op/gate-summary.test.ts +62 -0
  40. package/src/op/gate-summary.ts +17 -5
  41. package/src/params.ts +1 -1
  42. package/src/project-root.ts +2 -2
@@ -204,8 +204,8 @@ describe("observeResources", () => {
204
204
  const stack = (opts as { stack?: string }).stack;
205
205
  calls.push(stack);
206
206
  // Different resources per stack — the multi-stack, per-component case
207
- // (#57 loomster). Bare-string stacks keep BARE ids (no `src` scope), so
208
- // the union is `db-a`+`db-b`, not stack-qualified: per-component ids are
207
+ // (#57). Bare-string stacks keep BARE ids (no `src` scope), so the
208
+ // union is `db-a`+`db-b`, not stack-qualified: per-component ids are
209
209
  // already unique and behold reads them bare. Qualification is a scoped
210
210
  // (`src`) feature — see the per-stack src test below.
211
211
  const resources: Record<string, ResourceMetadata> =
@@ -71,10 +71,10 @@ function qualifyObservation(obs: NormalizedObservation, stackName: string): Norm
71
71
  * (`read-failed`, #1089) rather than dropped, so a failed read is visibly a
72
72
  * hole instead of a silent absence.
73
73
  *
74
- * `stacks` (#57) is for a multi-stack, per-component project (e.g. loomster)
75
- * where there is no single stack named after the environment — AWS's
76
- * single-stack convention (`lexicons/aws/src/plugin.ts`'s `describeResources`,
77
- * absent an explicit `stack`) queries a stack that simply doesn't exist there,
74
+ * `stacks` (#57) is for a multi-stack, per-component project, where there is no
75
+ * single stack named after the environment — AWS's single-stack convention
76
+ * (`lexicons/aws/src/plugin.ts`'s `describeResources`, absent an explicit
77
+ * `stack`) queries a stack that simply doesn't exist there,
78
78
  * so the single-call path always observes zero nodes. When `stacks` is
79
79
  * present and non-empty, each observing plugin's `describeResources` is
80
80
  * called once per stack and the returned observations are merged. A stack entry
@@ -197,10 +197,10 @@ export async function observeResources(
197
197
  // Qualify ids by stack ONLY for a scoped (`src`) stack (#1162): that
198
198
  // is the multi-region case where the SAME bare LogicalResourceId
199
199
  // (e.g. `vpc`) exists in every stack, so a bare union would collide.
200
- // A bare-string stack (#57 loomster) has unique per-component ids and
201
- // is asked the whole-project entity set, so it keeps the bare-id
202
- // tri-state merge (present > not-observed > absent) that behold and
203
- // other consumers read.
200
+ // A bare-string stack (#57) has unique per-component ids and is asked
201
+ // the whole-project entity set, so it keeps the bare-id tri-state
202
+ // merge (present > not-observed > absent) that behold and other
203
+ // consumers read.
204
204
  parts.push(stack.src ? qualifyObservation(norm, stack.name) : norm);
205
205
  }
206
206
  observed = mergeObservations(parts);
@@ -256,10 +256,10 @@ describe("release-ledger", () => {
256
256
  test("a Forgejo instance resolves through the same env contract to its own host", () => {
257
257
  const env = {
258
258
  GITHUB_RUN_ID: "42",
259
- GITHUB_REPOSITORY: "intentius/loomster",
259
+ GITHUB_REPOSITORY: "acme/widgets",
260
260
  GITHUB_SERVER_URL: "https://forge.example.dev",
261
261
  };
262
- expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/intentius/loomster/actions/runs/42");
262
+ expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/acme/widgets/actions/runs/42");
263
263
  });
264
264
 
265
265
  test("GitLab CI env records the project path and takes CI_PIPELINE_URL verbatim", () => {
@@ -43,7 +43,14 @@ export type { EnvTeardownArgs, EnvTeardownResult, EnvTeardownDeps } from "./env-
43
43
  // the registry imports this module statically, so a project that installs
44
44
  // nothing but chant still resolves every step below.
45
45
 
46
- export { reconcilePr, commentMarker, pullRequestContextFrom, resolvePullRequestContext } from "./reconcile";
46
+ export {
47
+ reconcilePr,
48
+ commentMarker,
49
+ pullRequestContextFrom,
50
+ resolvePullRequestContext,
51
+ mergeRequestContextFrom,
52
+ gitlabNoteTokenFrom,
53
+ } from "./reconcile";
47
54
  export type { ReconcilePrArgs, ReconcileResult, ReconcileMode, ReconcileEntry, PullRequestContext } from "./reconcile";
48
55
 
49
56
  export { nativeApply, compensateApply, hasNativeRollback } from "./apply";
@@ -6,6 +6,8 @@ import {
6
6
  entriesFromPlan,
7
7
  commentMarker,
8
8
  pullRequestContextFrom,
9
+ mergeRequestContextFrom,
10
+ gitlabNoteTokenFrom,
9
11
  } from "./reconcile";
10
12
 
11
13
  const entries = [
@@ -159,3 +161,239 @@ describe("reconcilePr comment mode refuses a run with no pull request (#2231)",
159
161
  }
160
162
  });
161
163
  });
164
+
165
+ // ── The GitLab merge-request note (#2256) ───────────────────────────────────
166
+
167
+ describe("mergeRequestContextFrom (#2256)", () => {
168
+ const gitlabEnv = {
169
+ CI_API_V4_URL: "https://gitlab.com/api/v4",
170
+ CI_PROJECT_ID: "42",
171
+ CI_PROJECT_PATH: "acme/infra",
172
+ CI_PROJECT_URL: "https://gitlab.com/acme/infra",
173
+ CI_MERGE_REQUEST_IID: "7",
174
+ };
175
+
176
+ test("reads the merge request off a merge_request_event pipeline", () => {
177
+ expect(mergeRequestContextFrom(gitlabEnv)).toEqual({
178
+ api: "https://gitlab.com/api/v4",
179
+ project: "42",
180
+ iid: 7,
181
+ path: "acme/infra",
182
+ webUrl: "https://gitlab.com/acme/infra",
183
+ });
184
+ });
185
+
186
+ test("prefers the merge request's own project over the pipeline's", () => {
187
+ // A merge request from a fork runs its pipeline in the fork's project,
188
+ // and the note belongs on the target project's merge request.
189
+ expect(
190
+ mergeRequestContextFrom({ ...gitlabEnv, CI_MERGE_REQUEST_PROJECT_ID: "9" })?.project,
191
+ ).toBe("9");
192
+ });
193
+
194
+ test("derives the API base from CI_SERVER_URL when CI_API_V4_URL is unset", () => {
195
+ const { CI_API_V4_URL: _drop, ...rest } = gitlabEnv;
196
+ expect(mergeRequestContextFrom({ ...rest, CI_SERVER_URL: "https://gl.example.com" })?.api).toBe(
197
+ "https://gl.example.com/api/v4",
198
+ );
199
+ });
200
+
201
+ test("a push or scheduled GitLab pipeline has no merge request", () => {
202
+ const { CI_MERGE_REQUEST_IID: _drop, ...rest } = gitlabEnv;
203
+ expect(mergeRequestContextFrom(rest)).toBeUndefined();
204
+ expect(mergeRequestContextFrom({ ...rest, CI_MERGE_REQUEST_IID: "" })).toBeUndefined();
205
+ expect(mergeRequestContextFrom({ ...rest, CI_MERGE_REQUEST_IID: "not-a-number" })).toBeUndefined();
206
+ });
207
+
208
+ test("a GitHub Actions run is not mistaken for a GitLab one", () => {
209
+ expect(mergeRequestContextFrom({ GITHUB_REPOSITORY: "INTENTIUS/chant", GITHUB_REF: "refs/pull/1/merge" })).toBeUndefined();
210
+ });
211
+ });
212
+
213
+ describe("gitlabNoteTokenFrom (#2256)", () => {
214
+ test("a project or personal access token is sent as PRIVATE-TOKEN", () => {
215
+ expect(gitlabNoteTokenFrom({ GITLAB_TOKEN: "glpat-x" })).toEqual({
216
+ header: "PRIVATE-TOKEN",
217
+ value: "glpat-x",
218
+ source: "GITLAB_TOKEN",
219
+ });
220
+ });
221
+
222
+ test("CHANT_GITLAB_TOKEN wins over GITLAB_TOKEN, which wins over the job token", () => {
223
+ expect(
224
+ gitlabNoteTokenFrom({ CHANT_GITLAB_TOKEN: "a", GITLAB_TOKEN: "b", CI_JOB_TOKEN: "c" })?.source,
225
+ ).toBe("CHANT_GITLAB_TOKEN");
226
+ expect(gitlabNoteTokenFrom({ GITLAB_TOKEN: "b", CI_JOB_TOKEN: "c" })?.source).toBe("GITLAB_TOKEN");
227
+ });
228
+
229
+ test("the job token is sent as JOB-TOKEN, which is a different header", () => {
230
+ expect(gitlabNoteTokenFrom({ CI_JOB_TOKEN: "c" })).toEqual({
231
+ header: "JOB-TOKEN",
232
+ value: "c",
233
+ source: "CI_JOB_TOKEN",
234
+ });
235
+ });
236
+
237
+ test("no token at all is undefined rather than an empty header", () => {
238
+ expect(gitlabNoteTokenFrom({})).toBeUndefined();
239
+ expect(gitlabNoteTokenFrom({ GITLAB_TOKEN: "", CI_JOB_TOKEN: "" })).toBeUndefined();
240
+ });
241
+ });
242
+
243
+ /** One stubbed GitLab REST response. */
244
+ function gitlabResponse(body: unknown, headers: Record<string, string> = {}): Response {
245
+ return {
246
+ ok: true,
247
+ status: 200,
248
+ headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
249
+ json: async () => body,
250
+ text: async () => JSON.stringify(body),
251
+ } as unknown as Response;
252
+ }
253
+
254
+ describe("reconcilePr comment mode on GitLab posts one merge-request note (#2256)", () => {
255
+ const gitlabEnv: Record<string, string> = {
256
+ CI_API_V4_URL: "https://gitlab.com/api/v4",
257
+ CI_PROJECT_ID: "42",
258
+ CI_PROJECT_PATH: "acme/infra",
259
+ CI_PROJECT_URL: "https://gitlab.com/acme/infra",
260
+ CI_MERGE_REQUEST_IID: "7",
261
+ GITLAB_TOKEN: "glpat-x",
262
+ };
263
+
264
+ function stubGitlabEnv(): void {
265
+ for (const [k, v] of Object.entries(gitlabEnv)) vi.stubEnv(k, v);
266
+ // A GitLab job carries none of GitHub's variables; make that explicit so
267
+ // the GitHub path can never be the one under test here.
268
+ for (const k of ["GITHUB_REPOSITORY", "GITHUB_REF", "GITHUB_EVENT_PATH"]) vi.stubEnv(k, "");
269
+ }
270
+
271
+ test("POSTs a new note carrying the marker as its first line", async () => {
272
+ stubGitlabEnv();
273
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
274
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
275
+ calls.push({ url, init });
276
+ if (!init?.method || init.method === "GET") return gitlabResponse([]);
277
+ return gitlabResponse({ id: 555 });
278
+ });
279
+ try {
280
+ const result = await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
281
+ expect(result.commentUrl).toBe("https://gitlab.com/acme/infra/-/merge_requests/7#note_555");
282
+ expect(result.mergeRequest).toBe("acme/infra!7");
283
+ const post = calls[calls.length - 1];
284
+ expect(post.url).toBe("https://gitlab.com/api/v4/projects/42/merge_requests/7/notes");
285
+ expect(post.init?.method).toBe("POST");
286
+ expect((post.init?.headers as Record<string, string>)["PRIVATE-TOKEN"]).toBe("glpat-x");
287
+ expect(JSON.parse(String(post.init?.body)).body).toBe(
288
+ "<!-- chant-reconcile:app -->\n\nthe plan",
289
+ );
290
+ } finally {
291
+ vi.unstubAllEnvs();
292
+ vi.unstubAllGlobals();
293
+ }
294
+ });
295
+
296
+ test("PUTs the note it already owns instead of stacking a second one", async () => {
297
+ stubGitlabEnv();
298
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
299
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
300
+ calls.push({ url, init });
301
+ if (!init?.method || init.method === "GET") {
302
+ return gitlabResponse([
303
+ { id: 1, system: true, body: "changed the description" },
304
+ { id: 2, system: false, body: "unrelated human note" },
305
+ { id: 3, system: false, body: "<!-- chant-reconcile:app -->\n\nan older plan" },
306
+ ]);
307
+ }
308
+ return gitlabResponse({ id: 3 });
309
+ });
310
+ try {
311
+ const result = await reconcilePr({ env: "app", mode: "comment", body: "a newer plan" });
312
+ const write = calls[calls.length - 1];
313
+ expect(write.init?.method).toBe("PUT");
314
+ expect(write.url).toBe("https://gitlab.com/api/v4/projects/42/merge_requests/7/notes/3");
315
+ expect(result.commentUrl).toBe("https://gitlab.com/acme/infra/-/merge_requests/7#note_3");
316
+ } finally {
317
+ vi.unstubAllEnvs();
318
+ vi.unstubAllGlobals();
319
+ }
320
+ });
321
+
322
+ test("follows GitLab's own pagination rather than reading page one alone", async () => {
323
+ stubGitlabEnv();
324
+ const seen: string[] = [];
325
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
326
+ if (!init?.method || init.method === "GET") {
327
+ seen.push(url);
328
+ if (url.endsWith("page=1")) return gitlabResponse([{ id: 1, body: "nope" }], { "x-next-page": "2" });
329
+ return gitlabResponse([{ id: 9, body: "<!-- chant-reconcile:app -->\n\nold" }], { "x-next-page": "" });
330
+ }
331
+ return gitlabResponse({ id: 9 });
332
+ });
333
+ try {
334
+ await reconcilePr({ env: "app", mode: "comment", body: "new" });
335
+ expect(seen).toHaveLength(2);
336
+ expect(seen[1]).toContain("page=2");
337
+ } finally {
338
+ vi.unstubAllEnvs();
339
+ vi.unstubAllGlobals();
340
+ }
341
+ });
342
+
343
+ test("a rejected write fails the step by name, carrying GitLab's own status", async () => {
344
+ stubGitlabEnv();
345
+ vi.stubGlobal("fetch", async (_url: string, init?: RequestInit) => {
346
+ if (!init?.method || init.method === "GET") return gitlabResponse([]);
347
+ return {
348
+ ok: false,
349
+ status: 403,
350
+ headers: { get: () => null },
351
+ json: async () => ({}),
352
+ text: async () => '{"message":"403 Forbidden"}',
353
+ } as unknown as Response;
354
+ });
355
+ try {
356
+ await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
357
+ /merge_requests\/7\/notes.*403.*403 Forbidden/s,
358
+ );
359
+ } finally {
360
+ vi.unstubAllEnvs();
361
+ vi.unstubAllGlobals();
362
+ }
363
+ });
364
+
365
+ test("a merge-request pipeline with no token says which variable to set", async () => {
366
+ for (const [k, v] of Object.entries(gitlabEnv)) vi.stubEnv(k, k === "GITLAB_TOKEN" ? "" : v);
367
+ vi.stubEnv("CI_JOB_TOKEN", "");
368
+ vi.stubEnv("CHANT_GITLAB_TOKEN", "");
369
+ try {
370
+ await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
371
+ /GITLAB_TOKEN.*CI_JOB_TOKEN/s,
372
+ );
373
+ } finally {
374
+ vi.unstubAllEnvs();
375
+ }
376
+ });
377
+ });
378
+
379
+ describe("the no-context refusal names both forges' variables (#2256)", () => {
380
+ test("a run on neither forge is told what GitLab would have set too", async () => {
381
+ for (const k of [
382
+ "GITHUB_REPOSITORY",
383
+ "GITHUB_REF",
384
+ "GITHUB_EVENT_PATH",
385
+ "CI_MERGE_REQUEST_IID",
386
+ "CI_PROJECT_ID",
387
+ "CI_API_V4_URL",
388
+ ]) {
389
+ vi.stubEnv(k, "");
390
+ }
391
+ try {
392
+ await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
393
+ /CI_MERGE_REQUEST_IID/,
394
+ );
395
+ } finally {
396
+ vi.unstubAllEnvs();
397
+ }
398
+ });
399
+ });
@@ -12,6 +12,13 @@ const execAsync = promisify(exec);
12
12
  * re-run (chant #2231). It therefore needs a pull-request trigger, and
13
13
  * {@link resolvePullRequestContext} fails the step by name when the run has
14
14
  * none.
15
+ *
16
+ * On GitLab the same mode writes a merge-request note (chant #2256): the same
17
+ * marker, the same edit-in-place, a different API. Which forge a run is on is
18
+ * read off the run's own CI variables rather than configured — a
19
+ * `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID`, a GitHub
20
+ * `pull_request` event sets `GITHUB_REPOSITORY`, and no run sets both. See
21
+ * {@link mergeRequestContextFrom}.
15
22
  */
16
23
  export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
17
24
 
@@ -78,10 +85,12 @@ export interface ReconcileResult {
78
85
  prUrl?: string;
79
86
  /** Opened issue URL (issue mode). */
80
87
  issueUrl?: string;
81
- /** The posted or updated PR comment's URL (comment mode). */
88
+ /** The posted or updated PR comment / MR note URL (comment mode). */
82
89
  commentUrl?: string;
83
- /** The pull request the comment landed on, `owner/repo#number` (comment mode). */
90
+ /** The pull request the comment landed on, `owner/repo#number` (comment mode, GitHub). */
84
91
  pullRequest?: string;
92
+ /** The merge request the note landed on, `group/project!iid` (comment mode, GitLab — #2256). */
93
+ mergeRequest?: string;
85
94
  /** The markdown summary used as the PR/issue body. */
86
95
  summary: string;
87
96
  /** The entries that triggered the reconcile. */
@@ -142,14 +151,17 @@ export function commentMarker(env: string): string {
142
151
  return `<!-- chant-reconcile:${env.replace(/[^a-zA-Z0-9._-]+/g, "-")} -->`;
143
152
  }
144
153
 
145
- /** What a `comment`-mode step says when the run it is in has no pull request. */
154
+ /** What a `comment`-mode step says when the run it is in has no pull request and no merge request. */
146
155
  export function noPullRequestContextMessage(): string {
147
156
  return (
148
- 'reconcilePr mode "comment" posts the finding on the pull request that triggered the run, and this run ' +
149
- "has none. It needs GITHUB_REPOSITORY plus a pull request number, read from the event payload at " +
150
- "GITHUB_EVENT_PATH (`.number` / `.pull_request.number`) or from GITHUB_REF (`refs/pull/<n>/merge`). " +
151
- "GitHub Actions sets those on a pull_request event and on nothing else. Trigger this Op from a " +
152
- 'pull_request workflow, or give it findingMode "issue" or "report".'
157
+ 'reconcilePr mode "comment" posts the finding on the pull request or merge request that triggered the ' +
158
+ "run, and this run has none. On GitHub Actions it needs GITHUB_REPOSITORY plus a pull request number, " +
159
+ "read from the event payload at GITHUB_EVENT_PATH (`.number` / `.pull_request.number`) or from " +
160
+ "GITHUB_REF (`refs/pull/<n>/merge`), which a pull_request event sets and nothing else does. On GitLab " +
161
+ "CI it needs CI_MERGE_REQUEST_IID plus the project (CI_MERGE_REQUEST_PROJECT_ID or CI_PROJECT_ID) and " +
162
+ "the API base (CI_API_V4_URL, or CI_SERVER_URL to derive it), which a merge_request_event pipeline " +
163
+ "sets and nothing else does. Trigger this Op from a pull_request or merge_request pipeline, or give " +
164
+ 'it findingMode "issue" or "report".'
153
165
  );
154
166
  }
155
167
 
@@ -252,6 +264,226 @@ async function postOrUpdateComment(
252
264
  return stdout.trim();
253
265
  }
254
266
 
267
+ // ── The GitLab merge-request note (#2256) ───────────────────────────────────
268
+
269
+ /**
270
+ * The merge request a `comment`-mode run posts its note onto (#2256), as a
271
+ * GitLab CI job knows it. The GitLab counterpart of {@link
272
+ * PullRequestContext}.
273
+ */
274
+ export interface MergeRequestContext {
275
+ /** REST v4 base, from `CI_API_V4_URL` or derived from `CI_SERVER_URL`. */
276
+ api: string;
277
+ /** The project holding the merge request — its numeric id, or a `group/project` path. */
278
+ project: string;
279
+ /** The merge request's `iid` (its per-project number, which is what the API path takes). */
280
+ iid: number;
281
+ /** `group/project`, for the human-readable `group/project!iid` on the result. */
282
+ path?: string;
283
+ /** The project's web URL, used to build the note's own URL. */
284
+ webUrl?: string;
285
+ }
286
+
287
+ /**
288
+ * Derive the triggering merge request from a GitLab job's CI variables. Pure
289
+ * — exported for testing, and the whole forge detection: nothing but a
290
+ * `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID`, so a run that
291
+ * has it is on GitLab and has a merge request, and a run that does not is
292
+ * neither.
293
+ *
294
+ * The project is the merge request's own (`CI_MERGE_REQUEST_PROJECT_ID`) in
295
+ * preference to the pipeline's (`CI_PROJECT_ID`): a merge request opened from
296
+ * a fork runs its pipeline in the fork, and the note belongs on the target
297
+ * project's merge request rather than on an iid that means something else in
298
+ * the fork.
299
+ *
300
+ * Returns undefined rather than throwing, so the caller owns the message.
301
+ */
302
+ export function mergeRequestContextFrom(
303
+ env: Record<string, string | undefined>,
304
+ ): MergeRequestContext | undefined {
305
+ const rawIid = env.CI_MERGE_REQUEST_IID?.trim();
306
+ if (!rawIid) return undefined;
307
+ const iid = Number(rawIid);
308
+ if (!Number.isInteger(iid) || iid <= 0) return undefined;
309
+
310
+ const server = env.CI_SERVER_URL?.trim().replace(/\/+$/, "");
311
+ const api = env.CI_API_V4_URL?.trim().replace(/\/+$/, "") || (server ? `${server}/api/v4` : "");
312
+ if (!api) return undefined;
313
+
314
+ const project =
315
+ env.CI_MERGE_REQUEST_PROJECT_ID?.trim() ||
316
+ env.CI_PROJECT_ID?.trim() ||
317
+ env.CI_MERGE_REQUEST_PROJECT_PATH?.trim() ||
318
+ env.CI_PROJECT_PATH?.trim() ||
319
+ "";
320
+ if (!project) return undefined;
321
+
322
+ const path = env.CI_MERGE_REQUEST_PROJECT_PATH?.trim() || env.CI_PROJECT_PATH?.trim();
323
+ const webUrl = env.CI_MERGE_REQUEST_PROJECT_URL?.trim() || env.CI_PROJECT_URL?.trim();
324
+ return {
325
+ api,
326
+ project,
327
+ iid,
328
+ ...(path ? { path } : {}),
329
+ ...(webUrl ? { webUrl } : {}),
330
+ };
331
+ }
332
+
333
+ /** The credential a merge-request note is written with, and the header GitLab reads it from. */
334
+ export interface GitlabNoteToken {
335
+ /** `PRIVATE-TOKEN` for a personal/project/group access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. */
336
+ header: "PRIVATE-TOKEN" | "JOB-TOKEN";
337
+ value: string;
338
+ /** The variable it came from, so a refusal or a log line can name it. */
339
+ source: string;
340
+ }
341
+
342
+ /**
343
+ * Resolve the token a merge-request note is written with, most specific
344
+ * first. Pure — exported for testing.
345
+ *
346
+ * Two headers, not one, because GitLab reads two different credentials from
347
+ * two different headers: an access token goes in `PRIVATE-TOKEN`, and the
348
+ * pipeline's own ephemeral `CI_JOB_TOKEN` goes in `JOB-TOKEN`. Sending one in
349
+ * the other's header is a 401, not a fallback.
350
+ *
351
+ * The access token is preferred because it is the one that reliably works:
352
+ * `CI_JOB_TOKEN` reaches only the endpoints GitLab's job-token allowlist
353
+ * names, and the notes API is not among them on current GitLab, so a project
354
+ * that has not widened that allowlist needs a token with `api` scope. It is
355
+ * still accepted last rather than refused, so a project on an instance whose
356
+ * allowlist does cover notes needs no long-lived credential at all.
357
+ */
358
+ export function gitlabNoteTokenFrom(
359
+ env: Record<string, string | undefined>,
360
+ ): GitlabNoteToken | undefined {
361
+ for (const source of ["CHANT_GITLAB_TOKEN", "GITLAB_TOKEN"]) {
362
+ const value = env[source]?.trim();
363
+ if (value) return { header: "PRIVATE-TOKEN", value, source };
364
+ }
365
+ const jobToken = env.CI_JOB_TOKEN?.trim();
366
+ if (jobToken) return { header: "JOB-TOKEN", value: jobToken, source: "CI_JOB_TOKEN" };
367
+ return undefined;
368
+ }
369
+
370
+ /** What a `comment`-mode step says on a merge request it has no credential for. */
371
+ export function noGitlabNoteTokenMessage(iid: number): string {
372
+ return (
373
+ `reconcilePr mode "comment" has merge request !${iid} to post its finding on and no token to post it ` +
374
+ "with. Set a GITLAB_TOKEN CI/CD variable (masked, scope: api) on the project — a project access token " +
375
+ "is enough — or, on an instance whose job-token allowlist covers the notes API, make CI_JOB_TOKEN " +
376
+ "available to the job. CHANT_GITLAB_TOKEN is read first where the two must differ."
377
+ );
378
+ }
379
+
380
+ /** One page of merge-request notes, as much of each as this activity reads. */
381
+ interface GitlabNote {
382
+ id: number;
383
+ body?: string;
384
+ /** GitLab's own generated notes ("changed the description"), never ours. */
385
+ system?: boolean;
386
+ }
387
+
388
+ /** GitLab's REST paths take a URL-encoded project id or `group%2Fproject` path. */
389
+ function notesEndpoint(ctx: MergeRequestContext): string {
390
+ return `${ctx.api}/projects/${encodeURIComponent(ctx.project)}/merge_requests/${ctx.iid}/notes`;
391
+ }
392
+
393
+ /** One GitLab REST call, with the failure spelled out rather than swallowed into a parse error. */
394
+ async function gitlabRequest(
395
+ url: string,
396
+ token: GitlabNoteToken,
397
+ init: RequestInit,
398
+ signal?: AbortSignal,
399
+ ): Promise<Response> {
400
+ const res = await fetch(url, {
401
+ ...init,
402
+ ...(signal ? { signal } : {}),
403
+ headers: { [token.header]: token.value, "content-type": "application/json" },
404
+ });
405
+ if (!res.ok) {
406
+ const detail = (await res.text().catch(() => "")).slice(0, 500);
407
+ throw new Error(
408
+ `GitLab API ${init.method ?? "GET"} ${url} answered ${res.status}${detail ? `: ${detail}` : ""} ` +
409
+ `(token from ${token.source}, sent as ${token.header}).`,
410
+ );
411
+ }
412
+ return res;
413
+ }
414
+
415
+ /**
416
+ * Find the note this Op already owns on `ctx`'s merge request, by the same
417
+ * hidden marker `postOrUpdateComment` looks a GitHub comment up by: the
418
+ * marker is the body's first line and the match is a prefix.
419
+ *
420
+ * Pages the way GitLab pages, following the `x-next-page` response header
421
+ * rather than guessing a page count — an active merge request runs past one
422
+ * page of notes routinely, and a lookup that read page one alone would post
423
+ * a second comment instead of editing the first.
424
+ *
425
+ * GitLab's own system notes are skipped: they are the activity feed
426
+ * ("changed the description"), they are never ours, and they are the bulk of
427
+ * what fills those pages.
428
+ */
429
+ async function findOwnedNote(
430
+ ctx: MergeRequestContext,
431
+ token: GitlabNoteToken,
432
+ marker: string,
433
+ signal?: AbortSignal,
434
+ ): Promise<number | undefined> {
435
+ const endpoint = notesEndpoint(ctx);
436
+ // A merge request with more notes than this has something other than a
437
+ // stale plan comment wrong with it; the bound is what stops a broken
438
+ // `x-next-page` header from looping forever.
439
+ const MAX_PAGES = 50;
440
+ for (let page = 1; page <= MAX_PAGES; page++) {
441
+ const res = await gitlabRequest(`${endpoint}?per_page=100&page=${page}`, token, { method: "GET" }, signal);
442
+ const notes = (await res.json()) as GitlabNote[];
443
+ const owned = notes.find((note) => !note.system && (note.body ?? "").startsWith(marker));
444
+ if (owned) return owned.id;
445
+ const next = res.headers.get("x-next-page")?.trim();
446
+ if (!next) return undefined;
447
+ }
448
+ return undefined;
449
+ }
450
+
451
+ /**
452
+ * Post `body` as one note on `ctx`'s merge request, or edit the note this Op
453
+ * already owns there — {@link postOrUpdateComment}'s GitLab half, and the
454
+ * same recipe: find by marker, PUT when there is one, POST when there is not,
455
+ * so a merge request pushed to five times carries one note holding the
456
+ * current finding rather than five stale ones.
457
+ *
458
+ * Over `fetch` rather than a CLI. `gh` is on GitHub's hosted runners and is
459
+ * already this activity's dependency for the issue and pull-request modes;
460
+ * `glab` is on no GitLab runner by default, and a job whose finding step
461
+ * depended on it would fail on the ordinary `node:22-slim` image the
462
+ * generator emits.
463
+ */
464
+ async function postOrUpdateNote(
465
+ ctx: MergeRequestContext,
466
+ token: GitlabNoteToken,
467
+ marker: string,
468
+ body: string,
469
+ signal?: AbortSignal,
470
+ ): Promise<string> {
471
+ const endpoint = notesEndpoint(ctx);
472
+ const existing = await findOwnedNote(ctx, token, marker, signal);
473
+ const payload = JSON.stringify({ body: `${marker}\n\n${body}` });
474
+ const res = existing
475
+ ? await gitlabRequest(`${endpoint}/${existing}`, token, { method: "PUT", body: payload }, signal)
476
+ : await gitlabRequest(endpoint, token, { method: "POST", body: payload }, signal);
477
+ const note = (await res.json()) as GitlabNote;
478
+ // GitLab's note payload carries no web URL, unlike GitHub's comment. The
479
+ // anchor is how the UI itself addresses a note, so it is built rather than
480
+ // read; with no project web URL to build it from, the API path is at least
481
+ // a resolvable address for the thing that was written.
482
+ return ctx.webUrl
483
+ ? `${ctx.webUrl}/-/merge_requests/${ctx.iid}#note_${note.id}`
484
+ : `${endpoint}/${note.id}`;
485
+ }
486
+
255
487
  /**
256
488
  * Map a `chant lifecycle plan --json` ChangeSet to reconcile entries, dropping
257
489
  * `noop` entries (nothing to reconcile). Pure — exported for testing.
@@ -286,10 +518,13 @@ async function derivePlanEntries(
286
518
  * - `issue` — open a GitHub issue describing the drift (no code change).
287
519
  * - `comment` — post the body as one comment on the pull request that
288
520
  * triggered the run, editing that same comment on every re-run rather than
289
- * stacking a new one (#2231). Needs a pull-request-triggered run; fails by
290
- * name when there is none. No code change, and the `pull-requests: write`
291
- * the generated workflow already grants on that trigger is the whole scope
292
- * it spends.
521
+ * stacking a new one (#2231), or, on a GitLab `merge_request_event`
522
+ * pipeline, as one note on that merge request by the same recipe (#2256).
523
+ * Needs a pull-request- or merge-request-triggered run; fails by name when
524
+ * there is none. No code change, and the `pull-requests: write` the
525
+ * generated workflow already grants on that trigger is the whole scope it
526
+ * spends on GitHub; on GitLab the scope is whatever the token it is given
527
+ * carries.
293
528
  * - `pull-request` — create a branch, regenerate source via
294
529
  * `chant import --from <env>`, commit, push, and open a PR whose diff is the
295
530
  * regenerated TypeScript. Never commits to the main branch.
@@ -325,8 +560,27 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
325
560
  // The trigger context is read here rather than passed in: a step's args
326
561
  // are serialized at build time, and the pull request is not known until
327
562
  // the run. Missing context is fatal — see `noPullRequestContextMessage`.
328
- const ctx = await resolvePullRequestContext();
329
563
  const marker = args.marker ?? commentMarker(args.env);
564
+
565
+ // GitLab first, because its check is the narrow one: only a
566
+ // `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID` (#2256), so
567
+ // a run that has it is unambiguously the GitLab case, and a GitHub run
568
+ // never reaches this branch.
569
+ const mr = mergeRequestContextFrom(process.env);
570
+ if (mr) {
571
+ const token = gitlabNoteTokenFrom(process.env);
572
+ if (!token) throw new Error(noGitlabNoteTokenMessage(mr.iid));
573
+ const commentUrl = await postOrUpdateNote(mr, token, marker, summary, signal);
574
+ return {
575
+ mode,
576
+ summary,
577
+ entries,
578
+ commentUrl,
579
+ mergeRequest: `${mr.path ?? mr.project}!${mr.iid}`,
580
+ };
581
+ }
582
+
583
+ const ctx = await resolvePullRequestContext();
330
584
  const commentUrl = await postOrUpdateComment(ctx, marker, summary, signal);
331
585
  return { mode, summary, entries, commentUrl, pullRequest: `${ctx.repo}#${ctx.number}` };
332
586
  }