@intentius/chant 0.122.0 → 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 (156) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/override-approve.d.ts +17 -0
  7. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  8. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +4 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/gate-decision.d.ts +157 -0
  13. package/dist/gate-decision.d.ts.map +1 -0
  14. package/dist/lifecycle/gate-applied.d.ts +75 -0
  15. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-audit.d.ts +145 -0
  17. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  19. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/override-ledger.d.ts +34 -0
  23. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  24. package/dist/lifecycle/rollback.d.ts.map +1 -1
  25. package/dist/lifecycle/work-item-id.d.ts +22 -0
  26. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  27. package/dist/lifecycle/work-lease.d.ts +2 -2
  28. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  29. package/dist/lint/post-synth.d.ts +20 -1
  30. package/dist/lint/post-synth.d.ts.map +1 -1
  31. package/dist/notify-verify.d.ts +62 -0
  32. package/dist/notify-verify.d.ts.map +1 -0
  33. package/dist/notify.d.ts +202 -0
  34. package/dist/notify.d.ts.map +1 -0
  35. package/dist/op/activities/activity-contracts.d.ts +4 -4
  36. package/dist/op/activities/gh-host.d.ts +37 -0
  37. package/dist/op/activities/gh-host.d.ts.map +1 -0
  38. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  39. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  40. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  41. package/dist/op/activities/reconcile.d.ts +74 -76
  42. package/dist/op/activities/reconcile.d.ts.map +1 -1
  43. package/dist/op/gate-approval.d.ts +17 -1
  44. package/dist/op/gate-approval.d.ts.map +1 -1
  45. package/dist/op/gate-name.d.ts +8 -1
  46. package/dist/op/gate-name.d.ts.map +1 -1
  47. package/dist/op/gate-resume.d.ts +4 -2
  48. package/dist/op/gate-resume.d.ts.map +1 -1
  49. package/dist/op/gate-review.d.ts +2 -1
  50. package/dist/op/gate-review.d.ts.map +1 -1
  51. package/dist/op/gate.d.ts +43 -6
  52. package/dist/op/gate.d.ts.map +1 -1
  53. package/dist/op/index.d.ts +8 -6
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/op-waves-notify.d.ts +31 -0
  56. package/dist/op/op-waves-notify.d.ts.map +1 -0
  57. package/dist/op/op-waves-run.d.ts +91 -16
  58. package/dist/op/op-waves-run.d.ts.map +1 -1
  59. package/dist/op/op-waves.d.ts +99 -2
  60. package/dist/op/op-waves.d.ts.map +1 -1
  61. package/dist/op/operator.d.ts +15 -0
  62. package/dist/op/operator.d.ts.map +1 -1
  63. package/dist/op/work-lease-decl.d.ts.map +1 -1
  64. package/dist/op/work-lease-run.d.ts.map +1 -1
  65. package/dist/override-gate.d.ts +224 -0
  66. package/dist/override-gate.d.ts.map +1 -0
  67. package/dist/pr-forge.d.ts +198 -19
  68. package/dist/pr-forge.d.ts.map +1 -1
  69. package/dist/workspace/status-gates.d.ts +1 -0
  70. package/dist/workspace/status-gates.d.ts.map +1 -1
  71. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  72. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  73. package/dist/workspace/trust/policy.d.ts +2 -41
  74. package/dist/workspace/trust/policy.d.ts.map +1 -1
  75. package/dist/workspace/trust/seal.d.ts +2 -27
  76. package/dist/workspace/trust/seal.d.ts.map +1 -1
  77. package/dist/workspace/trust/trust-files.d.ts +57 -0
  78. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  79. package/dist/workspace/work-cli.d.ts.map +1 -1
  80. package/package.json +2 -1
  81. package/src/cli/commands/build.ts +1 -1
  82. package/src/cli/commands/lint.ts +5 -2
  83. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  84. package/src/cli/handlers/gate-resume.ts +3 -2
  85. package/src/cli/handlers/operator.test.ts +12 -1
  86. package/src/cli/handlers/operator.ts +25 -13
  87. package/src/cli/handlers/override-approve.test.ts +87 -0
  88. package/src/cli/handlers/override-approve.ts +103 -0
  89. package/src/cli/handlers/run-wave.ts +10 -2
  90. package/src/cli/main.test.ts +8 -0
  91. package/src/cli/main.ts +15 -0
  92. package/src/cli/registry.ts +4 -0
  93. package/src/gate-decision-bundle.test.ts +56 -0
  94. package/src/gate-decision.test.ts +237 -0
  95. package/src/gate-decision.ts +321 -0
  96. package/src/lifecycle/gate-applied.ts +115 -0
  97. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  98. package/src/lifecycle/gate-audit.test.ts +134 -0
  99. package/src/lifecycle/gate-audit.ts +438 -0
  100. package/src/lifecycle/gate-ledger-select.ts +113 -0
  101. package/src/lifecycle/gate-ledger.test.ts +22 -0
  102. package/src/lifecycle/gate-ledger.ts +9 -104
  103. package/src/lifecycle/override-ledger.test.ts +63 -0
  104. package/src/lifecycle/override-ledger.ts +69 -0
  105. package/src/lifecycle/rollback.ts +5 -1
  106. package/src/lifecycle/work-item-id.test.ts +71 -0
  107. package/src/lifecycle/work-item-id.ts +35 -0
  108. package/src/lifecycle/work-lease.ts +4 -5
  109. package/src/lint/post-synth.test.ts +29 -0
  110. package/src/lint/post-synth.ts +21 -2
  111. package/src/notify-bundle.test.ts +50 -0
  112. package/src/notify-verify.test.ts +59 -0
  113. package/src/notify-verify.ts +93 -0
  114. package/src/notify.schema.json +72 -0
  115. package/src/notify.test.ts +279 -0
  116. package/src/notify.ts +398 -0
  117. package/src/op/activities/gh-host.test.ts +130 -0
  118. package/src/op/activities/gh-host.ts +79 -0
  119. package/src/op/activities/lexicon-upgrade.ts +22 -1
  120. package/src/op/activities/propose-upgrade.ts +5 -1
  121. package/src/op/activities/reconcile.test.ts +284 -280
  122. package/src/op/activities/reconcile.ts +128 -131
  123. package/src/op/gate-approval.test.ts +175 -7
  124. package/src/op/gate-approval.ts +27 -1
  125. package/src/op/gate-name.ts +10 -2
  126. package/src/op/gate-resume.ts +6 -3
  127. package/src/op/gate-review.ts +4 -7
  128. package/src/op/gate.ts +117 -16
  129. package/src/op/index.ts +20 -5
  130. package/src/op/op-waves-notify.test.ts +127 -0
  131. package/src/op/op-waves-notify.ts +101 -0
  132. package/src/op/op-waves-run.ts +527 -104
  133. package/src/op/op-waves.test.ts +383 -3
  134. package/src/op/op-waves.ts +187 -6
  135. package/src/op/operator.test.ts +29 -0
  136. package/src/op/operator.ts +27 -2
  137. package/src/op/run-context.test.ts +2 -2
  138. package/src/op/work-lease-decl.ts +4 -4
  139. package/src/op/work-lease-run.test.ts +5 -0
  140. package/src/op/work-lease-run.ts +4 -3
  141. package/src/override-gate-bundle.test.ts +47 -0
  142. package/src/override-gate.test.ts +239 -0
  143. package/src/override-gate.ts +395 -0
  144. package/src/pr-forge-bundle.test.ts +40 -0
  145. package/src/pr-forge.test.ts +303 -0
  146. package/src/pr-forge.ts +502 -47
  147. package/src/workspace/records-write-contract.test.ts +3 -2
  148. package/src/workspace/status-gates.ts +7 -4
  149. package/src/workspace/trust/gate-seal.test.ts +156 -0
  150. package/src/workspace/trust/gate-seal.ts +254 -0
  151. package/src/workspace/trust/policy.ts +10 -165
  152. package/src/workspace/trust/seal.ts +19 -34
  153. package/src/workspace/trust/trust-files.test.ts +50 -0
  154. package/src/workspace/trust/trust-files.ts +245 -0
  155. package/src/workspace/work-cli.ts +5 -3
  156. package/src/workspace/work-history.test.ts +7 -0
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The forge clients bundle small (#3759, ruling #3421).
3
+ *
4
+ * terragucci posts its plan note, its statuses and its drift issue from one
5
+ * bundled file with no TypeScript toolchain, and SQL Yodeler posts its
6
+ * comment and tracking issue the same way. `@intentius/chant/pr-forge` holds
7
+ * the comment by marker, the commit status, the tracking issue with its
8
+ * close-on-clear, and the reading of a CI job's environment, over an
9
+ * injected `fetch`. This bundles it alone, the way a product would, and reads
10
+ * esbuild's own list of what went in: one file, and nothing from a package.
11
+ */
12
+
13
+ import { join } from "node:path";
14
+ import { build } from "esbuild";
15
+ import { describe, expect, test } from "vitest";
16
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
17
+
18
+ const FORBIDDEN = [/node_modules\//, /typescript/, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/workspace\//, /\/src\/op\//];
19
+
20
+ describe("the pr-forge bundle", () => {
21
+ test("holds the forge clients and nothing else", async () => {
22
+ const result = await build({
23
+ entryPoints: [join(REPO, "packages", "core", "src", "pr-forge.ts")],
24
+ bundle: true,
25
+ platform: "node",
26
+ format: "esm",
27
+ write: false,
28
+ metafile: true,
29
+ outdir: "out",
30
+ logLevel: "silent",
31
+ });
32
+ const inputs = Object.keys(result.metafile.inputs);
33
+ for (const pattern of FORBIDDEN) {
34
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
35
+ }
36
+ expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
37
+ // About 20 KB today.
38
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(32 * 1024);
39
+ });
40
+ });
@@ -6,8 +6,12 @@
6
6
 
7
7
  import { describe, expect, test } from "vitest";
8
8
  import {
9
+ detectForge,
9
10
  ForgeApiError,
11
+ forgeClient,
12
+ ForgeEnvironmentError,
10
13
  forgeFromEnv,
14
+ forgeTargetFromEnv,
11
15
  forgejoForge,
12
16
  forgePrincipalOf,
13
17
  githubForge,
@@ -105,6 +109,45 @@ describe("github", () => {
105
109
  expect(await githubForge({ ...opts, fetch }).pullRequestFor(SHA)).toBe(9);
106
110
  });
107
111
 
112
+ test("resolves to the note's web URL", async () => {
113
+ const { fetch } = fakeFetch({
114
+ "GET /repos/acme/infra/issues/12/comments": [],
115
+ "POST /repos/acme/infra/issues/12/comments": { id: 3, html_url: "https://github.com/acme/infra/pull/12#issuecomment-3" },
116
+ });
117
+ expect(await githubForge({ ...opts, fetch }).upsertNote(12, "<!-- m -->", "<!-- m -->\nbody")).toBe(
118
+ "https://github.com/acme/infra/pull/12#issuecomment-3",
119
+ );
120
+ });
121
+
122
+ test("updates the open issue that starts with the marker, title and body, skipping pull requests (#3757)", async () => {
123
+ const { fetch, calls } = fakeFetch({
124
+ "GET /repos/acme/infra/issues": [
125
+ { number: 3, body: "<!-- m -->\nquoted", pull_request: { url: "x" } },
126
+ { number: 4, body: "unrelated", pull_request: null },
127
+ { number: 5, body: "<!-- m -->\nold" },
128
+ ],
129
+ "PATCH /repos/acme/infra/issues/5": { number: 5, html_url: "https://github.com/acme/infra/issues/5" },
130
+ });
131
+ const url = await githubForge({ ...opts, fetch }).upsertIssue("<!-- m -->", "Drift: 2", "<!-- m -->\nnew");
132
+ expect(url).toBe("https://github.com/acme/infra/issues/5");
133
+ expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
134
+ "GET https://api.github.com/repos/acme/infra/issues?state=open&per_page=100&page=1",
135
+ "PATCH https://api.github.com/repos/acme/infra/issues/5",
136
+ ]);
137
+ expect(calls[1].body).toEqual({ title: "Drift: 2", body: "<!-- m -->\nnew" });
138
+ });
139
+
140
+ test("opens an issue when no open one carries the marker (#3757)", async () => {
141
+ const { fetch, calls } = fakeFetch({
142
+ "GET /repos/acme/infra/issues": [{ number: 4, body: null }],
143
+ "POST /repos/acme/infra/issues": { number: 6, html_url: "https://github.com/acme/infra/issues/6" },
144
+ });
145
+ expect(await githubForge({ ...opts, fetch }).upsertIssue("<!-- m -->", "t", "<!-- m -->\nb")).toBe(
146
+ "https://github.com/acme/infra/issues/6",
147
+ );
148
+ expect(calls.at(-1)).toMatchObject({ method: "POST", body: { title: "t", body: "<!-- m -->\nb" } });
149
+ });
150
+
108
151
  test("an error status is a ForgeApiError naming the call", async () => {
109
152
  const { fetch } = fakeFetch({ [`POST /repos/acme/infra/statuses/${SHA}`]: 403 });
110
153
  await expect(githubForge({ ...opts, fetch }).setStatus(SHA, { context: "c", state: "success", description: "d" })).rejects.toBeInstanceOf(ForgeApiError);
@@ -131,6 +174,24 @@ describe("forgejo", () => {
131
174
  expect(calls[0].headers.Authorization).toBe("token t0k");
132
175
  });
133
176
 
177
+ test("keeps an issue under /api/v1 with Forgejo's page size (#3757)", async () => {
178
+ const page1 = Array.from({ length: 50 }, (_, i) => ({ number: i + 1, body: `other ${i}`, pull_request: null }));
179
+ const { fetch, calls } = fakeFetch({
180
+ "GET /api/v1/repos/acme/infra/issues?page=1": page1,
181
+ "GET /api/v1/repos/acme/infra/issues?page=2": [{ number: 77, body: "<!-- m -->\nold", pull_request: null }],
182
+ "PATCH /api/v1/repos/acme/infra/issues/77": { number: 77, html_url: "https://codeberg.org/acme/infra/issues/77" },
183
+ });
184
+ expect(await forgejoForge({ ...opts, fetch }).upsertIssue("<!-- m -->", "t", "<!-- m -->\nb")).toBe(
185
+ "https://codeberg.org/acme/infra/issues/77",
186
+ );
187
+ expect(calls.map((c) => c.url)).toEqual([
188
+ "https://codeberg.org/api/v1/repos/acme/infra/issues?state=open&limit=50&page=1",
189
+ "https://codeberg.org/api/v1/repos/acme/infra/issues?state=open&limit=50&page=2",
190
+ "https://codeberg.org/api/v1/repos/acme/infra/issues/77",
191
+ ]);
192
+ expect(calls[0].headers.Authorization).toBe("token t0k");
193
+ });
194
+
134
195
  test("a dismissed approval no longer counts, and a comment review decides nothing", async () => {
135
196
  const { fetch } = fakeFetch({
136
197
  "GET /api/v1/repos/acme/infra/pulls/4/reviews": [
@@ -235,3 +296,245 @@ describe("forgeFromEnv", () => {
235
296
  );
236
297
  });
237
298
  });
299
+
300
+ // ── #3759: comment, status and tracking issue by marker, for the products ──
301
+
302
+ describe("upsertComment (#3759)", () => {
303
+ const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
304
+
305
+ test("leaves a comment whose body is already current, and says which it did", async () => {
306
+ const { fetch, calls } = fakeFetch({
307
+ "GET /repos/acme/infra/issues/12/comments": [{ id: 4, body: "<!-- m -->\nsame", html_url: "https://h/4" }],
308
+ });
309
+ expect(await githubForge({ ...gh, fetch }).upsertComment(12, "<!-- m -->", "<!-- m -->\nsame")).toEqual({
310
+ action: "unchanged",
311
+ id: 4,
312
+ url: "https://h/4",
313
+ });
314
+ expect(calls.map((c) => c.method)).toEqual(["GET"]);
315
+ });
316
+
317
+ test("created and updated carry the comment's id and URL", async () => {
318
+ const created = fakeFetch({
319
+ "GET /repos/acme/infra/issues/12/comments": [],
320
+ "POST /repos/acme/infra/issues/12/comments": { id: 9, html_url: "https://h/9" },
321
+ });
322
+ expect(await githubForge({ ...gh, fetch: created.fetch }).upsertComment(12, "<!-- m -->", "<!-- m -->\nb")).toEqual({ action: "created", id: 9, url: "https://h/9" });
323
+ const updated = fakeFetch({
324
+ "GET /repos/acme/infra/issues/12/comments": [{ id: 3, body: "<!-- m -->\nold" }],
325
+ "PATCH /repos/acme/infra/issues/comments/3": { id: 3, html_url: "https://h/3" },
326
+ });
327
+ expect(await githubForge({ ...gh, fetch: updated.fetch }).upsertComment(12, "<!-- m -->", "<!-- m -->\nnew")).toEqual({ action: "updated", id: 3, url: "https://h/3" });
328
+ });
329
+
330
+ test("a body without its marker is refused before any call", async () => {
331
+ const { fetch, calls } = fakeFetch({});
332
+ await expect(githubForge({ ...gh, fetch }).upsertComment(12, "<!-- m -->", "no marker")).rejects.toThrow(/does not start with its marker/);
333
+ expect(calls).toEqual([]);
334
+ });
335
+
336
+ test("anywhere matches a marker inside the body, and author keeps only the bot's own", async () => {
337
+ const { fetch, calls } = fakeFetch({
338
+ "GET /repos/acme/infra/issues/12/comments": [
339
+ { id: 1, body: "quote: <!-- yodel:env=prod -->", user: { login: "mallory" } },
340
+ { id: 2, body: "plan\n<!-- yodel:env=prod -->", user: { login: "yodel-bot" } },
341
+ ],
342
+ "PATCH /repos/acme/infra/issues/comments/2": { id: 2 },
343
+ });
344
+ const r = await githubForge({ ...gh, fetch }).upsertComment(12, "<!-- yodel:env=prod -->", "new\n<!-- yodel:env=prod -->", { match: "anywhere", author: "Yodel-Bot" });
345
+ expect(r.action).toBe("updated");
346
+ expect(calls.at(-1)?.url).toBe("https://api.github.com/repos/acme/infra/issues/comments/2");
347
+ });
348
+
349
+ test("GitLab skips system notes, keeps the oldest of several, and edits with PUT", async () => {
350
+ const { fetch, calls } = fakeFetch({
351
+ "GET /api/v4/projects/42/merge_requests/5/notes": [
352
+ { id: 30, body: "<!-- m -->\nb", system: true },
353
+ { id: 21, body: "<!-- m -->\nnewer", author: { username: "bot" } },
354
+ { id: 20, body: "<!-- m -->\nolder", author: { username: "bot" } },
355
+ ],
356
+ "PUT /api/v4/projects/42/merge_requests/5/notes/20": { id: 20 },
357
+ });
358
+ const forge = gitlabForge({ apiBase: "https://gitlab.example.com/api/v4", project: "42", token: "glpat", fetch });
359
+ expect(await forge.upsertComment(5, "<!-- m -->", "<!-- m -->\nnow")).toEqual({ action: "updated", id: 20 });
360
+ expect(calls.at(-1)).toMatchObject({ method: "PUT", body: { body: "<!-- m -->\nnow" } });
361
+ });
362
+ });
363
+
364
+ describe("trackIssue (#3759)", () => {
365
+ const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
366
+ const open = [
367
+ { number: 8, title: "Drift: prod", body: "<!-- d -->\n2 changes", html_url: "https://h/issues/8" },
368
+ { number: 9, title: "pr", body: "<!-- d -->", pull_request: { url: "x" } },
369
+ ];
370
+
371
+ test("a cleared finding leaves the issue open unless close-on-clear is set", async () => {
372
+ const { fetch, calls } = fakeFetch({ "GET /repos/acme/infra/issues": open });
373
+ expect(await githubForge({ ...gh, fetch }).trackIssue("<!-- d -->", null)).toEqual({ action: "left", number: 8, url: "https://h/issues/8" });
374
+ expect(calls.map((c) => c.method)).toEqual(["GET"]);
375
+ });
376
+
377
+ test("close-on-clear comments, then closes, on GitHub and Forgejo", async () => {
378
+ for (const [make, base] of [
379
+ [(f: ForgeFetch) => githubForge({ ...gh, fetch: f }), "/repos/acme/infra"],
380
+ [(f: ForgeFetch) => forgejoForge({ ...gh, apiBase: "https://codeberg.org/api/v1", host: "codeberg.org", fetch: f }), "/api/v1/repos/acme/infra"],
381
+ ] as const) {
382
+ const { fetch, calls } = fakeFetch({
383
+ [`GET ${base}/issues`]: open,
384
+ [`POST ${base}/issues/8/comments`]: {},
385
+ [`PATCH ${base}/issues/8`]: {},
386
+ });
387
+ const r = await make(fetch).trackIssue("<!-- d -->", null, { closeOnClear: true, clearComment: "No drift now." });
388
+ expect(r).toEqual({ action: "closed", number: 8, url: "https://h/issues/8" });
389
+ expect(calls.slice(1).map((c) => [c.method, new URL(c.url).pathname, c.body])).toEqual([
390
+ ["POST", `${base}/issues/8/comments`, { body: "No drift now." }],
391
+ ["PATCH", `${base}/issues/8`, { state: "closed" }],
392
+ ]);
393
+ }
394
+ });
395
+
396
+ test("a cleared finding with no open issue does nothing", async () => {
397
+ const { fetch, calls } = fakeFetch({ "GET /repos/acme/infra/issues": [] });
398
+ expect(await githubForge({ ...gh, fetch }).trackIssue("<!-- d -->", null, { closeOnClear: true })).toEqual({ action: "none" });
399
+ expect(calls).toHaveLength(1);
400
+ });
401
+
402
+ test("a finding opens, updates, or leaves an issue that already says it", async () => {
403
+ const same = fakeFetch({ "GET /repos/acme/infra/issues": open });
404
+ expect(await githubForge({ ...gh, fetch: same.fetch }).trackIssue("<!-- d -->", { title: "Drift: prod", body: "<!-- d -->\n2 changes" })).toMatchObject({ action: "unchanged", number: 8 });
405
+ const changed = fakeFetch({ "GET /repos/acme/infra/issues": open, "PATCH /repos/acme/infra/issues/8": { number: 8, html_url: "https://h/issues/8" } });
406
+ expect(await githubForge({ ...gh, fetch: changed.fetch }).trackIssue("<!-- d -->", { title: "Drift: prod", body: "<!-- d -->\n3 changes" })).toMatchObject({ action: "updated", number: 8 });
407
+ const none = fakeFetch({ "GET /repos/acme/infra/issues": [], "POST /repos/acme/infra/issues": { number: 10, html_url: "https://h/issues/10" } });
408
+ expect(await githubForge({ ...gh, fetch: none.fetch }).trackIssue("<!-- d -->", { title: "t", body: "<!-- d -->\nb" })).toEqual({ action: "opened", number: 10, url: "https://h/issues/10" });
409
+ });
410
+
411
+ test("GitLab searches open issues by description, writes description, and closes with a note", async () => {
412
+ const issues = [{ iid: 3, title: "Drift", description: "<!-- d -->\nold", web_url: "https://gl/i/3" }];
413
+ const { fetch, calls } = fakeFetch({
414
+ "GET /api/v4/projects/group%2Fapp/issues": issues,
415
+ "PUT /api/v4/projects/group%2Fapp/issues/3": { iid: 3, web_url: "https://gl/i/3" },
416
+ "POST /api/v4/projects/group%2Fapp/issues/3/notes": {},
417
+ });
418
+ const forge = gitlabForge({ apiBase: "https://gitlab.example.com/api/v4", project: "group/app", token: "glpat", fetch });
419
+ expect(await forge.trackIssue("<!-- d -->", { title: "Drift", body: "<!-- d -->\nnew" })).toEqual({ action: "updated", number: 3, url: "https://gl/i/3" });
420
+ const list = new URL(calls[0].url);
421
+ expect([list.searchParams.get("state"), list.searchParams.get("search"), list.searchParams.get("in")]).toEqual(["opened", "<!-- d -->", "description"]);
422
+ expect(calls[1].body).toEqual({ title: "Drift", description: "<!-- d -->\nnew" });
423
+ expect(await forge.trackIssue("<!-- d -->", null, { closeOnClear: true, clearComment: "gone" })).toMatchObject({ action: "closed", number: 3 });
424
+ expect(calls.slice(-2).map((c) => [c.method, new URL(c.url).pathname, c.body])).toEqual([
425
+ ["POST", "/api/v4/projects/group%2Fapp/issues/3/notes", { body: "gone" }],
426
+ ["PUT", "/api/v4/projects/group%2Fapp/issues/3", { state_event: "close" }],
427
+ ]);
428
+ });
429
+
430
+ test("GitLab's upsertIssue opens one when none carries the marker", async () => {
431
+ const { fetch, calls } = fakeFetch({
432
+ "GET /api/v4/projects/42/issues": [],
433
+ "POST /api/v4/projects/42/issues": { iid: 1, web_url: "https://gl/i/1" },
434
+ });
435
+ expect(await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "x", fetch }).upsertIssue("<!-- d -->", "t", "<!-- d -->\nb")).toBe("https://gl/i/1");
436
+ expect(calls.at(-1)?.body).toEqual({ title: "t", description: "<!-- d -->\nb" });
437
+ });
438
+ });
439
+
440
+ describe("commit status (#3759)", () => {
441
+ test("cuts the description to what each forge keeps, and passes GitLab's pipeline", async () => {
442
+ const long = "x".repeat(400);
443
+ const gh = fakeFetch({ [`POST /repos/acme/infra/statuses/${SHA}`]: {} });
444
+ await githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch: gh.fetch }).setStatus(SHA, { context: "c", state: "success", description: long });
445
+ expect((gh.calls[0].body as { description: string }).description).toHaveLength(140);
446
+ const gl = fakeFetch({ [`POST /api/v4/projects/42/statuses/${SHA}`]: {} });
447
+ await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).setStatus(SHA, { context: "c", state: "pending", description: long, pipelineId: 7 });
448
+ expect(gl.calls[0].body).toMatchObject({ state: "pending", pipeline_id: 7 });
449
+ expect((gl.calls[0].body as { description: string }).description).toHaveLength(255);
450
+ });
451
+
452
+ test("GitLab's 400 for a status already in that state is not an error; another 400 is", async () => {
453
+ const answer = (text: string): ForgeFetch => async () => ({ ok: false, status: 400, text: async () => text });
454
+ const forge = (f: ForgeFetch) => gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: f });
455
+ await expect(forge(answer('{"message":"Cannot transition status via :success from :success"}')).setStatus(SHA, { context: "c", state: "success", description: "d" })).resolves.toBeUndefined();
456
+ await expect(forge(answer('{"message":"name is invalid"}')).setStatus(SHA, { context: "c", state: "success", description: "d" })).rejects.toBeInstanceOf(ForgeApiError);
457
+ });
458
+ });
459
+
460
+ describe("forgeTargetFromEnv (#3759)", () => {
461
+ test("detects the forge from the job's environment", () => {
462
+ expect(detectForge({ GITHUB_ACTIONS: "true", GITHUB_API_URL: "https://api.github.com" })).toBe("github");
463
+ expect(detectForge({ GITHUB_ACTIONS: "true", GITHUB_API_URL: "https://forgejo.example/api/v1" })).toBe("forgejo");
464
+ expect(detectForge({ GITHUB_ACTIONS: "true", FORGEJO_ACTIONS: "true" })).toBe("forgejo");
465
+ expect(detectForge({ GITLAB_CI: "true" })).toBe("gitlab");
466
+ expect(detectForge({})).toBeUndefined();
467
+ });
468
+
469
+ test("GitHub: repository, pull request and head from the event payload, token in the documented order, run link", () => {
470
+ const t = forgeTargetFromEnv(
471
+ {
472
+ GITHUB_ACTIONS: "true",
473
+ GITHUB_REPOSITORY: "acme/infra",
474
+ GITHUB_SERVER_URL: "https://github.com",
475
+ GITHUB_RUN_ID: "77",
476
+ GITHUB_SHA: "m".repeat(40),
477
+ GITHUB_EVENT_PATH: "/event.json",
478
+ GITHUB_TOKEN: "late",
479
+ GH_TOKEN: "early",
480
+ },
481
+ { readFile: () => JSON.stringify({ number: 12, pull_request: { number: 12, head: { sha: SHA } } }) },
482
+ );
483
+ expect(t).toEqual({
484
+ kind: "github",
485
+ apiBase: "https://api.github.com",
486
+ repo: "acme/infra",
487
+ host: "github.com",
488
+ token: "early",
489
+ tokenSource: "GH_TOKEN",
490
+ pr: 12,
491
+ sha: SHA,
492
+ runUrl: "https://github.com/acme/infra/actions/runs/77",
493
+ });
494
+ expect(forgeClient(t).kind).toBe("github");
495
+ });
496
+
497
+ test("Forgejo: FORGEJO_* first, the API under the server, the pull request from the ref, a named token variable", () => {
498
+ const t = forgeTargetFromEnv(
499
+ { FORGEJO_ACTIONS: "true", FORGEJO_REPOSITORY: "a/b", GITHUB_SERVER_URL: "https://codeberg.org/", GITHUB_REF: "refs/pull/4/head", FORGEJO_TOKEN: "f", CHANT_FORGEJO_TOKEN: "c" },
500
+ { tokenEnv: "FORGEJO_TOKEN" },
501
+ );
502
+ expect(t).toMatchObject({ kind: "forgejo", apiBase: "https://codeberg.org/api/v1", repo: "a/b", host: "codeberg.org", pr: 4, token: "f", tokenSource: "FORGEJO_TOKEN" });
503
+ const forge = forgeClient(t, fakeFetch({}).fetch);
504
+ expect(forge.principalOf("Ana")).toBe("forgejo@codeberg.org:ana");
505
+ });
506
+
507
+ test("GitLab: the merge request's project, its iid, and the job token in its own header", async () => {
508
+ const t = forgeTargetFromEnv({
509
+ GITLAB_CI: "true",
510
+ CI_API_V4_URL: "https://gitlab.example.com/api/v4",
511
+ CI_SERVER_URL: "https://gitlab.example.com",
512
+ CI_PROJECT_ID: "9",
513
+ CI_MERGE_REQUEST_PROJECT_ID: "42",
514
+ CI_MERGE_REQUEST_IID: "5",
515
+ CI_COMMIT_SHA: SHA,
516
+ CI_JOB_TOKEN: "job",
517
+ });
518
+ expect(t).toMatchObject({ kind: "gitlab", repo: "42", pr: 5, sha: SHA, host: "gitlab.example.com", tokenSource: "CI_JOB_TOKEN", tokenHeader: "JOB-TOKEN" });
519
+ const { fetch, calls } = fakeFetch({ "GET /api/v4/projects/42/merge_requests/5/notes": [], "POST /api/v4/projects/42/merge_requests/5/notes": { id: 1 } });
520
+ await forgeClient(t, fetch).upsertComment(5, "<!-- m -->", "<!-- m -->");
521
+ expect(calls[0].headers["JOB-TOKEN"]).toBe("job");
522
+ expect(calls[0].headers["PRIVATE-TOKEN"]).toBeUndefined();
523
+ });
524
+
525
+ test("says every missing piece, and when the forge is unknown", () => {
526
+ let err: unknown;
527
+ try {
528
+ forgeTargetFromEnv({ GITHUB_ACTIONS: "true" }, { requirePr: true });
529
+ } catch (e) {
530
+ err = e;
531
+ }
532
+ expect(err).toBeInstanceOf(ForgeEnvironmentError);
533
+ expect((err as ForgeEnvironmentError).missing).toEqual([
534
+ "GITHUB_REPOSITORY",
535
+ "a pull request (a pull_request event, or refs/pull/<n>/ in GITHUB_REF)",
536
+ "a token (CHANT_FORGEJO_TOKEN, GH_TOKEN, GITHUB_TOKEN)",
537
+ ]);
538
+ expect(() => forgeTargetFromEnv({})).toThrow(/forge is not known/);
539
+ });
540
+ });