@intentius/chant 0.61.0 → 0.63.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 (122) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -18
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/handlers/run.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +3 -0
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/components/driver-output.d.ts.map +1 -1
  10. package/dist/components/driver.d.ts +12 -0
  11. package/dist/components/driver.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +10 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +51 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/git.d.ts +117 -0
  20. package/dist/lifecycle/git.d.ts.map +1 -1
  21. package/dist/lifecycle/index.d.ts +1 -0
  22. package/dist/lifecycle/index.d.ts.map +1 -1
  23. package/dist/lifecycle/plan-digest.d.ts +33 -0
  24. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
  27. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  28. package/dist/op/activities/lifecycle.d.ts +27 -0
  29. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  30. package/dist/op/activities/reconcile.d.ts +394 -14
  31. package/dist/op/activities/reconcile.d.ts.map +1 -1
  32. package/dist/op/builders.d.ts +6 -0
  33. package/dist/op/builders.d.ts.map +1 -1
  34. package/dist/op/composites/apply-op.d.ts +6 -0
  35. package/dist/op/composites/apply-op.d.ts.map +1 -1
  36. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  37. package/dist/op/gate-summary.d.ts +16 -0
  38. package/dist/op/gate-summary.d.ts.map +1 -1
  39. package/dist/op/gate.d.ts +104 -13
  40. package/dist/op/gate.d.ts.map +1 -1
  41. package/dist/op/index.d.ts +3 -2
  42. package/dist/op/index.d.ts.map +1 -1
  43. package/dist/op/local-executor.d.ts +34 -2
  44. package/dist/op/local-executor.d.ts.map +1 -1
  45. package/dist/op/local-output.d.ts.map +1 -1
  46. package/dist/op/op-ir.d.ts +8 -1
  47. package/dist/op/op-ir.d.ts.map +1 -1
  48. package/dist/op/operator.d.ts.map +1 -1
  49. package/dist/op/runtime.d.ts +2 -0
  50. package/dist/op/runtime.d.ts.map +1 -1
  51. package/dist/op/runtimes/local.d.ts.map +1 -1
  52. package/dist/op/types.d.ts +19 -0
  53. package/dist/op/types.d.ts.map +1 -1
  54. package/dist/runtime-adapter.d.ts +8 -0
  55. package/dist/runtime-adapter.d.ts.map +1 -1
  56. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  57. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  58. package/dist/terraform/graph.d.ts +18 -2
  59. package/dist/terraform/graph.d.ts.map +1 -1
  60. package/dist/terraform/parse.d.ts.map +1 -1
  61. package/dist/terraform/types.d.ts +7 -0
  62. package/dist/terraform/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cli/handlers/operator.test.ts +232 -1
  65. package/src/cli/handlers/operator.ts +130 -6
  66. package/src/cli/handlers/run.ts +19 -0
  67. package/src/cli/main.ts +2 -0
  68. package/src/cli/registry.ts +2 -0
  69. package/src/components/cli-support.ts +29 -4
  70. package/src/components/driver-output.ts +10 -0
  71. package/src/components/driver.test.ts +31 -0
  72. package/src/components/driver.ts +54 -8
  73. package/src/discovery/fold-import.test.ts +55 -0
  74. package/src/fold/fold.test.ts +152 -0
  75. package/src/fold/fold.ts +102 -2
  76. package/src/fold/subset-doc-parity.test.ts +35 -1
  77. package/src/fold/subset.ts +10 -0
  78. package/src/lexicon.ts +51 -0
  79. package/src/lifecycle/gate-ledger.test.ts +133 -1
  80. package/src/lifecycle/gate-ledger.ts +108 -0
  81. package/src/lifecycle/git.test.ts +49 -5
  82. package/src/lifecycle/git.ts +312 -11
  83. package/src/lifecycle/index.ts +1 -0
  84. package/src/lifecycle/plan-digest.test.ts +49 -0
  85. package/src/lifecycle/plan-digest.ts +86 -0
  86. package/src/lifecycle/run-ledger.ts +1 -0
  87. package/src/op/activities/lexicon-upgrade.test.ts +134 -39
  88. package/src/op/activities/lexicon-upgrade.ts +74 -12
  89. package/src/op/activities/lifecycle.ts +51 -2
  90. package/src/op/activities/reconcile.test.ts +1013 -1
  91. package/src/op/activities/reconcile.ts +721 -25
  92. package/src/op/builders.ts +7 -1
  93. package/src/op/composites/apply-op.ts +16 -0
  94. package/src/op/composites/composites.test.ts +15 -2
  95. package/src/op/composites/reconcile-op.test.ts +18 -0
  96. package/src/op/composites/reconcile-op.ts +7 -1
  97. package/src/op/gate-summary.test.ts +33 -0
  98. package/src/op/gate-summary.ts +31 -0
  99. package/src/op/gate.test.ts +614 -0
  100. package/src/op/gate.ts +190 -24
  101. package/src/op/index.ts +5 -2
  102. package/src/op/local-executor.test.ts +340 -2
  103. package/src/op/local-executor.ts +190 -32
  104. package/src/op/local-output.test.ts +38 -0
  105. package/src/op/local-output.ts +24 -1
  106. package/src/op/op-ir.test.ts +22 -0
  107. package/src/op/op-ir.ts +9 -0
  108. package/src/op/operator.test.ts +20 -0
  109. package/src/op/operator.ts +39 -1
  110. package/src/op/runtime.ts +2 -0
  111. package/src/op/runtimes/local.ts +11 -0
  112. package/src/op/types.ts +19 -0
  113. package/src/runtime-adapter.ts +17 -3
  114. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  115. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  116. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  117. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  118. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  119. package/src/terraform/graph.test.ts +148 -1
  120. package/src/terraform/graph.ts +144 -6
  121. package/src/terraform/parse.ts +4 -1
  122. package/src/terraform/types.ts +7 -0
@@ -19,6 +19,64 @@ 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. Which is why that marker has to name the Op (chant #2319) and why
41
+ * this mode refuses a step that supplies neither an `op` nor a `marker` — see
42
+ * {@link issueMarker} and {@link noIssueIdentityMessage}.
43
+ *
44
+ * none. See {@link postOrUpdateGithubIssue} for the GitHub/GHES/Forgejo half
45
+ * — including why it does not use GitHub's Search API, and the decision that
46
+ * this mode never closes the issue itself. Only a run outside any known CI
47
+ * job — no `CI_PROJECT_ID`, no `GITHUB_REPOSITORY` — falls back to `gh issue
48
+ * create`'s own ambient repo detection, still marker-prefixed so a later CI
49
+ * run of the same Op finds and edits it instead of opening a second one.
50
+ *
51
+ * The credential did not always travel (chant #2320). The URL and the
52
+ * GET/PATCH/POST triple matched `postOrUpdateComment`'s, but that function
53
+ * resolved a token through {@link commentTokenFrom} and forwarded it as
54
+ * `GH_TOKEN` while the issue path forwarded no environment at all, so
55
+ * `CHANT_FORGEJO_TOKEN` never reached `gh`. Both paths now resolve the same
56
+ * way, in the same order, and forward the same variable, with the two
57
+ * refusals differing only in what they name ({@link noCommentTokenMessage},
58
+ * {@link noIssueTokenMessage}). The one deliberate exception is the `gh issue
59
+ * create` fallback above, which runs outside every CI job and so leaves the
60
+ * credential to `gh auth login` — see the comment on that branch. Parity with
61
+ * `comment` mode is not a working Forgejo write, as the next paragraph says.
62
+ *
63
+ * Settled against a real Forgejo 12.0.4+gitea-1.22.0 instance under chant
64
+ * #2315, the way `comment` mode's endpoints were under #2291: the *read*
65
+ * half works — plain paginated listing (no `/search/issues`, confirmed
66
+ * absent from Forgejo's own OpenAPI spec) and the `.pull_request == null`
67
+ * filter both behave exactly as they do against github.com. The *write*
68
+ * half does not, for a reason no mock could have caught: `gh`'s own
69
+ * `GH_TOKEN`/`GITHUB_TOKEN` only authenticate a request to github.com or a
70
+ * ghe.com subdomain (`gh help environment`), never a self-hosted Forgejo,
71
+ * and this function's POST/PATCH calls (like `postOrUpdateComment`'s) carry
72
+ * only `GH_TOKEN` — no `GH_HOST`, no `GH_ENTERPRISE_TOKEN`. Every write this
73
+ * mode makes on a real Forgejo instance fails with `{"message":"token is
74
+ * required"}` (HTTP 401), confirmed with `GH_DEBUG=api` sending no
75
+ * `Authorization` header at all. The forgejo Op generator refuses
76
+ * `findingMode: "issue"` by name for this reason (chant #2315); this
77
+ * activity's own behavior is unchanged; a hand-authored (non-generated)
78
+ * workflow that calls it directly against a Forgejo host will hit the same
79
+ * 401 the generator now refuses to produce.
22
80
  */
23
81
  export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
24
82
 
@@ -52,11 +110,32 @@ export interface ReconcilePrArgs {
52
110
  /** PR / issue title. Default derived from env. */
53
111
  title?: string;
54
112
  /**
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.
113
+ * The name of the Op this step belongs to, which is what makes an `issue`
114
+ * mode marker unique (#2319) — see {@link issueMarker}. Set it to the
115
+ * enclosing `Op`'s own `name`; both in-tree composites do, and a
116
+ * hand-written step should too.
117
+ *
118
+ * `issue` mode needs this or an explicit `marker`, and refuses by name
119
+ * ({@link noIssueIdentityMessage}) with neither: without one the marker
120
+ * falls back to naming only the env, two Ops over one env collide on it,
121
+ * and each nightly run silently overwrites the other's report.
122
+ *
123
+ * `comment` mode ignores it. That mode's marker is scoped to one pull
124
+ * request rather than to the repository, so its collision (two `comment`
125
+ * Ops over one env, both triggered by one pull request) is bounded by that
126
+ * pull request's life and leaves nothing behind. Tracked separately rather
127
+ * than folded in here, because changing `commentMarker` would orphan the
128
+ * comments already sitting on open pull requests.
129
+ */
130
+ op?: string;
131
+ /**
132
+ * Hidden marker identifying this Op's own comment or issue. The activity
133
+ * writes it as the first line and finds it again by it on the next run, so
134
+ * a re-run edits one comment/issue instead of stacking a new one. Default:
135
+ * {@link commentMarker} for `comment` mode, keyed on `env`; {@link
136
+ * issueMarker} for `issue` mode, keyed on `op` and `env` together. Supply
137
+ * it directly to own uniqueness yourself — in `issue` mode that is the one
138
+ * way to satisfy the identity requirement without passing `op`.
60
139
  */
61
140
  marker?: string;
62
141
  /**
@@ -83,7 +162,7 @@ export interface ReconcileResult {
83
162
  branch?: string;
84
163
  /** Opened PR URL (pull-request mode). */
85
164
  prUrl?: string;
86
- /** Opened issue URL (issue mode). */
165
+ /** Opened or edited issue URL (issue mode; sticky on every forge — #2292, #2297). */
87
166
  issueUrl?: string;
88
167
  /** The posted or updated PR comment / MR note URL (comment mode). */
89
168
  commentUrl?: string;
@@ -148,7 +227,132 @@ export interface PullRequestContext {
148
227
  * environments own two comments and each updates in place.
149
228
  */
150
229
  export function commentMarker(env: string): string {
151
- return `<!-- chant-reconcile:${env.replace(/[^a-zA-Z0-9._-]+/g, "-")} -->`;
230
+ return `<!-- chant-reconcile:${markerSlug(env)} -->`;
231
+ }
232
+
233
+ /**
234
+ * The hidden marker that makes an `issue`-mode finding findable across re-runs
235
+ * (#2292, #2297): written as the issue description's first line, matched on
236
+ * the next run by a `startswith` check (GitHub/Forgejo) or a server-side
237
+ * `search` plus the same check (GitLab). The same recipe {@link commentMarker}
238
+ * names for the `comment` mode's note, kept as its own function (rather than
239
+ * reused) because the two modes write to different resources and a caller may
240
+ * run both against the same `env`.
241
+ *
242
+ * Keyed on the owning Op *and* the env, not the env alone (#2319). An
243
+ * env-keyed marker was not unique, and once #2311 made this mode edit in place
244
+ * that stopped being merely untidy: `postOrUpdateGithubIssue` PATCHes both the
245
+ * title and the body of whatever it matches, so two Ops resolving to one
246
+ * marker rewrite each other's report on every run and the issue alternates
247
+ * between two unrelated findings. Two in-tree pairings hit it — a stock
248
+ * `TerraformWatchOp` and a `live: true` one over the same root (both pass the
249
+ * root as `env`), and a `ReconcileOp` for a chant environment whose name
250
+ * matches some terraform root — and both are ordinary configurations, not
251
+ * abuse. The Op name is the identifier closest to unique that this activity
252
+ * can be handed: it names the Op's output directory and is what `chant run`
253
+ * takes, so two Ops in a project do not share a raw name.
254
+ * `lexicons/github`'s gate notice reached the same conclusion first, keying
255
+ * its own sticky marker on `$CHANT_OP` alone.
256
+ *
257
+ * Both halves are slugified the same way {@link reconcileBranchName} slugifies
258
+ * an env, which also keeps the value free of the quotes and backslashes it is
259
+ * interpolated next to and of anything URL-encoding should have to escape out
260
+ * of. `/` cannot survive that slugify, so it separates the two halves
261
+ * unambiguously: the marker parses back to exactly one `op` slug and one `env`
262
+ * slug.
263
+ *
264
+ * What that leaves, and what is accepted rather than solved (#2319 pre-merge
265
+ * review): the slug is not injective. {@link markerSlug} collapses every run
266
+ * of characters outside `[A-Za-z0-9._-]` to a single `-`, so `"app watch"` and
267
+ * `"app:watch"` share a slug, and so do `"café"` and `"caf€"`. Two Ops named
268
+ * that way in one project over one env would still collide. Nothing in this
269
+ * repository constrains an Op name's character set today, so this cannot be
270
+ * closed here — it wants a charset rule on `OpConfig.name` (which would also
271
+ * settle the output directory and the branch name, both of which slugify the
272
+ * same way and have the same aliasing). Until then the residual is: distinct
273
+ * raw names, distinct markers, unless the names differ only in characters the
274
+ * slug does not keep.
275
+ */
276
+ export function issueMarker(op: string, env: string): string {
277
+ return `<!-- chant-reconcile-issue:${markerSlug(op)}/${markerSlug(env)} -->`;
278
+ }
279
+
280
+ /** The slugify both markers share: everything outside `[A-Za-z0-9._-]` collapses to `-`. */
281
+ function markerSlug(s: string): string {
282
+ return s.replace(/[^a-zA-Z0-9._-]+/g, "-");
283
+ }
284
+
285
+ /**
286
+ * What any mode says about a supplied `marker` it cannot use safely (#2319
287
+ * pre-merge review).
288
+ *
289
+ * The marker is interpolated raw into the `--jq` filter that finds this Op's
290
+ * own comment or issue, inside a jq string literal: `startswith("<marker>")`.
291
+ * Only the whole filter is shell-quoted, so a `"` or a `\` in the marker
292
+ * closes or escapes past that literal — at best a jq syntax error, at worst a
293
+ * filter that matches something other than what the caller wrote. A control
294
+ * character does the same to the jq program's own line structure.
295
+ *
296
+ * Markers this activity builds itself cannot contain any of the three:
297
+ * {@link markerSlug} keeps them to `[A-Za-z0-9._-]` plus the fixed `<!-- … -->`
298
+ * frame. This checks the one that comes in from outside, and refuses rather
299
+ * than escaping, because a marker is an identity a caller has to be able to
300
+ * predict — silently rewriting it would move the issue this run owns.
301
+ */
302
+ export function unsafeMarkerMessage(marker: string): string {
303
+ return (
304
+ `reconcilePr was given the marker ${JSON.stringify(marker)}, which it cannot use. The marker goes into ` +
305
+ 'the `--jq` filter that finds this Op\'s own comment or issue, as `startswith("<marker>")`, so a double ' +
306
+ "quote, a backslash or a control character in it either breaks that filter or changes what it matches. " +
307
+ "Markers this activity builds itself are slugified to letters, digits, dot, underscore and hyphen and " +
308
+ "cannot contain any of the three. Keep a supplied marker to printable text without `\"` or `\\`, or drop " +
309
+ "`marker` and pass `op` instead."
310
+ );
311
+ }
312
+
313
+ /**
314
+ * The caller's `marker` as this activity will actually use it, or `undefined`
315
+ * when the caller supplied nothing usable (#2319 pre-merge review).
316
+ *
317
+ * Blank is not a marker. `""` and `" "` used to satisfy the `issue`-mode
318
+ * identity check — `args.marker === undefined` is false for both, and `??`
319
+ * only falls through on nullish — and then built `startswith("")`, which is
320
+ * true of every issue body in the repository. So the step took whichever OPEN
321
+ * non-pull-request issue the forge listed first, very possibly a human's, and
322
+ * PATCHed its title and body. That is the precise failure #2319 exists to
323
+ * close, reachable through the escape hatch #2319 added, which is why the
324
+ * check lives on the field rather than in one mode's branch.
325
+ *
326
+ * Trimming rather than only rejecting: a marker with a trailing space is
327
+ * written and matched consistently either way, and normalizing it here means
328
+ * one definition of "blank" for both modes.
329
+ */
330
+ export function suppliedMarker(marker: string | undefined): string | undefined {
331
+ const trimmed = marker?.trim();
332
+ if (!trimmed) return undefined;
333
+ if (/["\\]|[\u0000-\u001f\u007f]/.test(trimmed)) throw new Error(unsafeMarkerMessage(trimmed));
334
+ return trimmed;
335
+ }
336
+
337
+ /**
338
+ * What an `issue`-mode step says when it was given no identity to key its
339
+ * marker on (#2319).
340
+ *
341
+ * A refusal rather than a fall back to the pre-#2319 env-only marker, because
342
+ * the fallback is the bug: it is silent, it looks like it worked, and what it
343
+ * costs is the *other* Op's report, which nobody is watching the log of. The
344
+ * step that has to change is the one being refused, and the message names the
345
+ * two ways to change it.
346
+ */
347
+ export function noIssueIdentityMessage(env: string): string {
348
+ return (
349
+ 'reconcilePr mode "issue" edits the one OPEN issue carrying this Op\'s hidden marker in place, so the ' +
350
+ `marker has to name the Op. This step supplies env "${env}" and no identity, and an env alone is not ` +
351
+ "unique: two Ops over one env — a stock terraform drift watch and a live one over the same root, or a " +
352
+ "terraform root and a chant environment that happen to share a name — resolve to the same marker, and " +
353
+ "each run then rewrites the other's issue title and body. Pass `op` with the Op's own name (every " +
354
+ "composite in tree does), or pass an explicit `marker` you keep unique yourself."
355
+ );
152
356
  }
153
357
 
154
358
  /** What a `comment`-mode step says when the run it is in has no pull request and no merge request. */
@@ -223,6 +427,84 @@ export async function resolvePullRequestContext(
223
427
  return ctx;
224
428
  }
225
429
 
430
+ /**
431
+ * The REST base a GitHub-shaped `gh api` call should target, read the same
432
+ * way the runner itself reads it (chant #2291). `GITHUB_API_URL` is a default
433
+ * environment variable every GitHub Actions *and* Forgejo Actions job carries
434
+ * — `https://api.github.com` on github.com, `<host>/api/v3` on GitHub
435
+ * Enterprise Server, and `<host>/api/v1` on Forgejo, which already advertises
436
+ * it correctly (confirmed on a real Forgejo 12.0.4+gitea-1.22.0 instance
437
+ * during INTENTIUS/choudoufu#1027: `$GITHUB_API_URL` read
438
+ * `http://forgejo:3000/api/v1` inside the job).
439
+ *
440
+ * Building the full URL from this rather than handing `gh api` a bare
441
+ * relative path (`repos/…`) is the fix itself: `gh` resolves a relative path
442
+ * by guessing a host-specific prefix of its own — `api.<host>` for
443
+ * `github.com`, `<host>/api/v3` for anything else — and that guess is `/api/v3`
444
+ * for a Forgejo host too, which Forgejo answers 404 for both GET and POST.
445
+ * Handed a full URL, `gh api` uses it verbatim and skips the guess entirely,
446
+ * which is what let the same `gh` binary reach Forgejo's `/api/v1` in the
447
+ * same session, over plain HTTP and over TLS. No Forgejo-specific branch is
448
+ * needed: every host in play (github.com, GHES, Forgejo) sets
449
+ * `GITHUB_API_URL` to the base its own `/repos/...` paths actually live
450
+ * under, so building the URL from it is correct everywhere `gh` already ran,
451
+ * not only on Forgejo.
452
+ */
453
+ export function githubApiBaseFrom(env: Record<string, string | undefined>): string {
454
+ return env.GITHUB_API_URL?.trim().replace(/\/+$/, "") || "https://api.github.com";
455
+ }
456
+
457
+ /** The credential a GitHub- or Forgejo-shaped comment call is made with. */
458
+ export interface CommentToken {
459
+ value: string;
460
+ /** The variable it came from, so a refusal or a log line can name it. */
461
+ source: string;
462
+ }
463
+
464
+ /**
465
+ * Resolve the token `postOrUpdateComment` sends with, most specific first
466
+ * (chant #2291). `gh` itself already resolves `GH_TOKEN`/`GITHUB_TOKEN`
467
+ * ambiently from the process environment, and the generated workflow sets
468
+ * both to `${{ github.token }}` for every non-`report` finding mode — on
469
+ * Forgejo the same way as on GitHub, and the real-instance read confirmed
470
+ * `github.token` is populated there and good for a 200 read and a 201 write,
471
+ * authored as `forgejo-actions`. So the common case needs nothing set beyond
472
+ * what the generator already emits.
473
+ *
474
+ * `CHANT_FORGEJO_TOKEN` is checked first for the case that ambient token
475
+ * cannot cover: a run posting to a Forgejo instance other than the one the
476
+ * job executes on, where `github.token`'s scope stops at its own instance.
477
+ * Resolving explicitly (rather than leaving it entirely to `gh`) also buys a
478
+ * named failure before the shell-out, in place of `gh`'s own opaque 401.
479
+ */
480
+ export function commentTokenFrom(env: Record<string, string | undefined>): CommentToken | undefined {
481
+ for (const source of ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
482
+ const value = env[source]?.trim();
483
+ if (value) return { value, source };
484
+ }
485
+ return undefined;
486
+ }
487
+
488
+ /** What a `comment`-mode step says on a pull request it has no credential for. */
489
+ export function noCommentTokenMessage(repo: string, number: number): string {
490
+ return (
491
+ `reconcilePr mode "comment" has pull request ${repo}#${number} to post its finding on and no token to ` +
492
+ "post it with. GH_TOKEN or GITHUB_TOKEN, set from github.token, already covers this on a GitHub Actions " +
493
+ "or Forgejo Actions run — set CHANT_FORGEJO_TOKEN to post against a different instance than the one the " +
494
+ "job runs on. CHANT_FORGEJO_TOKEN is read first where the two must differ."
495
+ );
496
+ }
497
+
498
+ /** What an `issue`-mode step says on a repository it has no credential for (#2320). */
499
+ export function noIssueTokenMessage(repo: string): string {
500
+ return (
501
+ `reconcilePr mode "issue" has repository ${repo} to open or edit its finding in and no token to do it ` +
502
+ "with. GH_TOKEN or GITHUB_TOKEN, set from github.token, already covers this on a GitHub Actions or " +
503
+ "Forgejo Actions run — set CHANT_FORGEJO_TOKEN to post against a different instance than the one the " +
504
+ "job runs on. CHANT_FORGEJO_TOKEN is read first where the two must differ."
505
+ );
506
+ }
507
+
226
508
  /**
227
509
  * Post `body` as one comment on `ctx`'s pull request, or edit the comment this
228
510
  * Op already owns there. The sticky-comment recipe the github lexicon's
@@ -230,7 +512,16 @@ export async function resolvePullRequestContext(
230
512
  * find the comment whose body starts with `marker`, PATCH it when there is
231
513
  * one, POST otherwise. `gh` ships on GitHub's hosted runners and is already
232
514
  * this activity's dependency for the issue and pull-request modes, so the
233
- * mode needs nothing new on the runner.
515
+ * mode needs nothing new on the runner — Forgejo's `act_runner` ships `gh`
516
+ * too, and Forgejo's `/api/v1` takes the same calls (chant #2291).
517
+ *
518
+ * Every call targets a full URL built from {@link githubApiBaseFrom} rather
519
+ * than the bare relative path this used before #2291 — see that function for
520
+ * why a bare path broke Forgejo specifically. The token is resolved
521
+ * explicitly via {@link commentTokenFrom} and forwarded as `GH_TOKEN`, which
522
+ * is a strict superset of `gh`'s own ambient resolution: same value in the
523
+ * common case, a named refusal instead of `gh`'s opaque 401 when neither is
524
+ * set.
234
525
  */
235
526
  async function postOrUpdateComment(
236
527
  ctx: PullRequestContext,
@@ -238,11 +529,16 @@ async function postOrUpdateComment(
238
529
  body: string,
239
530
  signal?: AbortSignal,
240
531
  ): Promise<string> {
241
- const listPath = `repos/${ctx.repo}/issues/${ctx.number}/comments`;
532
+ const token = commentTokenFrom(process.env);
533
+ if (!token) throw new Error(noCommentTokenMessage(ctx.repo, ctx.number));
534
+ const env = { ...process.env, GH_TOKEN: token.value };
535
+
536
+ const base = githubApiBaseFrom(process.env);
537
+ const listUrl = `${base}/repos/${ctx.repo}/issues/${ctx.number}/comments`;
242
538
  const jq = `map(select(.body | startswith("${marker}"))) | .[0].id // empty`;
243
539
  const { stdout: found } = await execAsync(
244
- `gh api ${shellQuote(listPath)} --paginate --jq ${shellQuote(jq)}`,
245
- { signal },
540
+ `gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`,
541
+ { signal, env },
246
542
  );
247
543
  // `--paginate` prints one `--jq` result per page, so take the first line
248
544
  // that is an id and ignore the empty ones the other pages produce.
@@ -251,15 +547,173 @@ async function postOrUpdateComment(
251
547
 
252
548
  if (existing) {
253
549
  const { stdout } = await execAsync(
254
- `gh api --method PATCH ${shellQuote(`repos/${ctx.repo}/issues/comments/${existing}`)} ` +
550
+ `gh api --method PATCH ${shellQuote(`${base}/repos/${ctx.repo}/issues/comments/${existing}`)} ` +
255
551
  `-f ${shellQuote(field)} --jq .html_url`,
256
- { signal },
552
+ { signal, env },
257
553
  );
258
554
  return stdout.trim();
259
555
  }
260
556
  const { stdout } = await execAsync(
261
- `gh api --method POST ${shellQuote(listPath)} -f ${shellQuote(field)} --jq .html_url`,
262
- { signal },
557
+ `gh api --method POST ${shellQuote(listUrl)} -f ${shellQuote(field)} --jq .html_url`,
558
+ { signal, env },
559
+ );
560
+ return stdout.trim();
561
+ }
562
+
563
+ // ── The GitHub/GHES/Forgejo sticky issue (#2297) ────────────────────────────
564
+
565
+ /**
566
+ * Minimal `gh` invocation shape {@link postOrUpdateGithubIssue} threads its
567
+ * calls through. `reconcilePr` wraps `execAsync` (carrying its own `signal`)
568
+ * to this shape; `lexiconUpgrade` passes its injectable `GhRunner` straight
569
+ * through, since the two already share this signature. One function, not two
570
+ * copies, because the recipe below — search, then PATCH or POST — is
571
+ * identical for both callers; only the issue's title/body and the marker that
572
+ * owns it differ.
573
+ *
574
+ * The `opts` argument exists so `postOrUpdateGithubIssue` can hand `gh` the
575
+ * token it resolved (chant #2320) rather than leaving `gh` to find one
576
+ * ambiently. A caller that adds nothing of its own can ignore it and pass it
577
+ * straight to `exec`; `reconcilePr` merges it with the `signal` it already
578
+ * carries. It is deliberately not a whole `ExecOptions`: an implementation
579
+ * that had to honour `cwd` or `shell` too would be a second exec wrapper, and
580
+ * the point of this type is that there is only one.
581
+ */
582
+ export type GhExec = (
583
+ cmd: string,
584
+ opts?: { env?: NodeJS.ProcessEnv },
585
+ ) => Promise<{ stdout: string; stderr: string }>;
586
+
587
+ /**
588
+ * Open `title`/`body` as one GitHub-shaped issue in `repo`, or edit the issue
589
+ * this caller already owns there (#2297) — `postOrUpdateComment`'s issue
590
+ * counterpart, and the GitHub/GHES/Forgejo sibling of GitLab's
591
+ * `postOrUpdateIssue` (#2292): find by marker, PATCH when found, POST when
592
+ * not, so a nightly finding carries one issue holding the current state
593
+ * instead of stacking a new one every run. Shared by `reconcilePr`'s own
594
+ * `issue` mode and `lexiconUpgrade`'s per-lexicon upgrade-status issue.
595
+ *
596
+ * Every call targets a full URL built from {@link githubApiBaseFrom}, the
597
+ * same as `postOrUpdateComment` (#2291) — so this reaches GitHub Enterprise
598
+ * Server the same way it reaches github.com.
599
+ *
600
+ * The credential is resolved and forwarded the same way too (chant #2320),
601
+ * through {@link commentTokenFrom} and out as `GH_TOKEN` on every call. It
602
+ * did not used to be: `reconcilePr` handed this `(cmd) => execAsync(cmd, {
603
+ * signal })` with no `env` at all, so `CHANT_FORGEJO_TOKEN` never reached
604
+ * `gh` and the one case that variable exists for — posting to a Forgejo
605
+ * instance other than the one the job runs on, where `github.token`'s scope
606
+ * stops at its own instance — failed with a 401 or a 404 that named nothing.
607
+ * With no token at all the operator got `gh`'s own error rather than chant's.
608
+ * The refusal here is {@link noIssueTokenMessage}, not the `comment` mode's
609
+ * {@link noCommentTokenMessage}: same resolution order, same advice, but that
610
+ * message names a pull request this mode does not have, and this function is
611
+ * shared with `lexiconUpgrade`, which is not `reconcilePr` at all. The same
612
+ * split GitLab already makes between `noGitlabNoteTokenMessage` and
613
+ * `noGitlabIssueTokenMessage`.
614
+ *
615
+ * ## Why plain pagination instead of GitHub's Search API
616
+ *
617
+ * GitHub offers real server-side full-text search — `gh issue list --search`
618
+ * and `GET /search/issues?q=...` both use it, and chant #2297 (the issue that
619
+ * asked for this) names both as candidates. Neither is used here, for three
620
+ * reasons found while building this:
621
+ *
622
+ * 1. **Consistency.** GitHub's search index is documented as eventually
623
+ * consistent: an issue this function just wrote is not guaranteed to be
624
+ * findable by search immediately afterward. That is exactly the
625
+ * read-after-write the sticky recipe depends on — a false "not found"
626
+ * means a duplicate issue, which is the bug this activity exists to fix.
627
+ * 2. **The marker itself.** The hidden marker is deliberately punctuation-
628
+ * heavy (`<!-- chant-reconcile-issue:nightly/app -->`) so it renders
629
+ * invisibly. GitHub's search tokenizer is not documented to preserve that
630
+ * shape through `in:body` matching, and a wrong query would either miss the
631
+ * marker (a duplicate, the same failure mode as above) or over-match and
632
+ * still need the same client-side `startswith` check this function
633
+ * already does — at which point the search bought nothing but risk.
634
+ * 3. **Forgejo.** `gh issue list --search` and `/search/issues` are both
635
+ * absent from Forgejo's API — checked against a live Forgejo instance's
636
+ * own OpenAPI spec while building this: no `/search/issues`, no
637
+ * `/repos/issues/search` path anywhere in it (Forgejo's own issues-list
638
+ * endpoint does carry a real `q` search parameter, but that is a
639
+ * different endpoint shape than GitHub's, which would mean forking this
640
+ * function by forge — exactly what the `comment` path (#2291)
641
+ * deliberately does not do). Chant #2315 confirmed this against a live
642
+ * call rather than only the spec: plain paginated `GET .../issues?state=
643
+ * open` on a real Forgejo 12.0.4+gitea-1.22.0 instance interleaves pull
644
+ * requests with issues exactly as GitHub's endpoint does, and the
645
+ * `.pull_request == null` filter below excludes them correctly. The write
646
+ * calls this function makes do not clear the same instance — see the
647
+ * module doc's `issue` bullet for why, and why the forgejo Op generator
648
+ * refuses this mode rather than generating a job that would 401 on every
649
+ * run.
650
+ *
651
+ * So this reuses the recipe `postOrUpdateComment` already proved for PR
652
+ * comments: list, `--paginate`, filter with `--jq` by an exact `startswith`
653
+ * prefix match, which is correct no matter how many pages it takes. The cost
654
+ * is real — a repository with many thousands of issues pays for every page
655
+ * on every run — and is the accepted trade-off here; narrowing further with
656
+ * GitHub's `creator` filter was considered and left alone, because the
657
+ * identity a `GH_TOKEN` posts as cannot be assumed reliably across a
658
+ * generated workflow and a hand-run one, and a wrong `creator` is a false
659
+ * negative — a duplicate — not just a slower search.
660
+ *
661
+ * ## The close decision (#2297)
662
+ *
663
+ * This never closes the issue, in either branch, matching both GitLab paths
664
+ * (#2256, #2292) — neither of which closes anything either. The search is
665
+ * scoped to `state=open` rather than `state=all`, which makes that decision
666
+ * concrete rather than implicit: a human closing the issue by hand, once the
667
+ * drift it named is actually fixed, is what tells the *next* run to open a
668
+ * fresh issue for a fresh finding instead of silently rewriting the closed
669
+ * one back open-in-substance-but-not-in-state. The alternative — searching
670
+ * `state=all` and reopening on write — would need this activity to guess
671
+ * when a finding is "resolved" with no reliable signal for that (an empty
672
+ * change set this run says nothing about whether the *last* finding was
673
+ * fixed or just not evaluated), and would turn a deliberate human "done" into
674
+ * something the bot silently reverses on its next run.
675
+ *
676
+ * `.pull_request == null` is filtered out of the search because GitHub's
677
+ * issues-list endpoint also returns pull requests, which this function must
678
+ * never mistake for an issue it owns.
679
+ */
680
+ export async function postOrUpdateGithubIssue(
681
+ repo: string,
682
+ marker: string,
683
+ title: string,
684
+ body: string,
685
+ exec: GhExec,
686
+ ): Promise<string> {
687
+ const token = commentTokenFrom(process.env);
688
+ if (!token) throw new Error(noIssueTokenMessage(repo));
689
+ const env = { ...process.env, GH_TOKEN: token.value };
690
+
691
+ const base = githubApiBaseFrom(process.env);
692
+ const listUrl = `${base}/repos/${repo}/issues?state=open`;
693
+ const jq =
694
+ 'map(select((.pull_request == null) and ((.body // "") | startswith(' +
695
+ `"${marker}"))))` +
696
+ " | .[0].number // empty";
697
+ const { stdout: found } = await exec(
698
+ `gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`,
699
+ { env },
700
+ );
701
+ // `--paginate` prints one `--jq` result per page, same as postOrUpdateComment.
702
+ const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
703
+ const titleField = shellQuote(`title=${title}`);
704
+ const bodyField = shellQuote(`body=${marker}\n\n${body}`);
705
+
706
+ if (existing) {
707
+ const { stdout } = await exec(
708
+ `gh api --method PATCH ${shellQuote(`${base}/repos/${repo}/issues/${existing}`)} ` +
709
+ `-f ${titleField} -f ${bodyField} --jq .html_url`,
710
+ { env },
711
+ );
712
+ return stdout.trim();
713
+ }
714
+ const { stdout } = await exec(
715
+ `gh api --method POST ${shellQuote(`${base}/repos/${repo}/issues`)} -f ${titleField} -f ${bodyField} --jq .html_url`,
716
+ { env },
263
717
  );
264
718
  return stdout.trim();
265
719
  }
@@ -330,6 +784,54 @@ export function mergeRequestContextFrom(
330
784
  };
331
785
  }
332
786
 
787
+ /**
788
+ * The GitLab project an `issue`-mode run opens or updates its issue on (#2292),
789
+ * as any GitLab CI job knows it — the counterpart of {@link
790
+ * mergeRequestContextFrom} for a mode that needs no merge request.
791
+ */
792
+ export interface GitlabProjectContext {
793
+ /** REST v4 base, from `CI_API_V4_URL` or derived from `CI_SERVER_URL`. */
794
+ api: string;
795
+ /** The project holding the issue — its numeric id, or a `group/project` path. */
796
+ project: string;
797
+ /** `group/project`, for a human-readable result. */
798
+ path?: string;
799
+ /** The project's web URL, used to build the issue's own URL. */
800
+ webUrl?: string;
801
+ }
802
+
803
+ /**
804
+ * Derive the GitLab project an `issue`-mode run is in, from the job's own CI
805
+ * variables. Pure — exported for testing, and the whole forge detection:
806
+ * `CI_PROJECT_ID` is set on every GitLab CI job, merge request or not, and
807
+ * nothing outside GitLab CI sets it — a GitHub Actions run never reaches this
808
+ * branch. Unlike {@link mergeRequestContextFrom}, no `CI_MERGE_REQUEST_IID`
809
+ * is required, since `issue` mode's whole point is a cron trigger that has
810
+ * none.
811
+ *
812
+ * Returns undefined rather than throwing, so the caller owns the fallback:
813
+ * a run with no GitLab signal falls through to `gh issue create`.
814
+ */
815
+ export function gitlabProjectContextFrom(
816
+ env: Record<string, string | undefined>,
817
+ ): GitlabProjectContext | undefined {
818
+ const project = env.CI_PROJECT_ID?.trim();
819
+ if (!project) return undefined;
820
+
821
+ const server = env.CI_SERVER_URL?.trim().replace(/\/+$/, "");
822
+ const api = env.CI_API_V4_URL?.trim().replace(/\/+$/, "") || (server ? `${server}/api/v4` : "");
823
+ if (!api) return undefined;
824
+
825
+ const path = env.CI_PROJECT_PATH?.trim();
826
+ const webUrl = env.CI_PROJECT_URL?.trim();
827
+ return {
828
+ api,
829
+ project,
830
+ ...(path ? { path } : {}),
831
+ ...(webUrl ? { webUrl } : {}),
832
+ };
833
+ }
834
+
333
835
  /** The credential a merge-request note is written with, and the header GitLab reads it from. */
334
836
  export interface GitlabNoteToken {
335
837
  /** `PRIVATE-TOKEN` for a personal/project/group access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. */
@@ -377,6 +879,16 @@ export function noGitlabNoteTokenMessage(iid: number): string {
377
879
  );
378
880
  }
379
881
 
882
+ /** What an `issue`-mode step says on a GitLab project it has no credential for (#2292). */
883
+ export function noGitlabIssueTokenMessage(project: string): string {
884
+ return (
885
+ `reconcilePr mode "issue" wants to open or update an issue on GitLab project ${project} and has no ` +
886
+ "token to do it with. Set a GITLAB_TOKEN CI/CD variable (masked, scope: api) on the project — a " +
887
+ "project access token is enough — or, on an instance whose job-token allowlist covers the issues API, " +
888
+ "make CI_JOB_TOKEN available to the job. CHANT_GITLAB_TOKEN is read first where the two must differ."
889
+ );
890
+ }
891
+
380
892
  /** One page of merge-request notes, as much of each as this activity reads. */
381
893
  interface GitlabNote {
382
894
  id: number;
@@ -484,6 +996,93 @@ async function postOrUpdateNote(
484
996
  : `${endpoint}/${note.id}`;
485
997
  }
486
998
 
999
+ // ── The GitLab issue (#2292) ─────────────────────────────────────────────
1000
+
1001
+ /** One GitLab issue, as much of it as this activity reads. */
1002
+ interface GitlabIssue {
1003
+ iid: number;
1004
+ description?: string;
1005
+ }
1006
+
1007
+ /** GitLab's REST path for a project's issues. */
1008
+ function issuesEndpoint(ctx: GitlabProjectContext): string {
1009
+ return `${ctx.api}/projects/${encodeURIComponent(ctx.project)}/issues`;
1010
+ }
1011
+
1012
+ /**
1013
+ * Find the issue this Op already owns in `ctx`'s project, by the same hidden
1014
+ * marker `findOwnedNote` looks a merge-request note up by: the marker is the
1015
+ * description's first line and the match is a prefix.
1016
+ *
1017
+ * `search`/`in=description` narrows the request server-side to issues whose
1018
+ * description contains the marker, rather than paging every issue the
1019
+ * project has ever opened — a long-lived project accumulates issues the way
1020
+ * an active merge request accumulates notes, and the marker is exact text a
1021
+ * full-text search matches reliably. The `startswith` check after the fetch
1022
+ * still decides ownership, the same as the note lookup, since `search` finds
1023
+ * the marker anywhere in the field and only a match at the very start is
1024
+ * this Op's own issue rather than one that happens to quote it.
1025
+ *
1026
+ * Paged the way GitLab pages, following the `x-next-page` response header —
1027
+ * see {@link findOwnedNote} for why a bounded loop rather than a guessed page
1028
+ * count.
1029
+ */
1030
+ async function findOwnedIssue(
1031
+ ctx: GitlabProjectContext,
1032
+ token: GitlabNoteToken,
1033
+ marker: string,
1034
+ signal?: AbortSignal,
1035
+ ): Promise<number | undefined> {
1036
+ const endpoint = issuesEndpoint(ctx);
1037
+ const MAX_PAGES = 50;
1038
+ for (let page = 1; page <= MAX_PAGES; page++) {
1039
+ const res = await gitlabRequest(
1040
+ `${endpoint}?per_page=100&page=${page}&search=${encodeURIComponent(marker)}&in=description`,
1041
+ token,
1042
+ { method: "GET" },
1043
+ signal,
1044
+ );
1045
+ const issues = (await res.json()) as GitlabIssue[];
1046
+ const owned = issues.find((issue) => (issue.description ?? "").startsWith(marker));
1047
+ if (owned) return owned.iid;
1048
+ const next = res.headers.get("x-next-page")?.trim();
1049
+ if (!next) return undefined;
1050
+ }
1051
+ return undefined;
1052
+ }
1053
+
1054
+ /**
1055
+ * Open `title`/`body` as one issue in `ctx`'s project, or edit the issue this
1056
+ * Op already owns there — {@link postOrUpdateNote}'s issue counterpart, and
1057
+ * the same recipe: find by marker, PUT when there is one, POST when there is
1058
+ * not, so a cron Op that finds drift every night carries one issue holding
1059
+ * the current finding rather than a new one each run (#2292).
1060
+ *
1061
+ * `title` is written on every call, POST or PUT, so the issue's headline
1062
+ * stays current (e.g. an entry count) even though only the description's
1063
+ * marker is what makes the issue findable again.
1064
+ */
1065
+ async function postOrUpdateIssue(
1066
+ ctx: GitlabProjectContext,
1067
+ token: GitlabNoteToken,
1068
+ marker: string,
1069
+ title: string,
1070
+ body: string,
1071
+ signal?: AbortSignal,
1072
+ ): Promise<string> {
1073
+ const endpoint = issuesEndpoint(ctx);
1074
+ const existing = await findOwnedIssue(ctx, token, marker, signal);
1075
+ const payload = JSON.stringify({ title, description: `${marker}\n\n${body}` });
1076
+ const res = existing
1077
+ ? await gitlabRequest(`${endpoint}/${existing}`, token, { method: "PUT", body: payload }, signal)
1078
+ : await gitlabRequest(endpoint, token, { method: "POST", body: payload }, signal);
1079
+ const issue = (await res.json()) as GitlabIssue;
1080
+ // GitLab does return a `web_url` on an issue payload, unlike a note, but
1081
+ // building it from the project's own web URL keeps this symmetric with
1082
+ // `postOrUpdateNote` and needs no extra field pinned in tests.
1083
+ return ctx.webUrl ? `${ctx.webUrl}/-/issues/${issue.iid}` : `${endpoint}/${issue.iid}`;
1084
+ }
1085
+
487
1086
  /**
488
1087
  * Map a `chant lifecycle plan --json` ChangeSet to reconcile entries, dropping
489
1088
  * `noop` entries (nothing to reconcile). Pure — exported for testing.
@@ -511,20 +1110,62 @@ async function derivePlanEntries(
511
1110
  return entriesFromPlan(stdout);
512
1111
  }
513
1112
 
1113
+ /**
1114
+ * The marker the run will write and find its finding by, for the two modes
1115
+ * that have one, or `undefined` for the two that do not (#2319, and its
1116
+ * pre-merge review).
1117
+ *
1118
+ * Pure, and called before anything else in {@link reconcilePr} so that both
1119
+ * refusals it can raise — no identity, unusable marker — happen before the
1120
+ * activity shells out to anything.
1121
+ *
1122
+ * `issue` mode requires an identity: the Op's name, or a marker the caller
1123
+ * keeps unique. `comment` mode does not, because its marker is scoped to one
1124
+ * pull request rather than to the repository — see `ReconcilePrArgs.op` and
1125
+ * {@link issueMarker} for that asymmetry and what is tracked separately.
1126
+ * Both modes get the same {@link suppliedMarker} treatment of the field,
1127
+ * because a blank or unusable marker is a property of the field, not of the
1128
+ * mode reading it.
1129
+ */
1130
+ function resolveMarker(mode: ReconcileMode, args: ReconcilePrArgs): string | undefined {
1131
+ if (mode === "issue") {
1132
+ const supplied = suppliedMarker(args.marker);
1133
+ const op = args.op?.trim();
1134
+ if (!supplied && !op) throw new Error(noIssueIdentityMessage(args.env));
1135
+ return supplied ?? issueMarker(op as string, args.env);
1136
+ }
1137
+ if (mode === "comment") {
1138
+ return suppliedMarker(args.marker) ?? commentMarker(args.env);
1139
+ }
1140
+ return undefined;
1141
+ }
1142
+
514
1143
  /**
515
1144
  * Reconcile activity: turn regenerated TypeScript into a reviewable artifact.
516
1145
  *
517
1146
  * - `report` — return the summary only; no git, no network.
518
- * - `issue` — open a GitHub issue describing the drift (no code change).
1147
+ * - `issue` — open or edit one issue describing the drift (no code change).
1148
+ * On a GitHub Actions or Forgejo Actions job, or GitHub Enterprise Server,
1149
+ * the OPEN issue already carrying this Op's hidden marker is edited in
1150
+ * place and a new one opened only when there is none (#2297). On a GitLab
1151
+ * CI job (any trigger — the point of this mode is a cron run that has no
1152
+ * merge request), the same recipe against one GitLab issue (#2292). Neither
1153
+ * branch ever closes the issue itself — see {@link postOrUpdateGithubIssue}
1154
+ * for that decision and why. The marker names the Op as well as the env
1155
+ * (#2319), so the step needs `op` or an explicit `marker` and fails by name
1156
+ * with neither; an issue left over from the pre-#2319 env-only marker
1157
+ * matches no Op's marker now and is left where it is, unedited, rather than
1158
+ * adopted by whichever Op happens to run first.
519
1159
  * - `comment` — post the body as one comment on the pull request that
520
1160
  * 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.
1161
+ * stacking a new one (#2231) — on GitHub and on Forgejo alike (#2291), the
1162
+ * same GitHub-shaped `issues/{n}/comments` calls against each host's own
1163
+ * API base — or, on a GitLab `merge_request_event` pipeline, as one note on
1164
+ * that merge request by the same recipe (#2256). Needs a pull-request- or
1165
+ * merge-request-triggered run; fails by name when there is none. No code
1166
+ * change, and the `pull-requests: write` the generated workflow already
1167
+ * grants on that trigger is the whole scope it spends on GitHub and
1168
+ * Forgejo; on GitLab the scope is whatever the token it is given carries.
528
1169
  * - `pull-request` — create a branch, regenerate source via
529
1170
  * `chant import --from <env>`, commit, push, and open a PR whose diff is the
530
1171
  * regenerated TypeScript. Never commits to the main branch.
@@ -538,6 +1179,18 @@ async function derivePlanEntries(
538
1179
  export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal): Promise<ReconcileResult> {
539
1180
  const mode = args.mode ?? "pull-request";
540
1181
  const owned = args.owned ?? false;
1182
+
1183
+ // Resolved first, ahead of the `chant lifecycle plan` shell-out below
1184
+ // (#2319 pre-merge review). The identity refusal used to sit inside the
1185
+ // `issue` branch, which is after that derivation, so the arg shape that
1186
+ // actually reaches it — `{ env, op, mode, owned }`, `ReconcileOp`'s own,
1187
+ // with no `body` to short-circuit the plan — ran a shell command before
1188
+ // failing. `chant lifecycle plan` is read-only, so nothing was written; but
1189
+ // a plan that fails first hands the operator a plan error in place of the
1190
+ // named refusal this design rests on, which is the whole point of refusing
1191
+ // by name.
1192
+ const resolvedMarker = resolveMarker(mode, args);
1193
+
541
1194
  // A caller-supplied body means the finding is already written, so there is
542
1195
  // nothing for `chant lifecycle plan` to tell us (#2087).
543
1196
  const entries = args.entries ?? (args.body !== undefined ? [] : await derivePlanEntries(args.env, owned, signal));
@@ -549,8 +1202,51 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
549
1202
  }
550
1203
 
551
1204
  if (mode === "issue") {
1205
+ // Non-null because `resolveMarker` returns a string for this mode or
1206
+ // throws, and it already ran at the top of the function.
1207
+ const marker = resolvedMarker!;
1208
+
1209
+ // GitLab first, because its check is the narrow one: `CI_PROJECT_ID` is
1210
+ // set on every GitLab CI job, and nothing outside GitLab CI sets it
1211
+ // (#2292) — see `gitlabProjectContextFrom`.
1212
+ const project = gitlabProjectContextFrom(process.env);
1213
+ if (project) {
1214
+ const token = gitlabNoteTokenFrom(process.env);
1215
+ if (!token) throw new Error(noGitlabIssueTokenMessage(project.project));
1216
+ const issueUrl = await postOrUpdateIssue(project, token, marker, title, summary, signal);
1217
+ return { mode, summary, entries, issueUrl };
1218
+ }
1219
+
1220
+ // GitHub, GHES, or Forgejo next: any job that ran under GitHub Actions or
1221
+ // Forgejo Actions carries GITHUB_REPOSITORY (#2297) — see
1222
+ // postOrUpdateGithubIssue's own doc comment for the search mechanism and
1223
+ // the close decision.
1224
+ const repo = process.env.GITHUB_REPOSITORY;
1225
+ if (repo) {
1226
+ const issueUrl = await postOrUpdateGithubIssue(repo, marker, title, summary, (cmd, opts) =>
1227
+ execAsync(cmd, { signal, ...opts }),
1228
+ );
1229
+ return { mode, summary, entries, issueUrl };
1230
+ }
1231
+
1232
+ // Outside any known CI job (a local or manual invocation): fall back to
1233
+ // gh's own ambient repo detection, unchanged from before #2297. The
1234
+ // marker is still written here — it costs nothing, and means a later CI
1235
+ // run of the same Op finds and edits this issue instead of opening a
1236
+ // second one.
1237
+ //
1238
+ // This is the one path that does NOT go through `commentTokenFrom`
1239
+ // (#2320), deliberately. It is reached only when the run is outside every
1240
+ // CI job chant recognizes, which is where `gh auth login`'s stored
1241
+ // credential is the credential — and that is a login `commentTokenFrom`
1242
+ // cannot see, so refusing on a missing environment variable here would
1243
+ // reject the exact setup the branch exists to serve. `gh` still reads
1244
+ // GH_TOKEN and GITHUB_TOKEN off this process's own environment if they
1245
+ // are set; what it does not get is CHANT_FORGEJO_TOKEN promoted into
1246
+ // GH_TOKEN, because promoting it would silently outrank the login the
1247
+ // operator is standing in front of.
552
1248
  const { stdout } = await execAsync(
553
- `gh issue create --title ${shellQuote(title)} --body ${shellQuote(summary)}`,
1249
+ `gh issue create --title ${shellQuote(title)} --body ${shellQuote(`${marker}\n\n${summary}`)}`,
554
1250
  { signal },
555
1251
  );
556
1252
  return { mode, summary, entries, issueUrl: stdout.trim() };
@@ -560,7 +1256,7 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
560
1256
  // The trigger context is read here rather than passed in: a step's args
561
1257
  // are serialized at build time, and the pull request is not known until
562
1258
  // the run. Missing context is fatal — see `noPullRequestContextMessage`.
563
- const marker = args.marker ?? commentMarker(args.env);
1259
+ const marker = resolvedMarker!;
564
1260
 
565
1261
  // GitLab first, because its check is the narrow one: only a
566
1262
  // `merge_request_event` pipeline sets `CI_MERGE_REQUEST_IID` (#2256), so