@intentius/chant 0.58.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 (169) hide show
  1. package/dist/audit/core.d.ts +17 -1
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +15 -4
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/build-params.d.ts +2 -2
  6. package/dist/cli/commands/audit.d.ts.map +1 -1
  7. package/dist/cli/commands/build.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts +13 -0
  10. package/dist/cli/commands/lint.d.ts.map +1 -1
  11. package/dist/cli/handlers/lint.d.ts.map +1 -1
  12. package/dist/cli/handlers/operator.d.ts.map +1 -1
  13. package/dist/cli/handlers/run.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts +0 -16
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/plugins.d.ts +22 -0
  17. package/dist/cli/plugins.d.ts.map +1 -1
  18. package/dist/cli/registry.d.ts +14 -0
  19. package/dist/cli/registry.d.ts.map +1 -1
  20. package/dist/components/cli-support.d.ts +4 -1
  21. package/dist/components/cli-support.d.ts.map +1 -1
  22. package/dist/components/component.d.ts +19 -4
  23. package/dist/components/component.d.ts.map +1 -1
  24. package/dist/components/driver.d.ts +8 -2
  25. package/dist/components/driver.d.ts.map +1 -1
  26. package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
  27. package/dist/components/verbs/run-agent.d.ts +1 -7
  28. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  29. package/dist/config.d.ts +4 -4
  30. package/dist/detectLexicon.d.ts +13 -0
  31. package/dist/detectLexicon.d.ts.map +1 -1
  32. package/dist/lexicon.d.ts +213 -3
  33. package/dist/lexicon.d.ts.map +1 -1
  34. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  35. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  36. package/dist/lifecycle/observe.d.ts +4 -4
  37. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  38. package/dist/op/activities/index.d.ts +2 -2
  39. package/dist/op/activities/index.d.ts.map +1 -1
  40. package/dist/op/activities/reconcile.d.ts +134 -2
  41. package/dist/op/activities/reconcile.d.ts.map +1 -1
  42. package/dist/op/builders.d.ts +2 -2
  43. package/dist/op/builders.d.ts.map +1 -1
  44. package/dist/op/change-signal.d.ts +91 -0
  45. package/dist/op/change-signal.d.ts.map +1 -0
  46. package/dist/op/composites/apply-op.d.ts +7 -2
  47. package/dist/op/composites/apply-op.d.ts.map +1 -1
  48. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  49. package/dist/op/gate-name.d.ts +40 -0
  50. package/dist/op/gate-name.d.ts.map +1 -0
  51. package/dist/op/gate-summary.d.ts +55 -0
  52. package/dist/op/gate-summary.d.ts.map +1 -0
  53. package/dist/op/index.d.ts +6 -2
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/op-ir.d.ts +2 -1
  57. package/dist/op/op-ir.d.ts.map +1 -1
  58. package/dist/op/operator.d.ts +63 -0
  59. package/dist/op/operator.d.ts.map +1 -1
  60. package/dist/op/types.d.ts +18 -3
  61. package/dist/op/types.d.ts.map +1 -1
  62. package/dist/params.d.ts +1 -1
  63. package/dist/project-root.d.ts +2 -2
  64. package/dist/terraform/bridge.d.ts +26 -7
  65. package/dist/terraform/bridge.d.ts.map +1 -1
  66. package/dist/terraform/carve-provider.d.ts +28 -2
  67. package/dist/terraform/carve-provider.d.ts.map +1 -1
  68. package/dist/terraform/data-source-shape.d.ts +77 -0
  69. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  70. package/dist/terraform/graph.d.ts.map +1 -1
  71. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  72. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  73. package/dist/terraform/tier-map.d.ts +16 -4
  74. package/dist/terraform/tier-map.d.ts.map +1 -1
  75. package/dist/terraform/types.d.ts +8 -0
  76. package/dist/terraform/types.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/audit/core.ts +25 -3
  79. package/src/audit/discover.ts +122 -14
  80. package/src/build-params.ts +2 -2
  81. package/src/cli/commands/audit.test.ts +5 -3
  82. package/src/cli/commands/audit.ts +5 -1
  83. package/src/cli/commands/build.test.ts +39 -1
  84. package/src/cli/commands/build.ts +22 -8
  85. package/src/cli/commands/carve-bridge.test.ts +237 -14
  86. package/src/cli/commands/carve-bridge.ts +21 -17
  87. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  88. package/src/cli/commands/lint.test.ts +297 -1
  89. package/src/cli/commands/lint.ts +119 -11
  90. package/src/cli/handlers/graph.test.ts +4 -4
  91. package/src/cli/handlers/graph.ts +11 -11
  92. package/src/cli/handlers/lint.test.ts +107 -0
  93. package/src/cli/handlers/lint.ts +30 -0
  94. package/src/cli/handlers/operator.ts +81 -0
  95. package/src/cli/handlers/run.test.ts +132 -3
  96. package/src/cli/handlers/run.ts +87 -3
  97. package/src/cli/main.test.ts +9 -11
  98. package/src/cli/main.ts +5 -27
  99. package/src/cli/plugins.test.ts +68 -2
  100. package/src/cli/plugins.ts +39 -0
  101. package/src/cli/registry.ts +14 -0
  102. package/src/components/README.md +2 -2
  103. package/src/components/SPRAWL-VALIDATION.md +5 -5
  104. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  105. package/src/components/cli-support.test.ts +18 -7
  106. package/src/components/cli-support.ts +8 -3
  107. package/src/components/component-schema.test.ts +18 -2
  108. package/src/components/component.schema.json +17 -4
  109. package/src/components/component.test.ts +2 -2
  110. package/src/components/component.ts +25 -5
  111. package/src/components/config-defaults.test.ts +2 -2
  112. package/src/components/driver.test.ts +20 -1
  113. package/src/components/driver.ts +12 -5
  114. package/src/components/pilots/README.md +1 -1
  115. package/src/components/pilots/alb-ecs.pilot.ts +2 -2
  116. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  117. package/src/components/verbs/run-agent.test.ts +19 -0
  118. package/src/components/verbs/run-agent.ts +1 -7
  119. package/src/config.ts +4 -4
  120. package/src/detectLexicon.ts +18 -1
  121. package/src/discovery/fold-import.test.ts +2 -2
  122. package/src/discovery/fold-import.ts +3 -3
  123. package/src/fold/foldable-helpers.ts +1 -1
  124. package/src/graph-ops.test.ts +1 -1
  125. package/src/lexicon.ts +221 -3
  126. package/src/lifecycle/gate-ledger.ts +12 -1
  127. package/src/lifecycle/observe.test.ts +2 -2
  128. package/src/lifecycle/observe.ts +8 -8
  129. package/src/lifecycle/release-ledger.test.ts +2 -2
  130. package/src/lint/pipeline-change-gate.test.ts +2 -2
  131. package/src/lint/rules/comp/comp.test.ts +26 -0
  132. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  133. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  134. package/src/op/activities/index.ts +9 -2
  135. package/src/op/activities/reconcile.test.ts +320 -2
  136. package/src/op/activities/reconcile.ts +423 -2
  137. package/src/op/builders.ts +3 -3
  138. package/src/op/change-signal.test.ts +117 -0
  139. package/src/op/change-signal.ts +169 -0
  140. package/src/op/composites/apply-op.ts +14 -4
  141. package/src/op/composites/composites.test.ts +17 -4
  142. package/src/op/composites/reconcile-op.ts +7 -4
  143. package/src/op/effect-step.test.ts +3 -3
  144. package/src/op/gate-name.test.ts +65 -0
  145. package/src/op/gate-name.ts +60 -0
  146. package/src/op/gate-summary.test.ts +62 -0
  147. package/src/op/gate-summary.ts +96 -0
  148. package/src/op/index.ts +9 -2
  149. package/src/op/local-executor.test.ts +16 -1
  150. package/src/op/local-executor.ts +4 -3
  151. package/src/op/op-ir.test.ts +12 -1
  152. package/src/op/op-ir.ts +5 -3
  153. package/src/op/op-verb-class.test.ts +2 -2
  154. package/src/op/op.test.ts +2 -2
  155. package/src/op/operator.test.ts +368 -0
  156. package/src/op/operator.ts +141 -5
  157. package/src/op/runtimes/local.test.ts +1 -1
  158. package/src/op/types.ts +23 -3
  159. package/src/params.ts +1 -1
  160. package/src/project-root.ts +2 -2
  161. package/src/terraform/aws-resources.test.ts +13 -4
  162. package/src/terraform/bridge.test.ts +22 -9
  163. package/src/terraform/bridge.ts +89 -28
  164. package/src/terraform/carve-provider.ts +38 -2
  165. package/src/terraform/data-source-shape.ts +95 -0
  166. package/src/terraform/graph.ts +35 -7
  167. package/src/terraform/providers/kubernetes.ts +48 -2
  168. package/src/terraform/tier-map.ts +21 -6
  169. package/src/terraform/types.ts +8 -0
@@ -1,10 +1,26 @@
1
1
  import { exec } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { promisify } from "node:util";
3
4
 
4
5
  const execAsync = promisify(exec);
5
6
 
6
- /** What the reconcile activity does with the regenerated source. */
7
- export type ReconcileMode = "pull-request" | "issue" | "report";
7
+ /**
8
+ * What the reconcile activity does with the regenerated source.
9
+ *
10
+ * `comment` is the one mode that posts nothing new: it writes the body onto
11
+ * the pull request that triggered the run, updating the same comment on every
12
+ * re-run (chant #2231). It therefore needs a pull-request trigger, and
13
+ * {@link resolvePullRequestContext} fails the step by name when the run has
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}.
22
+ */
23
+ export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
8
24
 
9
25
  /** A change-set entry that triggered reconciliation. */
10
26
  export interface ReconcileEntry {
@@ -35,6 +51,14 @@ export interface ReconcilePrArgs {
35
51
  owned?: boolean;
36
52
  /** PR / issue title. Default derived from env. */
37
53
  title?: string;
54
+ /**
55
+ * Hidden marker identifying this Op's comment on the pull request (comment
56
+ * mode). The activity writes it as the comment's first line and finds the
57
+ * comment again by it on the next run, so a re-run edits one comment instead
58
+ * of stacking a new one. Default: {@link commentMarker} keyed on `env`, so
59
+ * two Ops over two roots get two comments and each updates in place.
60
+ */
61
+ marker?: string;
38
62
  /**
39
63
  * A finding body built by the caller, used verbatim as the issue/PR body in
40
64
  * place of {@link reconcileSummary} (chant #2087).
@@ -61,6 +85,12 @@ export interface ReconcileResult {
61
85
  prUrl?: string;
62
86
  /** Opened issue URL (issue mode). */
63
87
  issueUrl?: string;
88
+ /** The posted or updated PR comment / MR note URL (comment mode). */
89
+ commentUrl?: string;
90
+ /** The pull request the comment landed on, `owner/repo#number` (comment mode, GitHub). */
91
+ pullRequest?: string;
92
+ /** The merge request the note landed on, `group/project!iid` (comment mode, GitLab — #2256). */
93
+ mergeRequest?: string;
64
94
  /** The markdown summary used as the PR/issue body. */
65
95
  summary: string;
66
96
  /** The entries that triggered the reconcile. */
@@ -101,6 +131,359 @@ function shellQuote(s: string): string {
101
131
  return `'${s.replace(/'/g, "'\\''")}'`;
102
132
  }
103
133
 
134
+ /** The pull request a `comment`-mode run posts onto. */
135
+ export interface PullRequestContext {
136
+ /** `owner/repo`, from `GITHUB_REPOSITORY`. */
137
+ repo: string;
138
+ /** The pull request number. */
139
+ number: number;
140
+ }
141
+
142
+ /**
143
+ * The hidden marker that makes a `comment`-mode finding findable across
144
+ * re-runs: written as the comment's first line, matched with `startswith` on
145
+ * the next run. Keyed on `env` (slugified the same way {@link
146
+ * reconcileBranchName} slugifies it, which also keeps the value free of the
147
+ * quotes and backslashes it is interpolated next to), so two Ops over two
148
+ * environments own two comments and each updates in place.
149
+ */
150
+ export function commentMarker(env: string): string {
151
+ return `<!-- chant-reconcile:${env.replace(/[^a-zA-Z0-9._-]+/g, "-")} -->`;
152
+ }
153
+
154
+ /** What a `comment`-mode step says when the run it is in has no pull request and no merge request. */
155
+ export function noPullRequestContextMessage(): string {
156
+ return (
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".'
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Read the pull request number out of a parsed webhook event payload. Pure.
170
+ * A `pull_request` event carries it top-level as `number` and again under
171
+ * `pull_request.number`; both are accepted, neither is invented.
172
+ */
173
+ function prNumberFromPayload(payload: unknown): number | undefined {
174
+ if (typeof payload !== "object" || payload === null) return undefined;
175
+ const p = payload as { number?: unknown; pull_request?: { number?: unknown } };
176
+ if (typeof p.number === "number") return p.number;
177
+ if (typeof p.pull_request?.number === "number") return p.pull_request.number;
178
+ return undefined;
179
+ }
180
+
181
+ /**
182
+ * Derive the triggering pull request from CI environment variables plus the
183
+ * already-parsed event payload. Pure — exported for testing; the IO (reading
184
+ * `GITHUB_EVENT_PATH`) is {@link resolvePullRequestContext}'s.
185
+ *
186
+ * Returns undefined rather than throwing, so the caller owns the message.
187
+ */
188
+ export function pullRequestContextFrom(
189
+ env: Record<string, string | undefined>,
190
+ eventPayload?: unknown,
191
+ ): PullRequestContext | undefined {
192
+ const repo = env.GITHUB_REPOSITORY;
193
+ if (!repo) return undefined;
194
+ const fromRef = /^refs\/pull\/(\d+)\//.exec(env.GITHUB_REF ?? "")?.[1];
195
+ const number = prNumberFromPayload(eventPayload) ?? (fromRef ? Number(fromRef) : undefined);
196
+ if (number === undefined || !Number.isInteger(number) || number <= 0) return undefined;
197
+ return { repo, number };
198
+ }
199
+
200
+ /**
201
+ * Resolve the triggering pull request, reading and parsing the event payload
202
+ * `GITHUB_EVENT_PATH` names. Throws {@link noPullRequestContextMessage} when
203
+ * the run has no pull request, which is the whole point: a `comment` mode that
204
+ * quietly fell back to an issue would post the finding somewhere nobody asked
205
+ * for it.
206
+ */
207
+ export async function resolvePullRequestContext(
208
+ env: Record<string, string | undefined> = process.env,
209
+ ): Promise<PullRequestContext> {
210
+ let payload: unknown;
211
+ const eventPath = env.GITHUB_EVENT_PATH;
212
+ if (eventPath) {
213
+ try {
214
+ payload = JSON.parse(await readFile(eventPath, "utf8"));
215
+ } catch {
216
+ // An unreadable or malformed payload is not fatal on its own: GITHUB_REF
217
+ // may still name the pull request. If it does not, the error below says so.
218
+ payload = undefined;
219
+ }
220
+ }
221
+ const ctx = pullRequestContextFrom(env, payload);
222
+ if (!ctx) throw new Error(noPullRequestContextMessage());
223
+ return ctx;
224
+ }
225
+
226
+ /**
227
+ * Post `body` as one comment on `ctx`'s pull request, or edit the comment this
228
+ * Op already owns there. The sticky-comment recipe the github lexicon's
229
+ * `PrPlanReport` uses, run from the activity instead of from generated YAML:
230
+ * find the comment whose body starts with `marker`, PATCH it when there is
231
+ * one, POST otherwise. `gh` ships on GitHub's hosted runners and is already
232
+ * this activity's dependency for the issue and pull-request modes, so the
233
+ * mode needs nothing new on the runner.
234
+ */
235
+ async function postOrUpdateComment(
236
+ ctx: PullRequestContext,
237
+ marker: string,
238
+ body: string,
239
+ signal?: AbortSignal,
240
+ ): Promise<string> {
241
+ const listPath = `repos/${ctx.repo}/issues/${ctx.number}/comments`;
242
+ const jq = `map(select(.body | startswith("${marker}"))) | .[0].id // empty`;
243
+ const { stdout: found } = await execAsync(
244
+ `gh api ${shellQuote(listPath)} --paginate --jq ${shellQuote(jq)}`,
245
+ { signal },
246
+ );
247
+ // `--paginate` prints one `--jq` result per page, so take the first line
248
+ // that is an id and ignore the empty ones the other pages produce.
249
+ const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
250
+ const field = `body=${marker}\n\n${body}`;
251
+
252
+ if (existing) {
253
+ const { stdout } = await execAsync(
254
+ `gh api --method PATCH ${shellQuote(`repos/${ctx.repo}/issues/comments/${existing}`)} ` +
255
+ `-f ${shellQuote(field)} --jq .html_url`,
256
+ { signal },
257
+ );
258
+ return stdout.trim();
259
+ }
260
+ const { stdout } = await execAsync(
261
+ `gh api --method POST ${shellQuote(listPath)} -f ${shellQuote(field)} --jq .html_url`,
262
+ { signal },
263
+ );
264
+ return stdout.trim();
265
+ }
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
+
104
487
  /**
105
488
  * Map a `chant lifecycle plan --json` ChangeSet to reconcile entries, dropping
106
489
  * `noop` entries (nothing to reconcile). Pure — exported for testing.
@@ -133,6 +516,15 @@ async function derivePlanEntries(
133
516
  *
134
517
  * - `report` — return the summary only; no git, no network.
135
518
  * - `issue` — open a GitHub issue describing the drift (no code change).
519
+ * - `comment` — post the body as one comment on the pull request that
520
+ * triggered the run, editing that same comment on every re-run rather than
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.
136
528
  * - `pull-request` — create a branch, regenerate source via
137
529
  * `chant import --from <env>`, commit, push, and open a PR whose diff is the
138
530
  * regenerated TypeScript. Never commits to the main branch.
@@ -164,6 +556,35 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
164
556
  return { mode, summary, entries, issueUrl: stdout.trim() };
165
557
  }
166
558
 
559
+ if (mode === "comment") {
560
+ // The trigger context is read here rather than passed in: a step's args
561
+ // are serialized at build time, and the pull request is not known until
562
+ // the run. Missing context is fatal — see `noPullRequestContextMessage`.
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();
584
+ const commentUrl = await postOrUpdateComment(ctx, marker, summary, signal);
585
+ return { mode, summary, entries, commentUrl, pullRequest: `${ctx.repo}#${ctx.number}` };
586
+ }
587
+
167
588
  // pull-request
168
589
  const branch = args.branch ?? reconcileBranchName(args.env);
169
590
  const output = args.output ?? "./infra";
@@ -105,16 +105,16 @@ export function activity(
105
105
  /**
106
106
  * Insert a human gate. A gate is a fact on the gate ledger, not a wait: a run
107
107
  * that reaches this step with no resolution newer than its pending fact
108
- * records the pending fact and ends `gated`. `chant approve <op> <signalName>`
108
+ * records the pending fact and ends `gated`. `chant approve <op> <gate>`
109
109
  * writes the resolution, and the next run walks through carrying the approver.
110
110
  */
111
111
  export function gate(
112
- signalName: string,
112
+ name: string,
113
113
  opts?: { timeout?: string; description?: string },
114
114
  ): GateStep {
115
115
  return {
116
116
  kind: "gate",
117
- signalName,
117
+ gate: name,
118
118
  ...(opts?.timeout ? { timeout: opts.timeout } : {}),
119
119
  ...(opts?.description ? { description: opts.description } : {}),
120
120
  };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The wake gate (#1981): coalescing, the floor, and abort.
3
+ *
4
+ * Real timers with small floors rather than fake ones: the gate is nothing but
5
+ * two `setTimeout`s racing, and a fake-timer test of that asserts the mock
6
+ * rather than the behaviour.
7
+ */
8
+ import { describe, test, expect } from "vitest";
9
+ import { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS } from "./change-signal";
10
+
11
+ describe("createChangeSignalGate", () => {
12
+ test("no signal: the wait runs the full interval and reports the timer", async () => {
13
+ const gate = createChangeSignalGate({ floorMs: 10 });
14
+ gate.roundStarted();
15
+ const started = Date.now();
16
+ expect(await gate.wait(60)).toBe("timer");
17
+ expect(Date.now() - started).toBeGreaterThanOrEqual(45);
18
+ expect(gate.wakeCount).toBe(0);
19
+ });
20
+
21
+ test("a signal past the floor wakes the sleep early", async () => {
22
+ const gate = createChangeSignalGate({ floorMs: 0 });
23
+ gate.roundStarted();
24
+ const started = Date.now();
25
+ const waiting = gate.wait(5_000);
26
+ setTimeout(() => gate.signal(), 10);
27
+ expect(await waiting).toBe("signal");
28
+ expect(Date.now() - started).toBeLessThan(2_000);
29
+ expect(gate.wakeCount).toBe(1);
30
+ });
31
+
32
+ test("a signal inside the floor waits the floor out rather than waking at once", async () => {
33
+ const gate = createChangeSignalGate({ floorMs: 120 });
34
+ gate.roundStarted();
35
+ const started = Date.now();
36
+ const waiting = gate.wait(5_000);
37
+ gate.signal();
38
+ expect(await waiting).toBe("signal");
39
+ // Woken by the signal, but not before the floor had passed.
40
+ expect(Date.now() - started).toBeGreaterThanOrEqual(100);
41
+ });
42
+
43
+ test("a storm of signals inside one floor window costs exactly one wake", async () => {
44
+ const gate = createChangeSignalGate({ floorMs: 60 });
45
+ gate.roundStarted();
46
+ const waiting = gate.wait(5_000);
47
+ for (let i = 0; i < 500; i++) gate.signal();
48
+ expect(await waiting).toBe("signal");
49
+ expect(gate.signalCount).toBe(500);
50
+ expect(gate.wakeCount).toBe(1);
51
+ });
52
+
53
+ test("the floor is measured from the round, so a steady stream cannot starve the tick", async () => {
54
+ const gate = createChangeSignalGate({ floorMs: 80 });
55
+ gate.roundStarted();
56
+ const started = Date.now();
57
+ const waiting = gate.wait(5_000);
58
+ // A signal every 10ms. A debounce would push the deadline out forever.
59
+ const drip = setInterval(() => gate.signal(), 10);
60
+ const reason = await waiting;
61
+ clearInterval(drip);
62
+ expect(reason).toBe("signal");
63
+ expect(Date.now() - started).toBeLessThan(1_000);
64
+ });
65
+
66
+ test("a signal arriving between rounds is honoured by the next wait, not lost", async () => {
67
+ const gate = createChangeSignalGate({ floorMs: 0 });
68
+ gate.roundStarted();
69
+ gate.signal(); // nothing is sleeping yet
70
+ expect(await gate.wait(5_000)).toBe("signal");
71
+ });
72
+
73
+ test("roundStarted clears the pending flag, so one signal never wakes two rounds", async () => {
74
+ const gate = createChangeSignalGate({ floorMs: 0 });
75
+ gate.roundStarted();
76
+ gate.signal();
77
+ expect(await gate.wait(5_000)).toBe("signal");
78
+ gate.roundStarted();
79
+ expect(await gate.wait(40)).toBe("timer");
80
+ expect(gate.wakeCount).toBe(1);
81
+ });
82
+
83
+ test("abort resolves rather than throwing, before and during a wait", async () => {
84
+ const gate = createChangeSignalGate({ floorMs: 0 });
85
+ const already = new AbortController();
86
+ already.abort();
87
+ expect(await gate.wait(5_000, already.signal)).toBe("aborted");
88
+
89
+ const controller = new AbortController();
90
+ const waiting = gate.wait(5_000, controller.signal);
91
+ setTimeout(() => controller.abort(), 10);
92
+ expect(await waiting).toBe("aborted");
93
+ });
94
+
95
+ test("a signal after abort does nothing", async () => {
96
+ const gate = createChangeSignalGate({ floorMs: 0 });
97
+ const controller = new AbortController();
98
+ const waiting = gate.wait(5_000, controller.signal);
99
+ controller.abort();
100
+ expect(await waiting).toBe("aborted");
101
+ gate.signal();
102
+ expect(gate.wakeCount).toBe(0);
103
+ });
104
+
105
+ test("signal() carries no payload: the type has no argument and the call ignores one", () => {
106
+ const gate = createChangeSignalGate();
107
+ expect(gate.signal.length).toBe(0);
108
+ // A caller that fabricates an event has nowhere to put it: the extra
109
+ // argument is dropped, and the gate's only state is a boolean.
110
+ (gate.signal as (...args: unknown[]) => void)({ kind: "Deployment", name: "fabricated" });
111
+ expect(gate.signalCount).toBe(1);
112
+ });
113
+
114
+ test("the documented floor is a real, positive default", () => {
115
+ expect(DEFAULT_SIGNAL_FLOOR_MS).toBe(5_000);
116
+ });
117
+ });