@intentius/chant 0.121.1 → 0.123.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 (161) hide show
  1. package/dist/cli/commands/build.d.ts +12 -0
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts +16 -0
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  6. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  7. package/dist/cli/handlers/operator.d.ts +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/override-approve.d.ts +17 -0
  10. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  11. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +4 -0
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/gate-decision.d.ts +157 -0
  16. package/dist/gate-decision.d.ts.map +1 -0
  17. package/dist/lifecycle/gate-applied.d.ts +75 -0
  18. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  19. package/dist/lifecycle/gate-audit.d.ts +145 -0
  20. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  22. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  23. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/lifecycle/override-ledger.d.ts +34 -0
  26. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  27. package/dist/lifecycle/rollback.d.ts.map +1 -1
  28. package/dist/lifecycle/work-item-id.d.ts +22 -0
  29. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  30. package/dist/lifecycle/work-lease.d.ts +2 -2
  31. package/dist/lifecycle/work-lease.d.ts.map +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.d.ts +202 -0
  37. package/dist/notify.d.ts.map +1 -0
  38. package/dist/op/activities/activity-contracts.d.ts +4 -4
  39. package/dist/op/activities/gh-host.d.ts +37 -0
  40. package/dist/op/activities/gh-host.d.ts.map +1 -0
  41. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  42. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  43. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  44. package/dist/op/activities/reconcile.d.ts +74 -76
  45. package/dist/op/activities/reconcile.d.ts.map +1 -1
  46. package/dist/op/gate-approval.d.ts +17 -1
  47. package/dist/op/gate-approval.d.ts.map +1 -1
  48. package/dist/op/gate-name.d.ts +8 -1
  49. package/dist/op/gate-name.d.ts.map +1 -1
  50. package/dist/op/gate-resume.d.ts +4 -2
  51. package/dist/op/gate-resume.d.ts.map +1 -1
  52. package/dist/op/gate-review.d.ts +2 -1
  53. package/dist/op/gate-review.d.ts.map +1 -1
  54. package/dist/op/gate.d.ts +43 -6
  55. package/dist/op/gate.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +8 -6
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/op/op-waves-notify.d.ts +31 -0
  59. package/dist/op/op-waves-notify.d.ts.map +1 -0
  60. package/dist/op/op-waves-run.d.ts +91 -16
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves.d.ts +99 -2
  63. package/dist/op/op-waves.d.ts.map +1 -1
  64. package/dist/op/operator.d.ts +15 -0
  65. package/dist/op/operator.d.ts.map +1 -1
  66. package/dist/op/work-lease-decl.d.ts.map +1 -1
  67. package/dist/op/work-lease-run.d.ts.map +1 -1
  68. package/dist/override-gate.d.ts +224 -0
  69. package/dist/override-gate.d.ts.map +1 -0
  70. package/dist/pr-forge.d.ts +198 -19
  71. package/dist/pr-forge.d.ts.map +1 -1
  72. package/dist/workspace/status-gates.d.ts +1 -0
  73. package/dist/workspace/status-gates.d.ts.map +1 -1
  74. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  75. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  76. package/dist/workspace/trust/policy.d.ts +2 -41
  77. package/dist/workspace/trust/policy.d.ts.map +1 -1
  78. package/dist/workspace/trust/seal.d.ts +2 -27
  79. package/dist/workspace/trust/seal.d.ts.map +1 -1
  80. package/dist/workspace/trust/trust-files.d.ts +57 -0
  81. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  82. package/dist/workspace/work-cli.d.ts.map +1 -1
  83. package/package.json +2 -1
  84. package/src/cli/commands/build.ts +37 -18
  85. package/src/cli/commands/carve-emit-state.test.ts +8 -2
  86. package/src/cli/commands/lint.test.ts +109 -1
  87. package/src/cli/commands/lint.ts +165 -1
  88. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  89. package/src/cli/handlers/gate-resume.ts +3 -2
  90. package/src/cli/handlers/operator.test.ts +12 -1
  91. package/src/cli/handlers/operator.ts +25 -13
  92. package/src/cli/handlers/override-approve.test.ts +87 -0
  93. package/src/cli/handlers/override-approve.ts +103 -0
  94. package/src/cli/handlers/run-wave.ts +10 -2
  95. package/src/cli/main.test.ts +8 -0
  96. package/src/cli/main.ts +15 -0
  97. package/src/cli/registry.ts +4 -0
  98. package/src/gate-decision-bundle.test.ts +56 -0
  99. package/src/gate-decision.test.ts +237 -0
  100. package/src/gate-decision.ts +321 -0
  101. package/src/lifecycle/gate-applied.ts +115 -0
  102. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  103. package/src/lifecycle/gate-audit.test.ts +134 -0
  104. package/src/lifecycle/gate-audit.ts +438 -0
  105. package/src/lifecycle/gate-ledger-select.ts +113 -0
  106. package/src/lifecycle/gate-ledger.test.ts +22 -0
  107. package/src/lifecycle/gate-ledger.ts +9 -104
  108. package/src/lifecycle/override-ledger.test.ts +63 -0
  109. package/src/lifecycle/override-ledger.ts +69 -0
  110. package/src/lifecycle/rollback.ts +5 -1
  111. package/src/lifecycle/work-item-id.test.ts +71 -0
  112. package/src/lifecycle/work-item-id.ts +35 -0
  113. package/src/lifecycle/work-lease.ts +4 -5
  114. package/src/lint/post-synth.test.ts +29 -0
  115. package/src/lint/post-synth.ts +21 -2
  116. package/src/notify-bundle.test.ts +50 -0
  117. package/src/notify-verify.test.ts +59 -0
  118. package/src/notify-verify.ts +93 -0
  119. package/src/notify.schema.json +72 -0
  120. package/src/notify.test.ts +279 -0
  121. package/src/notify.ts +398 -0
  122. package/src/op/activities/gh-host.test.ts +130 -0
  123. package/src/op/activities/gh-host.ts +79 -0
  124. package/src/op/activities/lexicon-upgrade.ts +22 -1
  125. package/src/op/activities/propose-upgrade.ts +5 -1
  126. package/src/op/activities/reconcile.test.ts +284 -280
  127. package/src/op/activities/reconcile.ts +128 -131
  128. package/src/op/gate-approval.test.ts +175 -7
  129. package/src/op/gate-approval.ts +27 -1
  130. package/src/op/gate-name.ts +10 -2
  131. package/src/op/gate-resume.ts +6 -3
  132. package/src/op/gate-review.ts +4 -7
  133. package/src/op/gate.ts +117 -16
  134. package/src/op/index.ts +20 -5
  135. package/src/op/op-waves-notify.test.ts +127 -0
  136. package/src/op/op-waves-notify.ts +101 -0
  137. package/src/op/op-waves-run.ts +527 -104
  138. package/src/op/op-waves.test.ts +383 -3
  139. package/src/op/op-waves.ts +187 -6
  140. package/src/op/operator.test.ts +29 -0
  141. package/src/op/operator.ts +27 -2
  142. package/src/op/run-context.test.ts +2 -2
  143. package/src/op/work-lease-decl.ts +4 -4
  144. package/src/op/work-lease-run.test.ts +5 -0
  145. package/src/op/work-lease-run.ts +4 -3
  146. package/src/override-gate-bundle.test.ts +47 -0
  147. package/src/override-gate.test.ts +239 -0
  148. package/src/override-gate.ts +395 -0
  149. package/src/pr-forge-bundle.test.ts +40 -0
  150. package/src/pr-forge.test.ts +303 -0
  151. package/src/pr-forge.ts +502 -47
  152. package/src/workspace/records-write-contract.test.ts +3 -2
  153. package/src/workspace/status-gates.ts +7 -4
  154. package/src/workspace/trust/gate-seal.test.ts +156 -0
  155. package/src/workspace/trust/gate-seal.ts +254 -0
  156. package/src/workspace/trust/policy.ts +10 -165
  157. package/src/workspace/trust/seal.ts +19 -34
  158. package/src/workspace/trust/trust-files.test.ts +50 -0
  159. package/src/workspace/trust/trust-files.ts +245 -0
  160. package/src/workspace/work-cli.ts +5 -3
  161. package/src/workspace/work-history.test.ts +7 -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,72 @@ 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 finding to keep in a tracking issue. `body` carries the marker. */
80
+ export interface IssueFinding {
81
+ title: string;
82
+ body: string;
83
+ }
84
+
85
+ export interface TrackIssueOptions extends MarkerMatch {
86
+ /**
87
+ * When the finding has cleared (`null`), close the open issue, after
88
+ * {@link clearComment} when one is given. Off by default: `reconcilePr`'s
89
+ * `issue` mode never closes an issue, and a closed one is never reopened,
90
+ * so the next finding opens a fresh issue.
91
+ */
92
+ closeOnClear?: boolean;
93
+ /** Posted on the issue before it is closed. */
94
+ clearComment?: string;
95
+ }
96
+
97
+ /** What {@link PrForge.trackIssue} did. */
98
+ export interface IssueResult {
99
+ /**
100
+ * `opened`, `updated`, or `unchanged` (title and body already current) for
101
+ * a finding; `closed`, `left` (open, `closeOnClear` off) or `none` (no open
102
+ * issue) for a cleared one.
103
+ */
104
+ action: "opened" | "updated" | "unchanged" | "closed" | "left" | "none";
105
+ /** The issue's number (GitHub, Forgejo) or iid (GitLab). */
106
+ number?: number;
107
+ url?: string;
37
108
  }
38
109
 
39
110
  export interface PrForge {
@@ -44,15 +115,43 @@ export interface PrForge {
44
115
  runUrl?: string;
45
116
  /** How #3163 names `login` on this forge. */
46
117
  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>;
118
+ /**
119
+ * Keep one comment on pull request `pr` (one note on merge request `pr`):
120
+ * the one {@link MarkerMatch} finds is updated, unless its body is already
121
+ * `body`, or one is added. `body` must carry the marker the same way, or
122
+ * the next run could not find it; that is refused before any call. Every
123
+ * page of comments is read, and GitLab's system notes are skipped. When
124
+ * several match, the oldest is kept.
125
+ */
126
+ upsertComment(pr: number, marker: string, body: string, opts?: MarkerMatch): Promise<CommentResult>;
127
+ /**
128
+ * {@link upsertComment} by prefix, without the body check, resolving to the
129
+ * comment's web URL. The pull-request loop's form.
130
+ */
131
+ upsertNote(pr: number, marker: string, body: string): Promise<string | undefined>;
49
132
  setStatus(sha: string, status: ForgeStatus): Promise<void>;
133
+ /**
134
+ * Keep one OPEN tracking issue for `marker` in line with `finding`: open
135
+ * it, update its title and body, or, when `finding` is `null` and
136
+ * `closeOnClear` is set, close it with a comment. Only open issues are
137
+ * read, so an issue closed by hand stays closed. Pull requests are never
138
+ * taken for issues. When several match, the oldest is kept.
139
+ */
140
+ trackIssue(marker: string, finding: IssueFinding | null, opts?: TrackIssueOptions): Promise<IssueResult>;
141
+ /**
142
+ * {@link trackIssue} by prefix for a finding, without the body check,
143
+ * resolving to the issue's web URL. `reconcilePr`'s `issue` mode's form.
144
+ */
145
+ upsertIssue(marker: string, title: string, body: string): Promise<string | undefined>;
50
146
  /** Logins whose standing review approves `pr`. */
51
147
  approvers(pr: number): Promise<string[]>;
52
148
  /** The merged pull request that produced `sha`, or null. */
53
149
  pullRequestFor(sha: string): Promise<number | null>;
54
150
  }
55
151
 
152
+ /** A GitHub or Forgejo client. */
153
+ export type GithubShapedForge = PrForge & { kind: "github" | "forgejo" };
154
+
56
155
  /** Thrown when a forge answers a call with an error status. */
57
156
  export class ForgeApiError extends Error {
58
157
  constructor(
@@ -112,6 +211,83 @@ function transport(base: string, headers: Record<string, string>, fetchImpl: For
112
211
 
113
212
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
114
213
  const loginOf = (v: unknown): string | undefined => (isObject(v) && isObject(v.user) && typeof v.user.login === "string" ? v.user.login : undefined);
214
+ const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
215
+ const num = (v: unknown): number | undefined => (typeof v === "number" ? v : typeof v === "string" && /^\d+$/.test(v) ? Number(v) : undefined);
216
+
217
+ /** A comment, note or issue as the shared recipes read it. `id` is an issue's number or iid. */
218
+ interface Thread {
219
+ id: number;
220
+ body: string;
221
+ title?: string;
222
+ author?: string;
223
+ url?: string;
224
+ }
225
+
226
+ /** The calls each forge makes for the shared comment and issue recipes. */
227
+ interface Threads {
228
+ comments(pr: number): Promise<Thread[]>;
229
+ addComment(pr: number, body: string): Promise<Thread | undefined>;
230
+ editComment(pr: number, id: number, body: string): Promise<Thread | undefined>;
231
+ /** Open issues only, pull requests left out. */
232
+ openIssues(marker: string): Promise<Thread[]>;
233
+ openIssue(title: string, body: string): Promise<Thread | undefined>;
234
+ editIssue(n: number, title: string, body: string): Promise<Thread | undefined>;
235
+ closeIssue(n: number, comment: string | undefined): Promise<void>;
236
+ }
237
+
238
+ const carries = (body: string, marker: string, match: MarkerMatch["match"]): boolean =>
239
+ match === "anywhere" ? body.includes(marker) : body.startsWith(marker);
240
+
241
+ const ownedBy = (marker: string, opts: MarkerMatch) => (t: Thread): boolean =>
242
+ carries(t.body, marker, opts.match) && (!opts.author || t.author?.toLowerCase() === opts.author.toLowerCase());
243
+
244
+ function checkMarker(what: string, marker: string, body: string, match: MarkerMatch["match"]): void {
245
+ if (!marker) throw new Error(`the ${what} needs a marker to be found again`);
246
+ if (!carries(body, marker, match)) {
247
+ throw new Error(
248
+ `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`,
249
+ );
250
+ }
251
+ }
252
+
253
+ async function upsertCommentWith(t: Threads, pr: number, marker: string, body: string, opts: MarkerMatch, check: boolean): Promise<CommentResult> {
254
+ if (check) checkMarker("comment", marker, body, opts.match);
255
+ const mine = (await t.comments(pr)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
256
+ if (!mine) {
257
+ const made = await t.addComment(pr, body);
258
+ return { action: "created", id: made?.id ?? 0, ...(made?.url ? { url: made.url } : {}) };
259
+ }
260
+ if (mine.body === body) return { action: "unchanged", id: mine.id, ...(mine.url ? { url: mine.url } : {}) };
261
+ const edited = await t.editComment(pr, mine.id, body);
262
+ const url = edited?.url ?? mine.url;
263
+ return { action: "updated", id: mine.id, ...(url ? { url } : {}) };
264
+ }
265
+
266
+ async function trackIssueWith(t: Threads, marker: string, finding: IssueFinding | null, opts: TrackIssueOptions, check: boolean): Promise<IssueResult> {
267
+ if (finding && check) checkMarker("issue", marker, finding.body, opts.match);
268
+ const mine = (await t.openIssues(marker)).filter(ownedBy(marker, opts)).sort((a, b) => a.id - b.id)[0];
269
+ const at = (i: Thread | undefined): Omit<IssueResult, "action"> => (i ? { number: i.id, ...(i.url ? { url: i.url } : {}) } : {});
270
+ if (finding === null) {
271
+ if (!mine) return { action: "none" };
272
+ if (!opts.closeOnClear) return { action: "left", ...at(mine) };
273
+ await t.closeIssue(mine.id, opts.clearComment);
274
+ return { action: "closed", ...at(mine) };
275
+ }
276
+ if (!mine) return { action: "opened", ...at(await t.openIssue(finding.title, finding.body)) };
277
+ if (mine.body === finding.body && mine.title === finding.title) return { action: "unchanged", ...at(mine) };
278
+ const edited = await t.editIssue(mine.id, finding.title, finding.body);
279
+ return { action: "updated", ...at(mine), ...(edited?.url ? { url: edited.url } : {}) };
280
+ }
281
+
282
+ /** The recipes every client shares, over its own {@link Threads}. */
283
+ function threadCalls(t: Threads): Pick<PrForge, "upsertComment" | "upsertNote" | "trackIssue" | "upsertIssue"> {
284
+ return {
285
+ upsertComment: (pr, marker, body, opts = {}) => upsertCommentWith(t, pr, marker, body, opts, true),
286
+ upsertNote: async (pr, marker, body) => (await upsertCommentWith(t, pr, marker, body, {}, false)).url,
287
+ trackIssue: (marker, finding, opts = {}) => trackIssueWith(t, marker, finding, opts, true),
288
+ upsertIssue: async (marker, title, body) => (await trackIssueWith(t, marker, { title, body }, {}, false)).url,
289
+ };
290
+ }
115
291
 
116
292
  export interface GithubForgeOptions {
117
293
  /** `$GITHUB_API_URL`: `https://api.github.com`, `<host>/api/v3`, or Forgejo's `<host>/api/v1`. */
@@ -125,8 +301,13 @@ export interface GithubForgeOptions {
125
301
  fetch?: ForgeFetch;
126
302
  }
127
303
 
304
+ /** GitHub keeps a status description to 140 characters; Forgejo follows. */
305
+ const GITHUB_DESCRIPTION = 140;
306
+ /** GitLab keeps a status description to 255 characters. */
307
+ const GITLAB_DESCRIPTION = 255;
308
+
128
309
  /** Which of the two GitHub-shaped forges a client is talking to. */
129
- function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrForge {
310
+ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): GithubShapedForge {
130
311
  const host = (opts.host ?? "github.com").toLowerCase();
131
312
  const api = transport(
132
313
  opts.apiBase,
@@ -139,25 +320,43 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrF
139
320
  // GitHub pages with per_page (up to 100); Forgejo with limit (capped at 50 by default).
140
321
  const pageSize = kind === "github" ? 100 : 50;
141
322
  const pageParams = (page: number) => (kind === "github" ? `per_page=100&page=${page}` : `limit=50&page=${page}`);
323
+ const thread = (v: unknown): Thread | undefined => {
324
+ if (!isObject(v)) return undefined;
325
+ const id = num(v.number) ?? num(v.id);
326
+ if (id === undefined) return undefined;
327
+ const author = loginOf(v);
328
+ const url = str(v.html_url);
329
+ return { id, body: str(v.body) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
330
+ };
331
+ const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
332
+ const t: Threads = {
333
+ comments: async (pr) => threads(await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize)),
334
+ addComment: async (pr, body) => thread(await api.call("POST", `${repo}/issues/${pr}/comments`, { body })),
335
+ editComment: async (_pr, id, body) => thread(await api.call("PATCH", `${repo}/issues/comments/${id}`, { body })),
336
+ // Paged in full rather than searched: GitHub's search index lags a
337
+ // write, so a run straight after another would miss the issue it just
338
+ // opened, and Forgejo has no `/search/issues` at all. Both list pull
339
+ // requests among issues; one of those is never ours.
340
+ openIssues: async () =>
341
+ threads((await api.list(`${repo}/issues?state=open`, pageParams, pageSize)).filter((i) => isObject(i) && !isObject(i.pull_request))),
342
+ openIssue: async (title, body) => thread(await api.call("POST", `${repo}/issues`, { title, body })),
343
+ editIssue: async (n, title, body) => thread(await api.call("PATCH", `${repo}/issues/${n}`, { title, body })),
344
+ async closeIssue(n, comment) {
345
+ if (comment) await api.call("POST", `${repo}/issues/${n}/comments`, { body: comment });
346
+ await api.call("PATCH", `${repo}/issues/${n}`, { state: "closed" });
347
+ },
348
+ };
142
349
  return {
143
350
  kind,
144
351
  host,
145
352
  ...(opts.runUrl ? { runUrl: opts.runUrl } : {}),
146
353
  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
- },
354
+ ...threadCalls(t),
156
355
  async setStatus(sha, status) {
157
356
  await api.call("POST", `${repo}/statuses/${sha}`, {
158
357
  state: status.state,
159
358
  context: status.context,
160
- description: status.description,
359
+ description: status.description.slice(0, GITHUB_DESCRIPTION),
161
360
  ...(status.url ? { target_url: status.url } : {}),
162
361
  });
163
362
  },
@@ -197,12 +396,12 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): PrF
197
396
  }
198
397
 
199
398
  /** A GitHub client (github.com or GitHub Enterprise Server). */
200
- export function githubForge(opts: GithubForgeOptions): PrForge {
399
+ export function githubForge(opts: GithubForgeOptions): GithubShapedForge {
201
400
  return githubShaped("github", opts);
202
401
  }
203
402
 
204
403
  /** A Forgejo (or Gitea) client. `host` is required: Forgejo has no default host. */
205
- export function forgejoForge(opts: GithubForgeOptions & { host: string }): PrForge {
404
+ export function forgejoForge(opts: GithubForgeOptions & { host: string }): GithubShapedForge {
206
405
  return githubShaped("forgejo", opts);
207
406
  }
208
407
 
@@ -212,6 +411,12 @@ export interface GitlabForgeOptions {
212
411
  /** `$CI_PROJECT_ID`, or the URL-encoded path. */
213
412
  project: string;
214
413
  token: string;
414
+ /**
415
+ * The header the token goes in: `PRIVATE-TOKEN` (the default) for an
416
+ * access token, `JOB-TOKEN` for `CI_JOB_TOKEN`. One in the other's header
417
+ * is a 401.
418
+ */
419
+ tokenHeader?: "PRIVATE-TOKEN" | "JOB-TOKEN";
215
420
  /** Default gitlab.com. */
216
421
  host?: string;
217
422
  runUrl?: string;
@@ -220,33 +425,78 @@ export interface GitlabForgeOptions {
220
425
 
221
426
  const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending", success: "success", failure: "failed" };
222
427
 
223
- /** A GitLab client. The token needs the `api` scope: a job token cannot write merge-request notes. */
428
+ /**
429
+ * A GitLab client. Writing notes needs an access token with the `api` scope
430
+ * on most instances: the job token reaches only the endpoints the instance's
431
+ * job-token allowlist names.
432
+ */
224
433
  export function gitlabForge(opts: GitlabForgeOptions): PrForge {
225
434
  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));
435
+ const api = transport(opts.apiBase, { [opts.tokenHeader ?? "PRIVATE-TOKEN"]: opts.token }, opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch));
227
436
  const project = `/projects/${encodeURIComponent(decodeURIComponent(opts.project))}`;
228
437
  const pageParams = (page: number) => `per_page=100&page=${page}`;
438
+ const authorOf = (v: Record<string, unknown>): string | undefined => (isObject(v.author) ? str(v.author.username) : undefined);
439
+ const note = (v: unknown): Thread | undefined => {
440
+ if (!isObject(v)) return undefined;
441
+ const id = num(v.id);
442
+ if (id === undefined) return undefined;
443
+ const author = authorOf(v);
444
+ return { id, body: str(v.body) ?? "", ...(author ? { author } : {}) };
445
+ };
446
+ const issue = (v: unknown): Thread | undefined => {
447
+ if (!isObject(v)) return undefined;
448
+ const id = num(v.iid);
449
+ if (id === undefined) return undefined;
450
+ const author = authorOf(v);
451
+ const url = str(v.web_url);
452
+ return { id, body: str(v.description) ?? "", ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
453
+ };
454
+ const notes = `${project}/merge_requests`;
455
+ const t: Threads = {
456
+ comments: async (mr) =>
457
+ (await api.list(`${notes}/${mr}/notes`, pageParams, 100))
458
+ // System notes ("added 1 commit") are the forge's own, never ours.
459
+ .filter((n) => !(isObject(n) && n.system === true))
460
+ .map(note)
461
+ .filter((n): n is Thread => n !== undefined),
462
+ addComment: async (mr, body) => note(await api.call("POST", `${notes}/${mr}/notes`, { body })),
463
+ editComment: async (mr, id, body) => note(await api.call("PUT", `${notes}/${mr}/notes/${id}`, { body })),
464
+ // `search` narrows the list server-side to issues whose description
465
+ // carries the marker, as `reconcilePr`'s GitLab issue does (#2292); the
466
+ // match itself is still decided here.
467
+ openIssues: async (marker) =>
468
+ (await api.list(`${project}/issues?state=opened&search=${encodeURIComponent(marker)}&in=description`, pageParams, 100))
469
+ .map(issue)
470
+ .filter((i): i is Thread => i !== undefined),
471
+ openIssue: async (title, body) => issue(await api.call("POST", `${project}/issues`, { title, description: body })),
472
+ editIssue: async (n, title, body) => issue(await api.call("PUT", `${project}/issues/${n}`, { title, description: body })),
473
+ async closeIssue(n, comment) {
474
+ if (comment) await api.call("POST", `${project}/issues/${n}/notes`, { body: comment });
475
+ await api.call("PUT", `${project}/issues/${n}`, { state_event: "close" });
476
+ },
477
+ };
229
478
  return {
230
479
  kind: "gitlab",
231
480
  host,
232
481
  ...(opts.runUrl ? { runUrl: opts.runUrl } : {}),
233
482
  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
- },
483
+ ...threadCalls(t),
243
484
  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
- });
485
+ try {
486
+ await api.call("POST", `${project}/statuses/${sha}`, {
487
+ state: GITLAB_STATE[status.state],
488
+ name: status.context,
489
+ description: status.description.slice(0, GITLAB_DESCRIPTION),
490
+ ...(status.url ? { target_url: status.url } : {}),
491
+ ...(status.pipelineId !== undefined ? { pipeline_id: status.pipelineId } : {}),
492
+ });
493
+ } catch (err) {
494
+ // GitLab answers 400 to a status set again to the state it already
495
+ // has ("Cannot transition status via :success from :success"): the
496
+ // status is what was asked for.
497
+ if (err instanceof ForgeApiError && err.status === 400 && /cannot transition status/i.test(err.message)) return;
498
+ throw err;
499
+ }
250
500
  },
251
501
  async approvers(mr) {
252
502
  const approvals = await api.call("GET", `${project}/merge_requests/${mr}/approvals`);
@@ -268,8 +518,19 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
268
518
 
269
519
  /** Thrown when the environment lacks what a forge client needs. */
270
520
  export class ForgeEnvironmentError extends Error {
271
- constructor(kind: ForgeKind, missing: string[]) {
272
- super(`--forge ${kind} needs ${missing.join(", ")} in the environment`);
521
+ /**
522
+ * @param kind The forge, or undefined when the environment does not say which.
523
+ * @param missing What is missing, each naming the variable to set.
524
+ */
525
+ constructor(
526
+ readonly kind: ForgeKind | undefined,
527
+ readonly missing: string[],
528
+ ) {
529
+ super(
530
+ kind
531
+ ? `--forge ${kind} needs ${missing.join(", ")} in the environment`
532
+ : "the forge is not known: this is not a GitHub Actions, GitLab CI or Forgejo Actions job, so name the forge",
533
+ );
273
534
  this.name = "ForgeEnvironmentError";
274
535
  }
275
536
  }
@@ -284,10 +545,12 @@ const hostOf = (url: string | undefined): string | undefined => {
284
545
  };
285
546
 
286
547
  /**
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.
548
+ * A client for `kind` from the CI job's environment, as the pull-request
549
+ * loop reads it. The token is `CHANT_FORGE_TOKEN` when set, else the forge's
550
+ * own: `GITHUB_TOKEN` (also set on Forgejo Actions) or `GITLAB_TOKEN`.
551
+ * GitLab's `CI_JOB_TOKEN` cannot write notes, so a GitLab pipeline names a
552
+ * project access token. {@link forgeTargetFromEnv} is the general reading,
553
+ * with the finding modes' token order.
291
554
  */
292
555
  export function forgeFromEnv(kind: ForgeKind, env: Record<string, string | undefined> = process.env, fetchImpl?: ForgeFetch): PrForge {
293
556
  const f = fetchImpl ? { fetch: fetchImpl } : {};
@@ -330,3 +593,195 @@ export function forgeFromEnv(kind: ForgeKind, env: Record<string, string | undef
330
593
  };
331
594
  return kind === "forgejo" ? forgejoForge({ ...opts, host: host! }) : githubForge({ ...opts, host: host ?? "github.com" });
332
595
  }
596
+
597
+ // ── Where a CI job posts (#3759) ─────────────────────────────────────────
598
+
599
+ type Env = Record<string, string | undefined>;
600
+
601
+ const truthy = (v: string | undefined): boolean => v === "true" || v === "1";
602
+
603
+ /**
604
+ * The forge a CI job runs on, from its environment, or undefined outside a
605
+ * GitHub Actions, GitLab CI or Forgejo Actions job. Forgejo is told apart by
606
+ * `FORGEJO_ACTIONS` or `GITEA_ACTIONS`, or by a GitHub-style job whose
607
+ * `GITHUB_API_URL` ends in `/api/v1`, which GitHub never serves.
608
+ */
609
+ export function detectForge(env: Env = process.env): ForgeKind | undefined {
610
+ if (truthy(env.GITLAB_CI)) return "gitlab";
611
+ if (truthy(env.FORGEJO_ACTIONS) || truthy(env.GITEA_ACTIONS)) return "forgejo";
612
+ if (truthy(env.GITHUB_ACTIONS)) return /\/api\/v1\/?$/.test(env.GITHUB_API_URL ?? "") ? "forgejo" : "github";
613
+ return undefined;
614
+ }
615
+
616
+ /** Where a CI job posts: the forge, the repository, the token, and the pull request and commit when there are ones. */
617
+ export interface ForgeTarget {
618
+ kind: ForgeKind;
619
+ /** The REST base: `https://api.github.com`, `<host>/api/v3`, `<host>/api/v1`, or `<host>/api/v4`. */
620
+ apiBase: string;
621
+ /** `owner/name` on GitHub and Forgejo; the project id or path on GitLab. */
622
+ repo: string;
623
+ /** Lower-cased. */
624
+ host: string;
625
+ token: string;
626
+ /** The variable the token came from, for messages. Never the token itself. */
627
+ tokenSource: string;
628
+ /** GitLab: `JOB-TOKEN` when the token is `CI_JOB_TOKEN`, else `PRIVATE-TOKEN`. */
629
+ tokenHeader?: "PRIVATE-TOKEN" | "JOB-TOKEN";
630
+ /** The pull request's number, or the merge request's iid. */
631
+ pr?: number;
632
+ /** The commit a status belongs on: the pull request's head when the event names it. */
633
+ sha?: string;
634
+ /** The run, to link from a status. */
635
+ runUrl?: string;
636
+ }
637
+
638
+ export interface ForgeTargetOptions {
639
+ /** The forge, in place of {@link detectForge}. */
640
+ kind?: ForgeKind;
641
+ /** Read the token from this variable only, in place of the default order. */
642
+ tokenEnv?: string;
643
+ /** Refuse an environment with no pull or merge request. Default false. */
644
+ requirePr?: boolean;
645
+ /** Reads the event payload `GITHUB_EVENT_PATH` names. Default `node:fs`. */
646
+ readFile?: (path: string) => string;
647
+ }
648
+
649
+ /**
650
+ * The token order guide/ops documents for the finding modes (#2291, #2256):
651
+ * a chant-specific variable first, for posting to an instance other than the
652
+ * one the job runs on, then what the job already carries.
653
+ */
654
+ export const FORGE_TOKEN_ORDER: Record<ForgeKind, readonly string[]> = {
655
+ github: ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
656
+ forgejo: ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
657
+ gitlab: ["CHANT_GITLAB_TOKEN", "GITLAB_TOKEN", "CI_JOB_TOKEN"],
658
+ };
659
+
660
+ /** The pull request and its head from a GitHub-style event payload, or from the ref. */
661
+ function prFromEvent(payload: unknown, ref: string | undefined): { pr?: number; sha?: string } {
662
+ const out: { pr?: number; sha?: string } = {};
663
+ if (isObject(payload)) {
664
+ const pr = isObject(payload.pull_request) ? payload.pull_request : undefined;
665
+ const issue = isObject(payload.issue) ? payload.issue : undefined;
666
+ const n = num(pr?.number) ?? (issue && isObject(issue.pull_request) ? num(issue.number) : undefined) ?? (pr ? num(payload.number) : undefined);
667
+ if (n !== undefined && n > 0) out.pr = n;
668
+ const head = pr && isObject(pr.head) ? str(pr.head.sha) : undefined;
669
+ if (head) out.sha = head;
670
+ }
671
+ if (out.pr === undefined) {
672
+ const m = /^refs\/pull\/(\d+)\/(?:merge|head)$/.exec(ref ?? "");
673
+ if (m) out.pr = Number(m[1]);
674
+ }
675
+ return out;
676
+ }
677
+
678
+ /**
679
+ * Where a CI job posts, read from its environment: the forge
680
+ * ({@link detectForge} unless `kind` is given), the repository, the REST
681
+ * base, the host, the token, and the pull request and its head commit when
682
+ * the run has them. Throws {@link ForgeEnvironmentError} naming every
683
+ * missing piece.
684
+ *
685
+ * - GitHub Actions: `GITHUB_REPOSITORY`, `GITHUB_API_URL` (default
686
+ * `https://api.github.com`), `GITHUB_SERVER_URL`; the pull request from
687
+ * the event payload at `GITHUB_EVENT_PATH` or `refs/pull/<n>/` in
688
+ * `GITHUB_REF`, its head from the payload, else `GITHUB_SHA`.
689
+ * - Forgejo Actions: the same, with `FORGEJO_*` read before `GITHUB_*`,
690
+ * and the REST base `<server>/api/v1` when no API URL is set.
691
+ * - GitLab CI: `CI_API_V4_URL` (or `CI_SERVER_URL/api/v4`), the merge
692
+ * request's project before the pipeline's (a fork's merge request runs in
693
+ * the fork), `CI_MERGE_REQUEST_IID`, `CI_COMMIT_SHA`, `CI_PIPELINE_URL`.
694
+ *
695
+ * The token is read in {@link FORGE_TOKEN_ORDER}, or from `tokenEnv` alone.
696
+ */
697
+ export function forgeTargetFromEnv(env: Env = process.env, opts: ForgeTargetOptions = {}): ForgeTarget {
698
+ const kind = opts.kind ?? detectForge(env);
699
+ if (!kind) throw new ForgeEnvironmentError(undefined, []);
700
+ const pick = (...names: string[]): string | undefined => names.map((n) => env[n]?.trim()).find((v) => v !== undefined && v !== "");
701
+ const names = opts.tokenEnv ? [opts.tokenEnv] : FORGE_TOKEN_ORDER[kind];
702
+ const tokenSource = names.find((n) => env[n]?.trim());
703
+ const token = tokenSource ? env[tokenSource]!.trim() : undefined;
704
+ const missing: string[] = [];
705
+ const tokenMissing = `a token (${names.join(", ")})`;
706
+
707
+ if (kind === "gitlab") {
708
+ const server = pick("CI_SERVER_URL")?.replace(/\/+$/, "");
709
+ const apiBase = pick("CI_API_V4_URL")?.replace(/\/+$/, "") ?? (server ? `${server}/api/v4` : undefined);
710
+ const repo = pick("CI_MERGE_REQUEST_PROJECT_ID", "CI_PROJECT_ID", "CI_MERGE_REQUEST_PROJECT_PATH", "CI_PROJECT_PATH");
711
+ const iid = num(pick("CI_MERGE_REQUEST_IID"));
712
+ if (!apiBase) missing.push("CI_API_V4_URL");
713
+ if (!repo) missing.push("CI_PROJECT_ID");
714
+ if (opts.requirePr && !iid) missing.push("CI_MERGE_REQUEST_IID (a merge request pipeline)");
715
+ if (!token) missing.push(tokenMissing);
716
+ if (missing.length > 0) throw new ForgeEnvironmentError(kind, missing);
717
+ const sha = pick("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "CI_COMMIT_SHA");
718
+ const runUrl = pick("CI_PIPELINE_URL");
719
+ return {
720
+ kind,
721
+ apiBase: apiBase!,
722
+ repo: repo!,
723
+ host: pick("CI_SERVER_HOST")?.toLowerCase() ?? hostOf(server) ?? hostOf(apiBase) ?? "gitlab.com",
724
+ token: token!,
725
+ tokenSource: tokenSource!,
726
+ tokenHeader: tokenSource === "CI_JOB_TOKEN" ? "JOB-TOKEN" : "PRIVATE-TOKEN",
727
+ ...(iid ? { pr: iid } : {}),
728
+ ...(sha ? { sha } : {}),
729
+ ...(runUrl ? { runUrl } : {}),
730
+ };
731
+ }
732
+
733
+ const fj = kind === "forgejo";
734
+ const read = (name: string): string | undefined => (fj ? pick(`FORGEJO_${name}`, `GITHUB_${name}`) : pick(`GITHUB_${name}`));
735
+ const repo = read("REPOSITORY");
736
+ const server = read("SERVER_URL")?.replace(/\/+$/, "");
737
+ const apiBase = read("API_URL")?.replace(/\/+$/, "") ?? (fj ? (server ? `${server}/api/v1` : undefined) : "https://api.github.com");
738
+ let event: unknown;
739
+ const eventPath = read("EVENT_PATH");
740
+ if (eventPath) {
741
+ try {
742
+ event = JSON.parse((opts.readFile ?? ((p: string) => readFileSync(p, "utf8")))(eventPath));
743
+ } catch {
744
+ // An unreadable payload is not fatal: the ref may still name the pull request.
745
+ event = undefined;
746
+ }
747
+ }
748
+ const { pr, sha: head } = prFromEvent(event, read("REF"));
749
+ if (!repo) missing.push(fj ? "FORGEJO_REPOSITORY or GITHUB_REPOSITORY" : "GITHUB_REPOSITORY");
750
+ if (!apiBase) missing.push("GITHUB_SERVER_URL or GITHUB_API_URL");
751
+ if (opts.requirePr && !pr) missing.push("a pull request (a pull_request event, or refs/pull/<n>/ in GITHUB_REF)");
752
+ if (!token) missing.push(tokenMissing);
753
+ if (missing.length > 0) throw new ForgeEnvironmentError(kind, missing);
754
+ const sha = head ?? read("SHA");
755
+ const runId = read("RUN_ID");
756
+ const runUrl = server && runId ? `${server}/${repo}/actions/runs/${runId}` : undefined;
757
+ return {
758
+ kind,
759
+ apiBase: apiBase!,
760
+ repo: repo!,
761
+ host: hostOf(server) ?? (fj ? hostOf(apiBase)! : "github.com"),
762
+ token: token!,
763
+ tokenSource: tokenSource!,
764
+ ...(pr ? { pr } : {}),
765
+ ...(sha ? { sha } : {}),
766
+ ...(runUrl ? { runUrl } : {}),
767
+ };
768
+ }
769
+
770
+ /** The client for a {@link ForgeTarget}, from {@link forgeTargetFromEnv} or built by hand. */
771
+ export function forgeClient(target: ForgeTarget, fetchImpl?: ForgeFetch): PrForge {
772
+ const f = fetchImpl ? { fetch: fetchImpl } : {};
773
+ const run = target.runUrl ? { runUrl: target.runUrl } : {};
774
+ if (target.kind === "gitlab") {
775
+ return gitlabForge({
776
+ apiBase: target.apiBase,
777
+ project: target.repo,
778
+ token: target.token,
779
+ host: target.host,
780
+ ...(target.tokenHeader ? { tokenHeader: target.tokenHeader } : {}),
781
+ ...run,
782
+ ...f,
783
+ });
784
+ }
785
+ const opts = { apiBase: target.apiBase, repo: target.repo, token: target.token, host: target.host, ...run, ...f };
786
+ return target.kind === "forgejo" ? forgejoForge(opts) : githubForge(opts);
787
+ }