@intentius/chant 0.122.0 → 0.124.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 (203) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  4. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts +1 -1
  6. package/dist/cli/handlers/operator.d.ts.map +1 -1
  7. package/dist/cli/handlers/override-approve.d.ts +17 -0
  8. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  9. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +4 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config-import.d.ts +1 -1
  14. package/dist/gate-decision.d.ts +157 -0
  15. package/dist/gate-decision.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-applied.d.ts +75 -0
  17. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-audit.d.ts +145 -0
  19. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  22. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  23. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/override-ledger.d.ts +34 -0
  25. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/rollback.d.ts.map +1 -1
  27. package/dist/lifecycle/work-item-id.d.ts +22 -0
  28. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  29. package/dist/lifecycle/work-lease.d.ts +2 -2
  30. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  31. package/dist/lint/policy-import.d.ts +1 -1
  32. package/dist/lint/post-synth.d.ts +20 -1
  33. package/dist/lint/post-synth.d.ts.map +1 -1
  34. package/dist/notify-verify.d.ts +62 -0
  35. package/dist/notify-verify.d.ts.map +1 -0
  36. package/dist/notify-verify.js +76 -0
  37. package/dist/notify.d.ts +202 -0
  38. package/dist/notify.d.ts.map +1 -0
  39. package/dist/notify.js +307 -0
  40. package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
  41. package/dist/op/activities/activity-contracts.d.ts +4 -4
  42. package/dist/op/activities/gh-host.d.ts +37 -0
  43. package/dist/op/activities/gh-host.d.ts.map +1 -0
  44. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  45. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  46. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  47. package/dist/op/activities/reconcile.d.ts +74 -76
  48. package/dist/op/activities/reconcile.d.ts.map +1 -1
  49. package/dist/op/gate-approval.d.ts +17 -1
  50. package/dist/op/gate-approval.d.ts.map +1 -1
  51. package/dist/op/gate-name.d.ts +8 -1
  52. package/dist/op/gate-name.d.ts.map +1 -1
  53. package/dist/op/gate-resume.d.ts +4 -2
  54. package/dist/op/gate-resume.d.ts.map +1 -1
  55. package/dist/op/gate-review.d.ts +2 -1
  56. package/dist/op/gate-review.d.ts.map +1 -1
  57. package/dist/op/gate.d.ts +43 -6
  58. package/dist/op/gate.d.ts.map +1 -1
  59. package/dist/op/index.d.ts +8 -6
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/op-waves-notify.d.ts +31 -0
  62. package/dist/op/op-waves-notify.d.ts.map +1 -0
  63. package/dist/op/op-waves-run.d.ts +218 -16
  64. package/dist/op/op-waves-run.d.ts.map +1 -1
  65. package/dist/op/op-waves.d.ts +113 -2
  66. package/dist/op/op-waves.d.ts.map +1 -1
  67. package/dist/op/operator.d.ts +15 -0
  68. package/dist/op/operator.d.ts.map +1 -1
  69. package/dist/op/work-lease-decl.d.ts.map +1 -1
  70. package/dist/op/work-lease-run.d.ts.map +1 -1
  71. package/dist/override-gate.d.ts +224 -0
  72. package/dist/override-gate.d.ts.map +1 -0
  73. package/dist/pr-forge.d.ts +225 -19
  74. package/dist/pr-forge.d.ts.map +1 -1
  75. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
  76. package/dist/workspace/conformance/broker.d.ts +1 -1
  77. package/dist/workspace/status-gates.d.ts +1 -0
  78. package/dist/workspace/status-gates.d.ts.map +1 -1
  79. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  80. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  81. package/dist/workspace/trust/policy.d.ts +2 -41
  82. package/dist/workspace/trust/policy.d.ts.map +1 -1
  83. package/dist/workspace/trust/seal.d.ts +2 -27
  84. package/dist/workspace/trust/seal.d.ts.map +1 -1
  85. package/dist/workspace/trust/trust-files.d.ts +57 -0
  86. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  87. package/dist/workspace/work-cli.d.ts.map +1 -1
  88. package/package.json +15 -2
  89. package/src/cli/commands/build.ts +1 -1
  90. package/src/cli/commands/check-lexicon.ts +3 -1
  91. package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
  92. package/src/cli/commands/lint.test.ts +4 -109
  93. package/src/cli/commands/lint.ts +5 -2
  94. package/src/cli/discovery-skip.e2e.test.ts +18 -3
  95. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  96. package/src/cli/handlers/gate-resume.ts +3 -2
  97. package/src/cli/handlers/operator.test.ts +12 -1
  98. package/src/cli/handlers/operator.ts +25 -13
  99. package/src/cli/handlers/override-approve.test.ts +87 -0
  100. package/src/cli/handlers/override-approve.ts +103 -0
  101. package/src/cli/handlers/run-wave.ts +10 -2
  102. package/src/cli/main.test.ts +8 -0
  103. package/src/cli/main.ts +15 -0
  104. package/src/cli/registry.ts +4 -0
  105. package/src/config-import.ts +1 -1
  106. package/src/discovery/sandbox/run.test.ts +1 -1
  107. package/src/gate-decision-bundle.test.ts +56 -0
  108. package/src/gate-decision.test.ts +237 -0
  109. package/src/gate-decision.ts +321 -0
  110. package/src/lifecycle/gate-applied.ts +115 -0
  111. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  112. package/src/lifecycle/gate-audit.test.ts +134 -0
  113. package/src/lifecycle/gate-audit.ts +438 -0
  114. package/src/lifecycle/gate-ledger-select.ts +113 -0
  115. package/src/lifecycle/gate-ledger.test.ts +22 -0
  116. package/src/lifecycle/gate-ledger.ts +9 -104
  117. package/src/lifecycle/override-ledger.test.ts +63 -0
  118. package/src/lifecycle/override-ledger.ts +69 -0
  119. package/src/lifecycle/rollback.ts +5 -1
  120. package/src/lifecycle/work-item-id.test.ts +71 -0
  121. package/src/lifecycle/work-item-id.ts +35 -0
  122. package/src/lifecycle/work-lease.ts +4 -5
  123. package/src/lint/policy-import.ts +1 -1
  124. package/src/lint/post-synth.test.ts +29 -0
  125. package/src/lint/post-synth.ts +21 -2
  126. package/src/notify-bundle.test.ts +50 -0
  127. package/src/notify-plain-node.e2e.test.ts +68 -0
  128. package/src/notify-verify.test.ts +59 -0
  129. package/src/notify-verify.ts +93 -0
  130. package/src/notify.schema.json +72 -0
  131. package/src/notify.test.ts +279 -0
  132. package/src/notify.ts +398 -0
  133. package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
  134. package/src/op/activities/gh-host.test.ts +130 -0
  135. package/src/op/activities/gh-host.ts +79 -0
  136. package/src/op/activities/lexicon-upgrade.ts +22 -1
  137. package/src/op/activities/propose-upgrade.ts +5 -1
  138. package/src/op/activities/reconcile.test.ts +284 -280
  139. package/src/op/activities/reconcile.ts +128 -131
  140. package/src/op/gate-approval.test.ts +175 -7
  141. package/src/op/gate-approval.ts +27 -1
  142. package/src/op/gate-name.ts +10 -2
  143. package/src/op/gate-resume.ts +6 -3
  144. package/src/op/gate-review.ts +4 -7
  145. package/src/op/gate.ts +117 -16
  146. package/src/op/index.ts +36 -5
  147. package/src/op/op-waves-notify.test.ts +127 -0
  148. package/src/op/op-waves-notify.ts +101 -0
  149. package/src/op/op-waves-run.ts +908 -123
  150. package/src/op/op-waves.test.ts +543 -3
  151. package/src/op/op-waves.ts +215 -6
  152. package/src/op/operator.test.ts +29 -0
  153. package/src/op/operator.ts +27 -2
  154. package/src/op/run-context.test.ts +2 -2
  155. package/src/op/work-lease-decl.ts +4 -4
  156. package/src/op/work-lease-run.test.ts +5 -0
  157. package/src/op/work-lease-run.ts +4 -3
  158. package/src/override-gate-bundle.test.ts +47 -0
  159. package/src/override-gate.test.ts +239 -0
  160. package/src/override-gate.ts +395 -0
  161. package/src/pr-forge-bundle.test.ts +40 -0
  162. package/src/pr-forge.test.ts +378 -0
  163. package/src/pr-forge.ts +546 -47
  164. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
  165. package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
  166. package/src/workspace/conformance/broker.ts +1 -1
  167. package/src/workspace/records-write-contract.test.ts +3 -2
  168. package/src/workspace/records-write.e2e.test.ts +1 -1
  169. package/src/workspace/status-gates.ts +7 -4
  170. package/src/workspace/trust/gate-seal.test.ts +156 -0
  171. package/src/workspace/trust/gate-seal.ts +254 -0
  172. package/src/workspace/trust/policy.ts +10 -165
  173. package/src/workspace/trust/seal.ts +19 -34
  174. package/src/workspace/trust/trust-files.test.ts +50 -0
  175. package/src/workspace/trust/trust-files.ts +245 -0
  176. package/src/workspace/work-cli.ts +5 -3
  177. package/src/workspace/work-history.test.ts +7 -0
  178. /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
  179. /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
  180. /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
  181. /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
  182. /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
  183. /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
  184. /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
  185. /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
  186. /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
  187. /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
  188. /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
  189. /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
  190. /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
  191. /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
  192. /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
  193. /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
  194. /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
  195. /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
  196. /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
  197. /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
  198. /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
  199. /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
  200. /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
  201. /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
  202. /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
  203. /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
package/src/pr-forge.ts CHANGED
@@ -1,22 +1,33 @@
1
1
  /**
2
- * What the pull-request loop (#3183) asks of a forge: keep one note on the
3
- * pull request up to date, set a commit status per stage, list who approved
4
- * the pull request, and find the pull request that merged a commit.
2
+ * What chant and the products built on it ask of a forge: keep one comment
3
+ * (a merge request note on GitLab) on a pull request up to date, set a commit
4
+ * status, keep one tracking issue in the repository, list who approved a pull
5
+ * request, and find the pull request that merged a commit.
5
6
  *
6
- * GitHub and Forgejo speak the same REST shapes for the first three, at
7
+ * The pull-request loop (#3183) uses the note, the status and the review
8
+ * calls. `reconcilePr`'s `comment` and `issue` finding modes post through the
9
+ * same clients (#3757), so a finding needs no `gh` on the runner. terragucci
10
+ * and SQL Yodeler post their own notes, statuses and drift issues through
11
+ * them too (#3759): bodies stay with each product, the finding by marker and
12
+ * the forge's REST shapes live here.
13
+ *
14
+ * GitHub and Forgejo speak the same REST shapes for most of this, at
7
15
  * different base URLs (`$GITHUB_API_URL` is right on both, #2305); they part
8
- * on the commit-to-pull-request lookup and on review states. GitLab has its
9
- * own API for all four. Each client takes a `fetch`, so the tests drive them
10
- * with a recording fake and no network.
16
+ * on the commit-to-pull-request lookup, on review states and on page size.
17
+ * GitLab has its own API for all of it. Each client takes a `fetch`, so the
18
+ * tests drive them with a recording fake and no network.
11
19
  *
12
20
  * A reviewer is named the way #3163 names a person: `github:<login>`,
13
21
  * `gitlab:<login>`, or `<forge>@<host>:<login>` off the forge's default host.
14
22
  * The apply compares that string with the approval's `resolvedBy`.
15
23
  *
16
24
  * Like `./pr-loop.ts`, this bundles without the TypeScript toolchain (#3421):
17
- * it imports nothing.
25
+ * it imports nothing but `node:fs`, to read a CI job's event payload.
26
+ * `./pr-forge-bundle.test.ts` holds it to that.
18
27
  */
19
28
 
29
+ import { readFileSync } from "node:fs";
30
+
20
31
  export type ForgeKind = "github" | "gitlab" | "forgejo";
21
32
 
22
33
  export const FORGE_KINDS: readonly ForgeKind[] = ["github", "gitlab", "forgejo"];
@@ -28,12 +39,90 @@ export type ForgeFetch = (
28
39
  ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
29
40
 
30
41
  export interface ForgeStatus {
31
- /** `chant/plan` or `chant/apply`. */
42
+ /** The status's name, such as `chant/plan` or `terragucci/plan`. */
32
43
  context: string;
33
44
  state: "pending" | "success" | "failure";
45
+ /** Cut to what the forge keeps: 140 characters on GitHub and Forgejo, 255 on GitLab. */
34
46
  description: string;
35
47
  /** The pipeline run, linked from the status. */
36
48
  url?: string;
49
+ /** GitLab only: the pipeline the status belongs to. */
50
+ pipelineId?: number;
51
+ }
52
+
53
+ /**
54
+ * How a comment or an issue is told to be the caller's own. By default the
55
+ * body starts with the marker, chant's recipe since #2291, and anyone may
56
+ * have written it.
57
+ */
58
+ export interface MarkerMatch {
59
+ /** `prefix` (the default): the body starts with the marker. `anywhere`: the body carries it. */
60
+ match?: "prefix" | "anywhere";
61
+ /**
62
+ * Only a comment or issue written by this login counts: `user.login` on
63
+ * GitHub and Forgejo, `author.username` on GitLab, compared without case.
64
+ * A merge request's author can write a note carrying any marker, so a
65
+ * product that reads its own notes back names its bot here.
66
+ */
67
+ author?: string;
68
+ }
69
+
70
+ /** What {@link PrForge.upsertComment} did. */
71
+ export interface CommentResult {
72
+ action: "created" | "updated" | "unchanged";
73
+ /** The comment's or note's id. */
74
+ id: number;
75
+ /** Its web URL, when the forge's answer carries one (GitHub and Forgejo do; a GitLab note does not). */
76
+ url?: string;
77
+ }
78
+
79
+ /** A comment or note {@link PrForge.findComment} read back. */
80
+ export interface FoundComment {
81
+ /** The comment's or note's id. */
82
+ id: number;
83
+ body: string;
84
+ /** Its web URL, when the forge's answer carries one (GitHub and Forgejo do; a GitLab note does not). */
85
+ url?: string;
86
+ }
87
+
88
+ /** A finding to keep in a tracking issue. `body` carries the marker. */
89
+ export interface IssueFinding {
90
+ title: string;
91
+ body: string;
92
+ }
93
+
94
+ export interface TrackIssueOptions extends MarkerMatch {
95
+ /**
96
+ * When the finding has cleared (`null`), close the open issue, after
97
+ * {@link clearComment} when one is given. Off by default: `reconcilePr`'s
98
+ * `issue` mode never closes an issue, and a closed one is never reopened,
99
+ * so the next finding opens a fresh issue.
100
+ */
101
+ closeOnClear?: boolean;
102
+ /** Posted on the issue before it is closed. */
103
+ clearComment?: string;
104
+ /**
105
+ * When the open issue counts as already current, in place of an equal
106
+ * title and body, for a body that names something that changes on every
107
+ * run (the run's URL, a timestamp). A string: unchanged when the titles
108
+ * are equal and the open issue's body carries it, such as the finding's
109
+ * fingerprint. A function: unchanged when it returns true for the open
110
+ * issue and the finding. An unchanged issue is not edited.
111
+ */
112
+ sameWhen?: string | ((open: IssueFinding, finding: IssueFinding) => boolean);
113
+ }
114
+
115
+ /** What {@link PrForge.trackIssue} did. */
116
+ export interface IssueResult {
117
+ /**
118
+ * `opened`, `updated`, or `unchanged` (title and body already current) for
119
+ * a finding; `closed`, `left` (open, `closeOnClear` off) or `none` (no open
120
+ * issue) for a cleared one.
121
+ */
122
+ action: "opened" | "updated" | "unchanged" | "closed" | "left" | "none";
123
+ /** The issue's number (GitHub, Forgejo) or iid (GitLab). */
124
+ number?: number;
125
+ url?: string;
37
126
  }
38
127
 
39
128
  export interface PrForge {
@@ -44,15 +133,51 @@ export interface PrForge {
44
133
  runUrl?: string;
45
134
  /** How #3163 names `login` on this forge. */
46
135
  principalOf(login: string): string;
47
- /** Update the note on `pr` whose body starts with `marker`, or add one. */
48
- upsertNote(pr: number, marker: string, body: string): Promise<void>;
136
+ /**
137
+ * Keep one comment on pull request `pr` (one note on merge request `pr`):
138
+ * the one {@link MarkerMatch} finds is updated, unless its body is already
139
+ * `body`, or one is added. `body` must carry the marker the same way, or
140
+ * the next run could not find it; that is refused before any call. Every
141
+ * page of comments is read, and GitLab's system notes are skipped. When
142
+ * several match, the oldest is kept.
143
+ */
144
+ upsertComment(pr: number, marker: string, body: string, opts?: MarkerMatch): Promise<CommentResult>;
145
+ /**
146
+ * {@link upsertComment} by prefix, without the body check, resolving to the
147
+ * comment's web URL. The pull-request loop's form.
148
+ */
149
+ upsertNote(pr: number, marker: string, body: string): Promise<string | undefined>;
150
+ /**
151
+ * The comment on pull request `pr` (the note on merge request `pr`) that
152
+ * {@link upsertComment} would update for `marker`: the oldest one that
153
+ * {@link MarkerMatch} finds, `author` honoured, so a pull request's author
154
+ * cannot plant one. Undefined when there is none. Every page is read, and
155
+ * GitLab's system notes are skipped.
156
+ */
157
+ findComment(pr: number, marker: string, opts?: MarkerMatch): Promise<FoundComment | undefined>;
49
158
  setStatus(sha: string, status: ForgeStatus): Promise<void>;
159
+ /**
160
+ * Keep one OPEN tracking issue for `marker` in line with `finding`: open
161
+ * it, update its title and body, or, when `finding` is `null` and
162
+ * `closeOnClear` is set, close it with a comment. Only open issues are
163
+ * read, so an issue closed by hand stays closed. Pull requests are never
164
+ * taken for issues. When several match, the oldest is kept.
165
+ */
166
+ trackIssue(marker: string, finding: IssueFinding | null, opts?: TrackIssueOptions): Promise<IssueResult>;
167
+ /**
168
+ * {@link trackIssue} by prefix for a finding, without the body check,
169
+ * resolving to the issue's web URL. `reconcilePr`'s `issue` mode's form.
170
+ */
171
+ upsertIssue(marker: string, title: string, body: string): Promise<string | undefined>;
50
172
  /** Logins whose standing review approves `pr`. */
51
173
  approvers(pr: number): Promise<string[]>;
52
174
  /** The merged pull request that produced `sha`, or null. */
53
175
  pullRequestFor(sha: string): Promise<number | null>;
54
176
  }
55
177
 
178
+ /** A GitHub or Forgejo client. */
179
+ export type GithubShapedForge = PrForge & { kind: "github" | "forgejo" };
180
+
56
181
  /** Thrown when a forge answers a call with an error status. */
57
182
  export class ForgeApiError extends Error {
58
183
  constructor(
@@ -112,6 +237,99 @@ function transport(base: string, headers: Record<string, string>, fetchImpl: For
112
237
 
113
238
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
114
239
  const loginOf = (v: unknown): string | undefined => (isObject(v) && isObject(v.user) && typeof v.user.login === "string" ? v.user.login : undefined);
240
+ const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
241
+ const num = (v: unknown): number | undefined => (typeof v === "number" ? v : typeof v === "string" && /^\d+$/.test(v) ? Number(v) : undefined);
242
+
243
+ /** A comment, note or issue as the shared recipes read it. `id` is an issue's number or iid. */
244
+ interface Thread {
245
+ id: number;
246
+ body: string;
247
+ title?: string;
248
+ author?: string;
249
+ url?: string;
250
+ }
251
+
252
+ /** The calls each forge makes for the shared comment and issue recipes. */
253
+ interface Threads {
254
+ comments(pr: number): Promise<Thread[]>;
255
+ addComment(pr: number, body: string): Promise<Thread | undefined>;
256
+ editComment(pr: number, id: number, body: string): Promise<Thread | undefined>;
257
+ /** Open issues only, pull requests left out. */
258
+ openIssues(marker: string): Promise<Thread[]>;
259
+ openIssue(title: string, body: string): Promise<Thread | undefined>;
260
+ editIssue(n: number, title: string, body: string): Promise<Thread | undefined>;
261
+ closeIssue(n: number, comment: string | undefined): Promise<void>;
262
+ }
263
+
264
+ const carries = (body: string, marker: string, match: MarkerMatch["match"]): boolean =>
265
+ match === "anywhere" ? body.includes(marker) : body.startsWith(marker);
266
+
267
+ const ownedBy = (marker: string, opts: MarkerMatch) => (t: Thread): boolean =>
268
+ carries(t.body, marker, opts.match) && (!opts.author || t.author?.toLowerCase() === opts.author.toLowerCase());
269
+
270
+ function checkMarker(what: string, marker: string, body: string, match: MarkerMatch["match"]): void {
271
+ if (!marker) throw new Error(`the ${what} needs a marker to be found again`);
272
+ if (!carries(body, marker, match)) {
273
+ throw new Error(
274
+ `the ${what} body ${match === "anywhere" ? "does not carry" : "does not start with"} its marker ${JSON.stringify(marker)}, so the next run could not find it`,
275
+ );
276
+ }
277
+ }
278
+
279
+ /** The oldest comment on `pr` that `opts` takes for the caller's own. */
280
+ async function ownComment(t: Threads, pr: number, marker: string, opts: MarkerMatch): Promise<Thread | undefined> {
281
+ if (!marker) throw new Error("the comment needs a marker to be found");
282
+ return (await t.comments(pr)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
283
+ }
284
+
285
+ async function upsertCommentWith(t: Threads, pr: number, marker: string, body: string, opts: MarkerMatch, check: boolean): Promise<CommentResult> {
286
+ if (check) checkMarker("comment", marker, body, opts.match);
287
+ const mine = await ownComment(t, pr, marker, opts);
288
+ if (!mine) {
289
+ const made = await t.addComment(pr, body);
290
+ return { action: "created", id: made?.id ?? 0, ...(made?.url ? { url: made.url } : {}) };
291
+ }
292
+ if (mine.body === body) return { action: "unchanged", id: mine.id, ...(mine.url ? { url: mine.url } : {}) };
293
+ const edited = await t.editComment(pr, mine.id, body);
294
+ const url = edited?.url ?? mine.url;
295
+ return { action: "updated", id: mine.id, ...(url ? { url } : {}) };
296
+ }
297
+
298
+ async function trackIssueWith(t: Threads, marker: string, finding: IssueFinding | null, opts: TrackIssueOptions, check: boolean): Promise<IssueResult> {
299
+ if (finding && check) checkMarker("issue", marker, finding.body, opts.match);
300
+ const mine = (await t.openIssues(marker)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
301
+ const at = (i: Thread | undefined): Omit<IssueResult, "action"> => (i ? { number: i.id, ...(i.url ? { url: i.url } : {}) } : {});
302
+ if (finding === null) {
303
+ if (!mine) return { action: "none" };
304
+ if (!opts.closeOnClear) return { action: "left", ...at(mine) };
305
+ await t.closeIssue(mine.id, opts.clearComment);
306
+ return { action: "closed", ...at(mine) };
307
+ }
308
+ if (!mine) return { action: "opened", ...at(await t.openIssue(finding.title, finding.body)) };
309
+ if (sameIssue({ title: mine.title ?? "", body: mine.body }, finding, opts.sameWhen)) return { action: "unchanged", ...at(mine) };
310
+ const edited = await t.editIssue(mine.id, finding.title, finding.body);
311
+ return { action: "updated", ...at(mine), ...(edited?.url ? { url: edited.url } : {}) };
312
+ }
313
+
314
+ function sameIssue(open: IssueFinding, finding: IssueFinding, sameWhen: TrackIssueOptions["sameWhen"]): boolean {
315
+ if (typeof sameWhen === "function") return sameWhen(open, finding);
316
+ if (open.title !== finding.title) return false;
317
+ return typeof sameWhen === "string" && sameWhen !== "" ? open.body.includes(sameWhen) : open.body === finding.body;
318
+ }
319
+
320
+ /** The recipes every client shares, over its own {@link Threads}. */
321
+ function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "findComment" | "trackIssue" | "upsertIssue"> {
322
+ return {
323
+ async findComment(pr, marker, opts = {}) {
324
+ const c = await ownComment(t, pr, marker, opts);
325
+ return c ? { id: c.id, body: c.body, ...(c.url ? { url: c.url } : {}) } : undefined;
326
+ },
327
+ upsertComment: (pr, marker, body, opts = {}) => upsertCommentWith(t, pr, marker, body, opts, true),
328
+ upsertNote: async (pr, marker, body) => (await upsertCommentWith(t, pr, marker, body, {}, false)).url,
329
+ trackIssue: (marker, finding, opts = {}) => trackIssueWith(t, marker, finding, opts, true),
330
+ upsertIssue: async (marker, title, body) => (await trackIssueWith(t, marker, { title, body }, {}, false)).url,
331
+ };
332
+ }
115
333
 
116
334
  export interface GithubForgeOptions {
117
335
  /** `$GITHUB_API_URL`: `https://api.github.com`, `<host>/api/v3`, or Forgejo's `<host>/api/v1`. */
@@ -125,8 +343,13 @@ export interface GithubForgeOptions {
125
343
  fetch?: ForgeFetch;
126
344
  }
127
345
 
346
+ /** GitHub keeps a status description to 140 characters; Forgejo follows. */
347
+ const GITHUB_DESCRIPTION = 140;
348
+ /** GitLab keeps a status description to 255 characters. */
349
+ const GITLAB_DESCRIPTION = 255;
350
+
128
351
  /** Which of the two GitHub-shaped forges a client is talking to. */
129
- function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrForge {
352
+ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): GithubShapedForge {
130
353
  const host = (opts.host ?? "github.com").toLowerCase();
131
354
  const api = transport(
132
355
  opts.apiBase,
@@ -139,25 +362,43 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrF
139
362
  // GitHub pages with per_page (up to 100); Forgejo with limit (capped at 50 by default).
140
363
  const pageSize = kind === "github" ? 100 : 50;
141
364
  const pageParams = (page: number) => (kind === "github" ? `per_page=100&page=${page}` : `limit=50&page=${page}`);
365
+ const thread = (v: unknown): Thread | undefined => {
366
+ if (!isObject(v)) return undefined;
367
+ const id = num(v.number) ?? num(v.id);
368
+ if (id === undefined) return undefined;
369
+ const author = loginOf(v);
370
+ const url = str(v.html_url);
371
+ return { id, body: str(v.body) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
372
+ };
373
+ const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
374
+ const t: Threads = {
375
+ comments: async (pr) => threads(await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize)),
376
+ addComment: async (pr, body) => thread(await api.call("POST", `${repo}/issues/${pr}/comments`, { body })),
377
+ editComment: async (_pr, id, body) => thread(await api.call("PATCH", `${repo}/issues/comments/${id}`, { body })),
378
+ // Paged in full rather than searched: GitHub's search index lags a
379
+ // write, so a run straight after another would miss the issue it just
380
+ // opened, and Forgejo has no `/search/issues` at all. Both list pull
381
+ // requests among issues; one of those is never ours.
382
+ openIssues: async () =>
383
+ threads((await api.list(`${repo}/issues?state=open`, pageParams, pageSize)).filter((i) => isObject(i) && !isObject(i.pull_request))),
384
+ openIssue: async (title, body) => thread(await api.call("POST", `${repo}/issues`, { title, body })),
385
+ editIssue: async (n, title, body) => thread(await api.call("PATCH", `${repo}/issues/${n}`, { title, body })),
386
+ async closeIssue(n, comment) {
387
+ if (comment) await api.call("POST", `${repo}/issues/${n}/comments`, { body: comment });
388
+ await api.call("PATCH", `${repo}/issues/${n}`, { state: "closed" });
389
+ },
390
+ };
142
391
  return {
143
392
  kind,
144
393
  host,
145
394
  ...(opts.runUrl ? { runUrl: opts.runUrl } : {}),
146
395
  principalOf: (login) => forgePrincipalOf(kind, host, login),
147
- async upsertNote(pr, marker, body) {
148
- const comments = await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize);
149
- const mine = comments.find((c) => isObject(c) && typeof c.body === "string" && c.body.startsWith(marker));
150
- if (isObject(mine) && (typeof mine.id === "number" || typeof mine.id === "string")) {
151
- await api.call("PATCH", `${repo}/issues/comments/${mine.id}`, { body });
152
- } else {
153
- await api.call("POST", `${repo}/issues/${pr}/comments`, { body });
154
- }
155
- },
396
+ ...threadCalls(t),
156
397
  async setStatus(sha, status) {
157
398
  await api.call("POST", `${repo}/statuses/${sha}`, {
158
399
  state: status.state,
159
400
  context: status.context,
160
- description: status.description,
401
+ description: status.description.slice(0, GITHUB_DESCRIPTION),
161
402
  ...(status.url ? { target_url: status.url } : {}),
162
403
  });
163
404
  },
@@ -197,12 +438,12 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrF
197
438
  }
198
439
 
199
440
  /** A GitHub client (github.com or GitHub Enterprise Server). */
200
- export function githubForge(opts: GithubForgeOptions): PrForge {
441
+ export function githubForge(opts: GithubForgeOptions): GithubShapedForge {
201
442
  return githubShaped("github", opts);
202
443
  }
203
444
 
204
445
  /** A Forgejo (or Gitea) client. `host` is required: Forgejo has no default host. */
205
- export function forgejoForge(opts: GithubForgeOptions & { host: string }): PrForge {
446
+ export function forgejoForge(opts: GithubForgeOptions & { host: string }): GithubShapedForge {
206
447
  return githubShaped("forgejo", opts);
207
448
  }
208
449
 
@@ -212,6 +453,12 @@ export interface GitlabForgeOptions {
212
453
  /** `$CI_PROJECT_ID`, or the URL-encoded path. */
213
454
  project: string;
214
455
  token: string;
456
+ /**
457
+ * The header the token goes in: `PRIVATE-TOKEN` (the default) for an
458
+ * access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. One in the other's header
459
+ * is a 401.
460
+ */
461
+ tokenHeader?: "PRIVATE-TOKEN" | "JOB-TOKEN";
215
462
  /** Default gitlab.com. */
216
463
  host?: string;
217
464
  runUrl?: string;
@@ -220,33 +467,78 @@ export interface GitlabForgeOptions {
220
467
 
221
468
  const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending", success: "success", failure: "failed" };
222
469
 
223
- /** A GitLab client. The token needs the `api` scope: a job token cannot write merge-request notes. */
470
+ /**
471
+ * A GitLab client. Writing notes needs an access token with the `api` scope
472
+ * on most instances: the job token reaches only the endpoints the instance's
473
+ * job-token allowlist names.
474
+ */
224
475
  export function gitlabForge(opts: GitlabForgeOptions): PrForge {
225
476
  const host = (opts.host ?? "gitlab.com").toLowerCase();
226
- const api = transport(opts.apiBase, { "PRIVATE-TOKEN": opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
477
+ const api = transport(opts.apiBase, { [opts.tokenHeader ?? "PRIVATE-TOKEN"]: opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
227
478
  const project = `/projects/${encodeURIComponent(decodeURIComponent(opts.project))}`;
228
479
  const pageParams = (page: number) => `per_page=100&page=${page}`;
480
+ const authorOf = (v: Record<string, unknown>): string | undefined => (isObject(v.author) ? str(v.author.username) : undefined);
481
+ const note = (v: unknown): Thread | undefined => {
482
+ if (!isObject(v)) return undefined;
483
+ const id = num(v.id);
484
+ if (id === undefined) return undefined;
485
+ const author = authorOf(v);
486
+ return { id, body: str(v.body) ?? "", ...(author ? { author } : {}) };
487
+ };
488
+ const issue = (v: unknown): Thread | undefined => {
489
+ if (!isObject(v)) return undefined;
490
+ const id = num(v.iid);
491
+ if (id === undefined) return undefined;
492
+ const author = authorOf(v);
493
+ const url = str(v.web_url);
494
+ return { id, body: str(v.description) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
495
+ };
496
+ const notes = `${project}/merge_requests`;
497
+ const t: Threads = {
498
+ comments: async (mr) =>
499
+ (await api.list(`${notes}/${mr}/notes`, pageParams, 100))
500
+ // System notes ("added 1 commit") are the forge's own, never ours.
501
+ .filter((n) => !(isObject(n) && n.system === true))
502
+ .map(note)
503
+ .filter((n): n is Thread => n !== undefined),
504
+ addComment: async (mr, body) => note(await api.call("POST", `${notes}/${mr}/notes`, { body })),
505
+ editComment: async (mr, id, body) => note(await api.call("PUT", `${notes}/${mr}/notes/${id}`, { body })),
506
+ // `search` narrows the list server-side to issues whose description
507
+ // carries the marker, as `reconcilePr`'s GitLab issue does (#2292); the
508
+ // match itself is still decided here.
509
+ openIssues: async (marker) =>
510
+ (await api.list(`${project}/issues?state=opened&search=${encodeURIComponent(marker)}&in=description`, pageParams, 100))
511
+ .map(issue)
512
+ .filter((i): i is Thread => i !== undefined),
513
+ openIssue: async (title, body) => issue(await api.call("POST", `${project}/issues`, { title, description: body })),
514
+ editIssue: async (n, title, body) => issue(await api.call("PUT", `${project}/issues/${n}`, { title, description: body })),
515
+ async closeIssue(n, comment) {
516
+ if (comment) await api.call("POST", `${project}/issues/${n}/notes`, { body: comment });
517
+ await api.call("PUT", `${project}/issues/${n}`, { state_event: "close" });
518
+ },
519
+ };
229
520
  return {
230
521
  kind: "gitlab",
231
522
  host,
232
523
  ...(opts.runUrl ? { runUrl: opts.runUrl } : {}),
233
524
  principalOf: (login) => forgePrincipalOf("gitlab", host, login),
234
- async upsertNote(mr, marker, body) {
235
- const notes = await api.list(`${project}/merge_requests/${mr}/notes`, pageParams, 100);
236
- const mine = notes.find((n) => isObject(n) && typeof n.body === "string" && n.body.startsWith(marker));
237
- if (isObject(mine) && typeof mine.id === "number") {
238
- await api.call("PUT", `${project}/merge_requests/${mr}/notes/${mine.id}`, { body });
239
- } else {
240
- await api.call("POST", `${project}/merge_requests/${mr}/notes`, { body });
241
- }
242
- },
525
+ ...threadCalls(t),
243
526
  async setStatus(sha, status) {
244
- await api.call("POST", `${project}/statuses/${sha}`, {
245
- state: GITLAB_STATE[status.state],
246
- name: status.context,
247
- description: status.description,
248
- ...(status.url ? { target_url: status.url } : {}),
249
- });
527
+ try {
528
+ await api.call("POST", `${project}/statuses/${sha}`, {
529
+ state: GITLAB_STATE[status.state],
530
+ name: status.context,
531
+ description: status.description.slice(0, GITLAB_DESCRIPTION),
532
+ ...(status.url ? { target_url: status.url } : {}),
533
+ ...(status.pipelineId !== undefined ? { pipeline_id: status.pipelineId } : {}),
534
+ });
535
+ } catch (err) {
536
+ // GitLab answers 400 to a status set again to the state it already
537
+ // has ("Cannot transition status via :success from :success"): the
538
+ // status is what was asked for.
539
+ if (err instanceof ForgeApiError && err.status === 400 && /cannot transition status/i.test(err.message)) return;
540
+ throw err;
541
+ }
250
542
  },
251
543
  async approvers(mr) {
252
544
  const approvals = await api.call("GET", `${project}/merge_requests/${mr}/approvals`);
@@ -268,8 +560,19 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
268
560
 
269
561
  /** Thrown when the environment lacks what a forge client needs. */
270
562
  export class ForgeEnvironmentError extends Error {
271
- constructor(kind: ForgeKind, missing: string[]) {
272
- super(`--forge ${kind} needs ${missing.join(", ")} in the environment`);
563
+ /**
564
+ * @param kind The forge, or undefined when the environment does not say which.
565
+ * @param missing What is missing, each naming the variable to set.
566
+ */
567
+ constructor(
568
+ readonly kind: ForgeKind | undefined,
569
+ readonly missing: string[],
570
+ ) {
571
+ super(
572
+ kind
573
+ ? `--forge ${kind} needs ${missing.join(", ")} in the environment`
574
+ : "the forge is not known: this is not a GitHub Actions, GitLab CI or Forgejo Actions job, so name the forge",
575
+ );
273
576
  this.name = "ForgeEnvironmentError";
274
577
  }
275
578
  }
@@ -284,10 +587,12 @@ const hostOf = (url: string | undefined): string | undefined => {
284
587
  };
285
588
 
286
589
  /**
287
- * A client for `kind` from the CI job's environment. The token is
288
- * `CHANT_FORGE_TOKEN` when set, else the forge's own: `GITHUB_TOKEN` (also
289
- * set on Forgejo Actions) or `GITLAB_TOKEN`. GitLab's `CI_JOB_TOKEN` cannot
290
- * write notes, so a GitLab pipeline names a project access token.
590
+ * A client for `kind` from the CI job's environment, as the pull-request
591
+ * loop reads it. The token is `CHANT_FORGE_TOKEN` when set, else the forge's
592
+ * own: `GITHUB_TOKEN` (also set on Forgejo Actions) or `GITLAB_TOKEN`.
593
+ * GitLab's `CI_JOB_TOKEN` cannot write notes, so a GitLab pipeline names a
594
+ * project access token. {@link forgeTargetFromEnv} is the general reading,
595
+ * with the finding modes' token order.
291
596
  */
292
597
  export function forgeFromEnv(kind: ForgeKind, env: Record<string, string | undefined> = process.env, fetchImpl?: ForgeFetch): PrForge {
293
598
  const f = fetchImpl ? { fetch: fetchImpl } : {};
@@ -330,3 +635,197 @@ export function forgeFromEnv(kind: ForgeKind, env: Record<string, string | undef
330
635
  };
331
636
  return kind === "forgejo" ? forgejoForge({ ...opts, host: host! }) : githubForge({ ...opts, host: host ?? "github.com" });
332
637
  }
638
+
639
+ // ── Where a CI job posts (#3759) ─────────────────────────────────────────
640
+
641
+ type Env = Record<string, string | undefined>;
642
+
643
+ const truthy = (v: string | undefined): boolean => v === "true" || v === "1";
644
+
645
+ /**
646
+ * The forge a CI job runs on, from its environment, or undefined outside a
647
+ * GitHub Actions, GitLab CI or Forgejo Actions job. Forgejo is told apart by
648
+ * `FORGEJO_ACTIONS` or `GITEA_ACTIONS`, or by a GitHub-style job whose
649
+ * `GITHUB_API_URL` ends in `/api/v1`, which GitHub never serves.
650
+ */
651
+ export function detectForge(env: Env = process.env): ForgeKind | undefined {
652
+ if (truthy(env.GITLAB_CI)) return "gitlab";
653
+ if (truthy(env.FORGEJO_ACTIONS) || truthy(env.GITEA_ACTIONS)) return "forgejo";
654
+ if (truthy(env.GITHUB_ACTIONS)) return /\/api\/v1\/?$/.test(env.GITHUB_API_URL ?? "") ? "forgejo" : "github";
655
+ return undefined;
656
+ }
657
+
658
+ /** Where a CI job posts: the forge, the repository, the token, and the pull request and commit when there are ones. */
659
+ export interface ForgeTarget {
660
+ kind: ForgeKind;
661
+ /** The REST base: `https://api.github.com`, `<host>/api/v3`, `<host>/api/v1`, or `<host>/api/v4`. */
662
+ apiBase: string;
663
+ /** `owner/name` on GitHub and Forgejo; the project id or path on GitLab. */
664
+ repo: string;
665
+ /** Lower-cased. */
666
+ host: string;
667
+ token: string;
668
+ /** The variable the token came from, for messages. Never the token itself. */
669
+ tokenSource: string;
670
+ /** GitLab: `JOB-TOKEN` when the token is `CI_JOB_TOKEN`, else `PRIVATE-TOKEN`. */
671
+ tokenHeader?: "PRIVATE-TOKEN" | "JOB-TOKEN";
672
+ /** The pull request's number, or the merge request's iid. */
673
+ pr?: number;
674
+ /** The commit a status belongs on: the pull request's head when the event names it. */
675
+ sha?: string;
676
+ /** The run, to link from a status. */
677
+ runUrl?: string;
678
+ }
679
+
680
+ export interface ForgeTargetOptions {
681
+ /** The forge, in place of {@link detectForge}. */
682
+ kind?: ForgeKind;
683
+ /** Read the token from this variable only, in place of the default order. */
684
+ tokenEnv?: string;
685
+ /** Refuse an environment with no pull or merge request. Default false. */
686
+ requirePr?: boolean;
687
+ /** Reads the event payload `GITHUB_EVENT_PATH` names. Default `node:fs`. */
688
+ readFile?: (path: string) => string;
689
+ }
690
+
691
+ /**
692
+ * The token order {@link forgeTargetFromEnv} reads, as guide/ops documents
693
+ * it: a chant-specific variable first, for posting to an instance other than
694
+ * the one the job runs on, then what the job already carries (#3802). A
695
+ * Forgejo job reads `FORGEJO_TOKEN` before the `GITHUB_*` names, and the
696
+ * GitHub row has its own chant variable rather than the Forgejo one.
697
+ */
698
+ export const FORGE_TOKEN_ORDER: Record<ForgeKind, readonly string[]> = {
699
+ github: ["CHANT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
700
+ forgejo: ["CHANT_FORGEJO_TOKEN", "FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
701
+ gitlab: ["CHANT_GITLAB_TOKEN", "GITLAB_TOKEN", "CI_JOB_TOKEN"],
702
+ };
703
+
704
+ /** The pull request and its head from a GitHub-style event payload, or from the ref. */
705
+ function prFromEvent(payload: unknown, ref: string | undefined): { pr?: number; sha?: string } {
706
+ const out: { pr?: number; sha?: string } = {};
707
+ if (isObject(payload)) {
708
+ const pr = isObject(payload.pull_request) ? payload.pull_request : undefined;
709
+ const issue = isObject(payload.issue) ? payload.issue : undefined;
710
+ const n = num(pr?.number) ?? (issue && isObject(issue.pull_request) ? num(issue.number) : undefined) ?? (pr ? num(payload.number) : undefined);
711
+ if (n !== undefined && n > 0) out.pr = n;
712
+ const head = pr && isObject(pr.head) ? str(pr.head.sha) : undefined;
713
+ if (head) out.sha = head;
714
+ }
715
+ if (out.pr === undefined) {
716
+ const m = /^refs\/pull\/(\d+)\/(?:merge|head)$/.exec(ref ?? "");
717
+ if (m) out.pr = Number(m[1]);
718
+ }
719
+ return out;
720
+ }
721
+
722
+ /**
723
+ * Where a CI job posts, read from its environment: the forge
724
+ * ({@link detectForge} unless `kind` is given), the repository, the REST
725
+ * base, the host, the token, and the pull request and its head commit when
726
+ * the run has them. Throws {@link ForgeEnvironmentError} naming every
727
+ * missing piece.
728
+ *
729
+ * - GitHub Actions: `GITHUB_REPOSITORY`, `GITHUB_API_URL` (default
730
+ * `https://api.github.com`), `GITHUB_SERVER_URL`; the pull request from
731
+ * the event payload at `GITHUB_EVENT_PATH` or `refs/pull/<n>/` in
732
+ * `GITHUB_REF`, its head from the payload, else `GITHUB_SHA`.
733
+ * - Forgejo Actions: the same, with `FORGEJO_*` read before `GITHUB_*`,
734
+ * and the REST base `<server>/api/v1` when no API URL is set.
735
+ * - GitLab CI: `CI_API_V4_URL` (or `CI_SERVER_URL/api/v4`), the merge
736
+ * request's project before the pipeline's (a fork's merge request runs in
737
+ * the fork), `CI_MERGE_REQUEST_IID`, `CI_COMMIT_SHA`, `CI_PIPELINE_URL`.
738
+ *
739
+ * The token is read in {@link FORGE_TOKEN_ORDER}, or from `tokenEnv` alone.
740
+ */
741
+ export function forgeTargetFromEnv(env: Env = process.env, opts: ForgeTargetOptions = {}): ForgeTarget {
742
+ const kind = opts.kind ?? detectForge(env);
743
+ if (!kind) throw new ForgeEnvironmentError(undefined, []);
744
+ const pick = (...names: string[]): string | undefined => names.map((n) => env[n]?.trim()).find((v) => v !== undefined && v !== "");
745
+ const names = opts.tokenEnv ? [opts.tokenEnv] : FORGE_TOKEN_ORDER[kind];
746
+ const tokenSource = names.find((n) => env[n]?.trim());
747
+ const token = tokenSource ? env[tokenSource]!.trim() : undefined;
748
+ const missing: string[] = [];
749
+ const tokenMissing = `a token (${names.join(", ")})`;
750
+
751
+ if (kind === "gitlab") {
752
+ const server = pick("CI_SERVER_URL")?.replace(/\/+$/, "");
753
+ const apiBase = pick("CI_API_V4_URL")?.replace(/\/+$/, "") ?? (server ? `${server}/api/v4` : undefined);
754
+ const repo = pick("CI_MERGE_REQUEST_PROJECT_ID", "CI_PROJECT_ID", "CI_MERGE_REQUEST_PROJECT_PATH", "CI_PROJECT_PATH");
755
+ const iid = num(pick("CI_MERGE_REQUEST_IID"));
756
+ if (!apiBase) missing.push("CI_API_V4_URL");
757
+ if (!repo) missing.push("CI_PROJECT_ID");
758
+ if (opts.requirePr && !iid) missing.push("CI_MERGE_REQUEST_IID (a merge request pipeline)");
759
+ if (!token) missing.push(tokenMissing);
760
+ if (missing.length > 0) throw new ForgeEnvironmentError(kind, missing);
761
+ const sha = pick("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "CI_COMMIT_SHA");
762
+ const runUrl = pick("CI_PIPELINE_URL");
763
+ return {
764
+ kind,
765
+ apiBase: apiBase!,
766
+ repo: repo!,
767
+ host: pick("CI_SERVER_HOST")?.toLowerCase() ?? hostOf(server) ?? hostOf(apiBase) ?? "gitlab.com",
768
+ token: token!,
769
+ tokenSource: tokenSource!,
770
+ tokenHeader: tokenSource === "CI_JOB_TOKEN" ? "JOB-TOKEN" : "PRIVATE-TOKEN",
771
+ ...(iid ? { pr: iid } : {}),
772
+ ...(sha ? { sha } : {}),
773
+ ...(runUrl ? { runUrl } : {}),
774
+ };
775
+ }
776
+
777
+ const fj = kind === "forgejo";
778
+ const read = (name: string): string | undefined => (fj ? pick(`FORGEJO_${name}`, `GITHUB_${name}`) : pick(`GITHUB_${name}`));
779
+ const repo = read("REPOSITORY");
780
+ const server = read("SERVER_URL")?.replace(/\/+$/, "");
781
+ const apiBase = read("API_URL")?.replace(/\/+$/, "") ?? (fj ? (server ? `${server}/api/v1` : undefined) : "https://api.github.com");
782
+ let event: unknown;
783
+ const eventPath = read("EVENT_PATH");
784
+ if (eventPath) {
785
+ try {
786
+ event = JSON.parse((opts.readFile ?? ((p: string) => readFileSync(p, "utf8")))(eventPath));
787
+ } catch {
788
+ // An unreadable payload is not fatal: the ref may still name the pull request.
789
+ event = undefined;
790
+ }
791
+ }
792
+ const { pr, sha: head } = prFromEvent(event, read("REF"));
793
+ if (!repo) missing.push(fj ? "FORGEJO_REPOSITORY or GITHUB_REPOSITORY" : "GITHUB_REPOSITORY");
794
+ if (!apiBase) missing.push("GITHUB_SERVER_URL or GITHUB_API_URL");
795
+ if (opts.requirePr && !pr) missing.push("a pull request (a pull_request event, or refs/pull/<n>/ in GITHUB_REF)");
796
+ if (!token) missing.push(tokenMissing);
797
+ if (missing.length > 0) throw new ForgeEnvironmentError(kind, missing);
798
+ const sha = head ?? read("SHA");
799
+ const runId = read("RUN_ID");
800
+ const runUrl = server && runId ? `${server}/${repo}/actions/runs/${runId}` : undefined;
801
+ return {
802
+ kind,
803
+ apiBase: apiBase!,
804
+ repo: repo!,
805
+ host: hostOf(server) ?? (fj ? hostOf(apiBase)! : "github.com"),
806
+ token: token!,
807
+ tokenSource: tokenSource!,
808
+ ...(pr ? { pr } : {}),
809
+ ...(sha ? { sha } : {}),
810
+ ...(runUrl ? { runUrl } : {}),
811
+ };
812
+ }
813
+
814
+ /** The client for a {@link ForgeTarget}, from {@link forgeTargetFromEnv} or built by hand. */
815
+ export function forgeClient(target: ForgeTarget, fetchImpl?: ForgeFetch): PrForge {
816
+ const f = fetchImpl ? { fetch: fetchImpl } : {};
817
+ const run = target.runUrl ? { runUrl: target.runUrl } : {};
818
+ if (target.kind === "gitlab") {
819
+ return gitlabForge({
820
+ apiBase: target.apiBase,
821
+ project: target.repo,
822
+ token: target.token,
823
+ host: target.host,
824
+ ...(target.tokenHeader ? { tokenHeader: target.tokenHeader } : {}),
825
+ ...run,
826
+ ...f,
827
+ });
828
+ }
829
+ const opts = { apiBase: target.apiBase, repo: target.repo, token: target.token, host: target.host, ...run, ...f };
830
+ return target.kind === "forgejo" ? forgejoForge(opts) : githubForge(opts);
831
+ }