@intentius/chant 0.61.0 → 0.62.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 (35) hide show
  1. package/dist/cli/handlers/operator.d.ts +0 -18
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/lexicon.d.ts +51 -0
  4. package/dist/lexicon.d.ts.map +1 -1
  5. package/dist/lifecycle/git.d.ts +117 -0
  6. package/dist/lifecycle/git.d.ts.map +1 -1
  7. package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
  8. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  9. package/dist/op/activities/reconcile.d.ts +225 -14
  10. package/dist/op/activities/reconcile.d.ts.map +1 -1
  11. package/dist/op/gate.d.ts.map +1 -1
  12. package/dist/op/local-executor.d.ts +17 -2
  13. package/dist/op/local-executor.d.ts.map +1 -1
  14. package/dist/op/operator.d.ts.map +1 -1
  15. package/dist/op/runtimes/local.d.ts.map +1 -1
  16. package/dist/runtime-adapter.d.ts +8 -0
  17. package/dist/runtime-adapter.d.ts.map +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/handlers/operator.test.ts +102 -1
  20. package/src/cli/handlers/operator.ts +75 -5
  21. package/src/lexicon.ts +51 -0
  22. package/src/lifecycle/git.test.ts +49 -5
  23. package/src/lifecycle/git.ts +312 -11
  24. package/src/op/activities/lexicon-upgrade.test.ts +122 -39
  25. package/src/op/activities/lexicon-upgrade.ts +55 -9
  26. package/src/op/activities/reconcile.test.ts +527 -1
  27. package/src/op/activities/reconcile.ts +446 -23
  28. package/src/op/gate.test.ts +504 -0
  29. package/src/op/gate.ts +9 -1
  30. package/src/op/local-executor.test.ts +115 -0
  31. package/src/op/local-executor.ts +130 -21
  32. package/src/op/operator.test.ts +20 -0
  33. package/src/op/operator.ts +39 -1
  34. package/src/op/runtimes/local.ts +11 -0
  35. package/src/runtime-adapter.ts +17 -3
@@ -19,6 +19,34 @@ const execAsync = promisify(exec);
19
19
  * `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID`, a GitHub
20
20
  * `pull_request` event sets `GITHUB_REPOSITORY`, and no run sets both. See
21
21
  * {@link mergeRequestContextFrom}.
22
+ *
23
+ * On Forgejo the same mode posts the same GitHub-shaped comment (chant
24
+ * #2291): no forge split at all, because a Forgejo Actions job already sets
25
+ * `GITHUB_REPOSITORY`, `GITHUB_API_URL` and `github.token` the same way a
26
+ * GitHub Actions job does, and Forgejo's own `/api/v1` takes the identical
27
+ * GET/POST/PATCH triple. The one thing that needed fixing was the URL: `gh
28
+ * api` resolves a *relative* path against `/api/v3` for any host other than
29
+ * `github.com`, and Forgejo does not serve `/api/v3` — verified on a real
30
+ * Forgejo 12.0.4+gitea-1.22.0 instance during INTENTIUS/choudoufu#1027. See
31
+ * {@link postOrUpdateComment} and {@link githubApiBaseFrom}.
32
+ *
33
+ * `issue` needs no merge/pull request — its whole point is a cron trigger
34
+ * that has none — so its forge split reads off a wider signal: any GitLab CI
35
+ * job sets `CI_PROJECT_ID`, not only a merge-request one (see {@link
36
+ * gitlabProjectContextFrom}), and any GitHub Actions or Forgejo Actions job
37
+ * sets `GITHUB_REPOSITORY`. Both are sticky now (chant #2292, #2297): each
38
+ * finds and edits the OPEN issue it already owns by a hidden marker, the same
39
+ * recipe `comment` mode's note uses, and opens a new one only when it finds
40
+ * none. See {@link postOrUpdateGithubIssue} for the GitHub/GHES/Forgejo half
41
+ * — including why it does not use GitHub's Search API, and the decision that
42
+ * this mode never closes the issue itself. Only a run outside any known CI
43
+ * job — no `CI_PROJECT_ID`, no `GITHUB_REPOSITORY` — falls back to `gh issue
44
+ * create`'s own ambient repo detection, still marker-prefixed so a later CI
45
+ * run of the same Op finds and edits it instead of opening a second one. `gh
46
+ * api` was not exercised against a real Forgejo instance for this mode (only
47
+ * the `comment` mode's endpoints were, chant #2291) — the request shape
48
+ * mirrors that mode's exactly, but the search behavior itself is the one
49
+ * part a mock cannot vouch for.
22
50
  */
23
51
  export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
24
52
 
@@ -52,11 +80,12 @@ export interface ReconcilePrArgs {
52
80
  /** PR / issue title. Default derived from env. */
53
81
  title?: string;
54
82
  /**
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.
83
+ * Hidden marker identifying this Op's own comment or issue. The activity
84
+ * writes it as the first line and finds it again by it on the next run, so
85
+ * a re-run edits one comment/issue instead of stacking a new one. Default:
86
+ * {@link commentMarker} for `comment` mode, {@link issueMarker} for `issue`
87
+ * mode, both keyed on `env`, so two Ops over two roots get two comments (or
88
+ * two issues) and each updates in place.
60
89
  */
61
90
  marker?: string;
62
91
  /**
@@ -83,7 +112,7 @@ export interface ReconcileResult {
83
112
  branch?: string;
84
113
  /** Opened PR URL (pull-request mode). */
85
114
  prUrl?: string;
86
- /** Opened issue URL (issue mode). */
115
+ /** Opened or edited issue URL (issue mode; sticky on every forge — #2292, #2297). */
87
116
  issueUrl?: string;
88
117
  /** The posted or updated PR comment / MR note URL (comment mode). */
89
118
  commentUrl?: string;
@@ -151,6 +180,20 @@ export function commentMarker(env: string): string {
151
180
  return `<!-- chant-reconcile:${env.replace(/[^a-zA-Z0-9._-]+/g, "-")} -->`;
152
181
  }
153
182
 
183
+ /**
184
+ * The hidden marker that makes an `issue`-mode GitLab finding findable across
185
+ * re-runs (#2292): written as the issue description's first line, matched by
186
+ * a server-side `search` plus a `startswith` check on the next run — the same
187
+ * recipe {@link commentMarker} names for the `comment` mode's note, kept as
188
+ * its own function (rather than reused) because the two modes write to
189
+ * different resources and a caller may run both against the same `env`.
190
+ * Slugified the same way, for the same reason: interpolated next to quotes
191
+ * and URL-encoding it should not need escaping out of.
192
+ */
193
+ export function issueMarker(env: string): string {
194
+ return `<!-- chant-reconcile-issue:${env.replace(/[^a-zA-Z0-9._-]+/g, "-")} -->`;
195
+ }
196
+
154
197
  /** What a `comment`-mode step says when the run it is in has no pull request and no merge request. */
155
198
  export function noPullRequestContextMessage(): string {
156
199
  return (
@@ -223,6 +266,74 @@ export async function resolvePullRequestContext(
223
266
  return ctx;
224
267
  }
225
268
 
269
+ /**
270
+ * The REST base a GitHub-shaped `gh api` call should target, read the same
271
+ * way the runner itself reads it (chant #2291). `GITHUB_API_URL` is a default
272
+ * environment variable every GitHub Actions *and* Forgejo Actions job carries
273
+ * — `https://api.github.com` on github.com, `<host>/api/v3` on GitHub
274
+ * Enterprise Server, and `<host>/api/v1` on Forgejo, which already advertises
275
+ * it correctly (confirmed on a real Forgejo 12.0.4+gitea-1.22.0 instance
276
+ * during INTENTIUS/choudoufu#1027: `$GITHUB_API_URL` read
277
+ * `http://forgejo:3000/api/v1` inside the job).
278
+ *
279
+ * Building the full URL from this rather than handing `gh api` a bare
280
+ * relative path (`repos/…`) is the fix itself: `gh` resolves a relative path
281
+ * by guessing a host-specific prefix of its own — `api.<host>` for
282
+ * `github.com`, `<host>/api/v3` for anything else — and that guess is `/api/v3`
283
+ * for a Forgejo host too, which Forgejo answers 404 for both GET and POST.
284
+ * Handed a full URL, `gh api` uses it verbatim and skips the guess entirely,
285
+ * which is what let the same `gh` binary reach Forgejo's `/api/v1` in the
286
+ * same session, over plain HTTP and over TLS. No Forgejo-specific branch is
287
+ * needed: every host in play (github.com, GHES, Forgejo) sets
288
+ * `GITHUB_API_URL` to the base its own `/repos/...` paths actually live
289
+ * under, so building the URL from it is correct everywhere `gh` already ran,
290
+ * not only on Forgejo.
291
+ */
292
+ export function githubApiBaseFrom(env: Record<string, string | undefined>): string {
293
+ return env.GITHUB_API_URL?.trim().replace(/\/+$/, "") || "https://api.github.com";
294
+ }
295
+
296
+ /** The credential a GitHub- or Forgejo-shaped comment call is made with. */
297
+ export interface CommentToken {
298
+ value: string;
299
+ /** The variable it came from, so a refusal or a log line can name it. */
300
+ source: string;
301
+ }
302
+
303
+ /**
304
+ * Resolve the token `postOrUpdateComment` sends with, most specific first
305
+ * (chant #2291). `gh` itself already resolves `GH_TOKEN`/`GITHUB_TOKEN`
306
+ * ambiently from the process environment, and the generated workflow sets
307
+ * both to `${{ github.token }}` for every non-`report` finding mode — on
308
+ * Forgejo the same way as on GitHub, and the real-instance read confirmed
309
+ * `github.token` is populated there and good for a 200 read and a 201 write,
310
+ * authored as `forgejo-actions`. So the common case needs nothing set beyond
311
+ * what the generator already emits.
312
+ *
313
+ * `CHANT_FORGEJO_TOKEN` is checked first for the case that ambient token
314
+ * cannot cover: a run posting to a Forgejo instance other than the one the
315
+ * job executes on, where `github.token`'s scope stops at its own instance.
316
+ * Resolving explicitly (rather than leaving it entirely to `gh`) also buys a
317
+ * named failure before the shell-out, in place of `gh`'s own opaque 401.
318
+ */
319
+ export function commentTokenFrom(env: Record<string, string | undefined>): CommentToken | undefined {
320
+ for (const source of ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
321
+ const value = env[source]?.trim();
322
+ if (value) return { value, source };
323
+ }
324
+ return undefined;
325
+ }
326
+
327
+ /** What a `comment`-mode step says on a pull request it has no credential for. */
328
+ export function noCommentTokenMessage(repo: string, number: number): string {
329
+ return (
330
+ `reconcilePr mode "comment" has pull request ${repo}#${number} to post its finding on and no token to ` +
331
+ "post it with. GH_TOKEN or GITHUB_TOKEN, set from github.token, already covers this on a GitHub Actions " +
332
+ "or Forgejo Actions run — set CHANT_FORGEJO_TOKEN to post against a different instance than the one the " +
333
+ "job runs on. CHANT_FORGEJO_TOKEN is read first where the two must differ."
334
+ );
335
+ }
336
+
226
337
  /**
227
338
  * Post `body` as one comment on `ctx`'s pull request, or edit the comment this
228
339
  * Op already owns there. The sticky-comment recipe the github lexicon's
@@ -230,7 +341,16 @@ export async function resolvePullRequestContext(
230
341
  * find the comment whose body starts with `marker`, PATCH it when there is
231
342
  * one, POST otherwise. `gh` ships on GitHub's hosted runners and is already
232
343
  * this activity's dependency for the issue and pull-request modes, so the
233
- * mode needs nothing new on the runner.
344
+ * mode needs nothing new on the runner — Forgejo's `act_runner` ships `gh`
345
+ * too, and Forgejo's `/api/v1` takes the same calls (chant #2291).
346
+ *
347
+ * Every call targets a full URL built from {@link githubApiBaseFrom} rather
348
+ * than the bare relative path this used before #2291 — see that function for
349
+ * why a bare path broke Forgejo specifically. The token is resolved
350
+ * explicitly via {@link commentTokenFrom} and forwarded as `GH_TOKEN`, which
351
+ * is a strict superset of `gh`'s own ambient resolution: same value in the
352
+ * common case, a named refusal instead of `gh`'s opaque 401 when neither is
353
+ * set.
234
354
  */
235
355
  async function postOrUpdateComment(
236
356
  ctx: PullRequestContext,
@@ -238,11 +358,16 @@ async function postOrUpdateComment(
238
358
  body: string,
239
359
  signal?: AbortSignal,
240
360
  ): Promise<string> {
241
- const listPath = `repos/${ctx.repo}/issues/${ctx.number}/comments`;
361
+ const token = commentTokenFrom(process.env);
362
+ if (!token) throw new Error(noCommentTokenMessage(ctx.repo, ctx.number));
363
+ const env = { ...process.env, GH_TOKEN: token.value };
364
+
365
+ const base = githubApiBaseFrom(process.env);
366
+ const listUrl = `${base}/repos/${ctx.repo}/issues/${ctx.number}/comments`;
242
367
  const jq = `map(select(.body | startswith("${marker}"))) | .[0].id // empty`;
243
368
  const { stdout: found } = await execAsync(
244
- `gh api ${shellQuote(listPath)} --paginate --jq ${shellQuote(jq)}`,
245
- { signal },
369
+ `gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`,
370
+ { signal, env },
246
371
  );
247
372
  // `--paginate` prints one `--jq` result per page, so take the first line
248
373
  // that is an id and ignore the empty ones the other pages produce.
@@ -251,15 +376,130 @@ async function postOrUpdateComment(
251
376
 
252
377
  if (existing) {
253
378
  const { stdout } = await execAsync(
254
- `gh api --method PATCH ${shellQuote(`repos/${ctx.repo}/issues/comments/${existing}`)} ` +
379
+ `gh api --method PATCH ${shellQuote(`${base}/repos/${ctx.repo}/issues/comments/${existing}`)} ` +
255
380
  `-f ${shellQuote(field)} --jq .html_url`,
256
- { signal },
381
+ { signal, env },
257
382
  );
258
383
  return stdout.trim();
259
384
  }
260
385
  const { stdout } = await execAsync(
261
- `gh api --method POST ${shellQuote(listPath)} -f ${shellQuote(field)} --jq .html_url`,
262
- { signal },
386
+ `gh api --method POST ${shellQuote(listUrl)} -f ${shellQuote(field)} --jq .html_url`,
387
+ { signal, env },
388
+ );
389
+ return stdout.trim();
390
+ }
391
+
392
+ // ── The GitHub/GHES/Forgejo sticky issue (#2297) ────────────────────────────
393
+
394
+ /**
395
+ * Minimal `gh` invocation shape {@link postOrUpdateGithubIssue} threads its
396
+ * calls through. `reconcilePr` wraps `execAsync` (carrying its own `signal`)
397
+ * to this shape; `lexiconUpgrade` passes its injectable `GhRunner` straight
398
+ * through, since the two already share the same `(cmd) => Promise<{ stdout,
399
+ * stderr }>` signature. One function, not two copies, because the recipe
400
+ * below — search, then PATCH or POST — is identical for both callers; only
401
+ * the issue's title/body and the marker that owns it differ.
402
+ */
403
+ export type GhExec = (cmd: string) => Promise<{ stdout: string; stderr: string }>;
404
+
405
+ /**
406
+ * Open `title`/`body` as one GitHub-shaped issue in `repo`, or edit the issue
407
+ * this caller already owns there (#2297) — `postOrUpdateComment`'s issue
408
+ * counterpart, and the GitHub/GHES/Forgejo sibling of GitLab's
409
+ * `postOrUpdateIssue` (#2292): find by marker, PATCH when found, POST when
410
+ * not, so a nightly finding carries one issue holding the current state
411
+ * instead of stacking a new one every run. Shared by `reconcilePr`'s own
412
+ * `issue` mode and `lexiconUpgrade`'s per-lexicon upgrade-status issue.
413
+ *
414
+ * Every call targets a full URL built from {@link githubApiBaseFrom}, the
415
+ * same as `postOrUpdateComment` (#2291) — so this reaches GitHub Enterprise
416
+ * Server the same way it reaches github.com.
417
+ *
418
+ * ## Why plain pagination instead of GitHub's Search API
419
+ *
420
+ * GitHub offers real server-side full-text search — `gh issue list --search`
421
+ * and `GET /search/issues?q=...` both use it, and chant #2297 (the issue that
422
+ * asked for this) names both as candidates. Neither is used here, for three
423
+ * reasons found while building this:
424
+ *
425
+ * 1. **Consistency.** GitHub's search index is documented as eventually
426
+ * consistent: an issue this function just wrote is not guaranteed to be
427
+ * findable by search immediately afterward. That is exactly the
428
+ * read-after-write the sticky recipe depends on — a false "not found"
429
+ * means a duplicate issue, which is the bug this activity exists to fix.
430
+ * 2. **The marker itself.** The hidden marker is deliberately punctuation-
431
+ * heavy (`<!-- chant-reconcile-issue:app -->`) so it renders invisibly.
432
+ * GitHub's search tokenizer is not documented to preserve that shape
433
+ * through `in:body` matching, and a wrong query would either miss the
434
+ * marker (a duplicate, the same failure mode as above) or over-match and
435
+ * still need the same client-side `startswith` check this function
436
+ * already does — at which point the search bought nothing but risk.
437
+ * 3. **Forgejo.** `gh issue list --search` and `/search/issues` are both
438
+ * absent from Forgejo's API — checked against a live Forgejo instance's
439
+ * own OpenAPI spec while building this: no `/search/issues`, no
440
+ * `/repos/issues/search` path anywhere in it (Forgejo's own issues-list
441
+ * endpoint does carry a real `q` search parameter, but that is a
442
+ * different endpoint shape than GitHub's, which would mean forking this
443
+ * function by forge — exactly what the `comment` path (#2291)
444
+ * deliberately does not do).
445
+ *
446
+ * So this reuses the recipe `postOrUpdateComment` already proved for PR
447
+ * comments: list, `--paginate`, filter with `--jq` by an exact `startswith`
448
+ * prefix match, which is correct no matter how many pages it takes. The cost
449
+ * is real — a repository with many thousands of issues pays for every page
450
+ * on every run — and is the accepted trade-off here; narrowing further with
451
+ * GitHub's `creator` filter was considered and left alone, because the
452
+ * identity a `GH_TOKEN` posts as cannot be assumed reliably across a
453
+ * generated workflow and a hand-run one, and a wrong `creator` is a false
454
+ * negative — a duplicate — not just a slower search.
455
+ *
456
+ * ## The close decision (#2297)
457
+ *
458
+ * This never closes the issue, in either branch, matching both GitLab paths
459
+ * (#2256, #2292) — neither of which closes anything either. The search is
460
+ * scoped to `state=open` rather than `state=all`, which makes that decision
461
+ * concrete rather than implicit: a human closing the issue by hand, once the
462
+ * drift it named is actually fixed, is what tells the *next* run to open a
463
+ * fresh issue for a fresh finding instead of silently rewriting the closed
464
+ * one back open-in-substance-but-not-in-state. The alternative — searching
465
+ * `state=all` and reopening on write — would need this activity to guess
466
+ * when a finding is "resolved" with no reliable signal for that (an empty
467
+ * change set this run says nothing about whether the *last* finding was
468
+ * fixed or just not evaluated), and would turn a deliberate human "done" into
469
+ * something the bot silently reverses on its next run.
470
+ *
471
+ * `.pull_request == null` is filtered out of the search because GitHub's
472
+ * issues-list endpoint also returns pull requests, which this function must
473
+ * never mistake for an issue it owns.
474
+ */
475
+ export async function postOrUpdateGithubIssue(
476
+ repo: string,
477
+ marker: string,
478
+ title: string,
479
+ body: string,
480
+ exec: GhExec,
481
+ ): Promise<string> {
482
+ const base = githubApiBaseFrom(process.env);
483
+ const listUrl = `${base}/repos/${repo}/issues?state=open`;
484
+ const jq =
485
+ 'map(select((.pull_request == null) and ((.body // "") | startswith(' +
486
+ `"${marker}"))))` +
487
+ " | .[0].number // empty";
488
+ const { stdout: found } = await exec(`gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`);
489
+ // `--paginate` prints one `--jq` result per page, same as postOrUpdateComment.
490
+ const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
491
+ const titleField = shellQuote(`title=${title}`);
492
+ const bodyField = shellQuote(`body=${marker}\n\n${body}`);
493
+
494
+ if (existing) {
495
+ const { stdout } = await exec(
496
+ `gh api --method PATCH ${shellQuote(`${base}/repos/${repo}/issues/${existing}`)} ` +
497
+ `-f ${titleField} -f ${bodyField} --jq .html_url`,
498
+ );
499
+ return stdout.trim();
500
+ }
501
+ const { stdout } = await exec(
502
+ `gh api --method POST ${shellQuote(`${base}/repos/${repo}/issues`)} -f ${titleField} -f ${bodyField} --jq .html_url`,
263
503
  );
264
504
  return stdout.trim();
265
505
  }
@@ -330,6 +570,54 @@ export function mergeRequestContextFrom(
330
570
  };
331
571
  }
332
572
 
573
+ /**
574
+ * The GitLab project an `issue`-mode run opens or updates its issue on (#2292),
575
+ * as any GitLab CI job knows it — the counterpart of {@link
576
+ * mergeRequestContextFrom} for a mode that needs no merge request.
577
+ */
578
+ export interface GitlabProjectContext {
579
+ /** REST v4 base, from `CI_API_V4_URL` or derived from `CI_SERVER_URL`. */
580
+ api: string;
581
+ /** The project holding the issue — its numeric id, or a `group/project` path. */
582
+ project: string;
583
+ /** `group/project`, for a human-readable result. */
584
+ path?: string;
585
+ /** The project's web URL, used to build the issue's own URL. */
586
+ webUrl?: string;
587
+ }
588
+
589
+ /**
590
+ * Derive the GitLab project an `issue`-mode run is in, from the job's own CI
591
+ * variables. Pure — exported for testing, and the whole forge detection:
592
+ * `CI_PROJECT_ID` is set on every GitLab CI job, merge request or not, and
593
+ * nothing outside GitLab CI sets it — a GitHub Actions run never reaches this
594
+ * branch. Unlike {@link mergeRequestContextFrom}, no `CI_MERGE_REQUEST_IID`
595
+ * is required, since `issue` mode's whole point is a cron trigger that has
596
+ * none.
597
+ *
598
+ * Returns undefined rather than throwing, so the caller owns the fallback:
599
+ * a run with no GitLab signal falls through to `gh issue create`.
600
+ */
601
+ export function gitlabProjectContextFrom(
602
+ env: Record<string, string | undefined>,
603
+ ): GitlabProjectContext | undefined {
604
+ const project = env.CI_PROJECT_ID?.trim();
605
+ if (!project) return undefined;
606
+
607
+ const server = env.CI_SERVER_URL?.trim().replace(/\/+$/, "");
608
+ const api = env.CI_API_V4_URL?.trim().replace(/\/+$/, "") || (server ? `${server}/api/v4` : "");
609
+ if (!api) return undefined;
610
+
611
+ const path = env.CI_PROJECT_PATH?.trim();
612
+ const webUrl = env.CI_PROJECT_URL?.trim();
613
+ return {
614
+ api,
615
+ project,
616
+ ...(path ? { path } : {}),
617
+ ...(webUrl ? { webUrl } : {}),
618
+ };
619
+ }
620
+
333
621
  /** The credential a merge-request note is written with, and the header GitLab reads it from. */
334
622
  export interface GitlabNoteToken {
335
623
  /** `PRIVATE-TOKEN` for a personal/project/group access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. */
@@ -377,6 +665,16 @@ export function noGitlabNoteTokenMessage(iid: number): string {
377
665
  );
378
666
  }
379
667
 
668
+ /** What an `issue`-mode step says on a GitLab project it has no credential for (#2292). */
669
+ export function noGitlabIssueTokenMessage(project: string): string {
670
+ return (
671
+ `reconcilePr mode "issue" wants to open or update an issue on GitLab project ${project} and has no ` +
672
+ "token to do it with. Set a GITLAB_TOKEN CI/CD variable (masked, scope: api) on the project — a " +
673
+ "project access token is enough — or, on an instance whose job-token allowlist covers the issues API, " +
674
+ "make CI_JOB_TOKEN available to the job. CHANT_GITLAB_TOKEN is read first where the two must differ."
675
+ );
676
+ }
677
+
380
678
  /** One page of merge-request notes, as much of each as this activity reads. */
381
679
  interface GitlabNote {
382
680
  id: number;
@@ -484,6 +782,93 @@ async function postOrUpdateNote(
484
782
  : `${endpoint}/${note.id}`;
485
783
  }
486
784
 
785
+ // ── The GitLab issue (#2292) ─────────────────────────────────────────────
786
+
787
+ /** One GitLab issue, as much of it as this activity reads. */
788
+ interface GitlabIssue {
789
+ iid: number;
790
+ description?: string;
791
+ }
792
+
793
+ /** GitLab's REST path for a project's issues. */
794
+ function issuesEndpoint(ctx: GitlabProjectContext): string {
795
+ return `${ctx.api}/projects/${encodeURIComponent(ctx.project)}/issues`;
796
+ }
797
+
798
+ /**
799
+ * Find the issue this Op already owns in `ctx`'s project, by the same hidden
800
+ * marker `findOwnedNote` looks a merge-request note up by: the marker is the
801
+ * description's first line and the match is a prefix.
802
+ *
803
+ * `search`/`in=description` narrows the request server-side to issues whose
804
+ * description contains the marker, rather than paging every issue the
805
+ * project has ever opened — a long-lived project accumulates issues the way
806
+ * an active merge request accumulates notes, and the marker is exact text a
807
+ * full-text search matches reliably. The `startswith` check after the fetch
808
+ * still decides ownership, the same as the note lookup, since `search` finds
809
+ * the marker anywhere in the field and only a match at the very start is
810
+ * this Op's own issue rather than one that happens to quote it.
811
+ *
812
+ * Paged the way GitLab pages, following the `x-next-page` response header —
813
+ * see {@link findOwnedNote} for why a bounded loop rather than a guessed page
814
+ * count.
815
+ */
816
+ async function findOwnedIssue(
817
+ ctx: GitlabProjectContext,
818
+ token: GitlabNoteToken,
819
+ marker: string,
820
+ signal?: AbortSignal,
821
+ ): Promise<number | undefined> {
822
+ const endpoint = issuesEndpoint(ctx);
823
+ const MAX_PAGES = 50;
824
+ for (let page = 1; page <= MAX_PAGES; page++) {
825
+ const res = await gitlabRequest(
826
+ `${endpoint}?per_page=100&page=${page}&search=${encodeURIComponent(marker)}&in=description`,
827
+ token,
828
+ { method: "GET" },
829
+ signal,
830
+ );
831
+ const issues = (await res.json()) as GitlabIssue[];
832
+ const owned = issues.find((issue) => (issue.description ?? "").startsWith(marker));
833
+ if (owned) return owned.iid;
834
+ const next = res.headers.get("x-next-page")?.trim();
835
+ if (!next) return undefined;
836
+ }
837
+ return undefined;
838
+ }
839
+
840
+ /**
841
+ * Open `title`/`body` as one issue in `ctx`'s project, or edit the issue this
842
+ * Op already owns there — {@link postOrUpdateNote}'s issue counterpart, and
843
+ * the same recipe: find by marker, PUT when there is one, POST when there is
844
+ * not, so a cron Op that finds drift every night carries one issue holding
845
+ * the current finding rather than a new one each run (#2292).
846
+ *
847
+ * `title` is written on every call, POST or PUT, so the issue's headline
848
+ * stays current (e.g. an entry count) even though only the description's
849
+ * marker is what makes the issue findable again.
850
+ */
851
+ async function postOrUpdateIssue(
852
+ ctx: GitlabProjectContext,
853
+ token: GitlabNoteToken,
854
+ marker: string,
855
+ title: string,
856
+ body: string,
857
+ signal?: AbortSignal,
858
+ ): Promise<string> {
859
+ const endpoint = issuesEndpoint(ctx);
860
+ const existing = await findOwnedIssue(ctx, token, marker, signal);
861
+ const payload = JSON.stringify({ title, description: `${marker}\n\n${body}` });
862
+ const res = existing
863
+ ? await gitlabRequest(`${endpoint}/${existing}`, token, { method: "PUT", body: payload }, signal)
864
+ : await gitlabRequest(endpoint, token, { method: "POST", body: payload }, signal);
865
+ const issue = (await res.json()) as GitlabIssue;
866
+ // GitLab does return a `web_url` on an issue payload, unlike a note, but
867
+ // building it from the project's own web URL keeps this symmetric with
868
+ // `postOrUpdateNote` and needs no extra field pinned in tests.
869
+ return ctx.webUrl ? `${ctx.webUrl}/-/issues/${issue.iid}` : `${endpoint}/${issue.iid}`;
870
+ }
871
+
487
872
  /**
488
873
  * Map a `chant lifecycle plan --json` ChangeSet to reconcile entries, dropping
489
874
  * `noop` entries (nothing to reconcile). Pure — exported for testing.
@@ -515,16 +900,24 @@ async function derivePlanEntries(
515
900
  * Reconcile activity: turn regenerated TypeScript into a reviewable artifact.
516
901
  *
517
902
  * - `report` — return the summary only; no git, no network.
518
- * - `issue` — open a GitHub issue describing the drift (no code change).
903
+ * - `issue` — open or edit one issue describing the drift (no code change).
904
+ * On a GitHub Actions or Forgejo Actions job, or GitHub Enterprise Server,
905
+ * the OPEN issue already carrying this Op's hidden marker is edited in
906
+ * place and a new one opened only when there is none (#2297). On a GitLab
907
+ * CI job (any trigger — the point of this mode is a cron run that has no
908
+ * merge request), the same recipe against one GitLab issue (#2292). Neither
909
+ * branch ever closes the issue itself — see {@link postOrUpdateGithubIssue}
910
+ * for that decision and why.
519
911
  * - `comment` — post the body as one comment on the pull request that
520
912
  * 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.
913
+ * stacking a new one (#2231) — on GitHub and on Forgejo alike (#2291), the
914
+ * same GitHub-shaped `issues/{n}/comments` calls against each host's own
915
+ * API base — or, on a GitLab `merge_request_event` pipeline, as one note on
916
+ * that merge request by the same recipe (#2256). Needs a pull-request- or
917
+ * merge-request-triggered run; fails by name when there is none. No code
918
+ * change, and the `pull-requests: write` the generated workflow already
919
+ * grants on that trigger is the whole scope it spends on GitHub and
920
+ * Forgejo; on GitLab the scope is whatever the token it is given carries.
528
921
  * - `pull-request` — create a branch, regenerate source via
529
922
  * `chant import --from <env>`, commit, push, and open a PR whose diff is the
530
923
  * regenerated TypeScript. Never commits to the main branch.
@@ -549,8 +942,38 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
549
942
  }
550
943
 
551
944
  if (mode === "issue") {
945
+ const marker = args.marker ?? issueMarker(args.env);
946
+
947
+ // GitLab first, because its check is the narrow one: `CI_PROJECT_ID` is
948
+ // set on every GitLab CI job, and nothing outside GitLab CI sets it
949
+ // (#2292) — see `gitlabProjectContextFrom`.
950
+ const project = gitlabProjectContextFrom(process.env);
951
+ if (project) {
952
+ const token = gitlabNoteTokenFrom(process.env);
953
+ if (!token) throw new Error(noGitlabIssueTokenMessage(project.project));
954
+ const issueUrl = await postOrUpdateIssue(project, token, marker, title, summary, signal);
955
+ return { mode, summary, entries, issueUrl };
956
+ }
957
+
958
+ // GitHub, GHES, or Forgejo next: any job that ran under GitHub Actions or
959
+ // Forgejo Actions carries GITHUB_REPOSITORY (#2297) — see
960
+ // postOrUpdateGithubIssue's own doc comment for the search mechanism and
961
+ // the close decision.
962
+ const repo = process.env.GITHUB_REPOSITORY;
963
+ if (repo) {
964
+ const issueUrl = await postOrUpdateGithubIssue(repo, marker, title, summary, (cmd) =>
965
+ execAsync(cmd, { signal }),
966
+ );
967
+ return { mode, summary, entries, issueUrl };
968
+ }
969
+
970
+ // Outside any known CI job (a local or manual invocation): fall back to
971
+ // gh's own ambient repo detection, unchanged from before #2297. The
972
+ // marker is still written here — it costs nothing, and means a later CI
973
+ // run of the same Op finds and edits this issue instead of opening a
974
+ // second one.
552
975
  const { stdout } = await execAsync(
553
- `gh issue create --title ${shellQuote(title)} --body ${shellQuote(summary)}`,
976
+ `gh issue create --title ${shellQuote(title)} --body ${shellQuote(`${marker}\n\n${summary}`)}`,
554
977
  { signal },
555
978
  );
556
979
  return { mode, summary, entries, issueUrl: stdout.trim() };