@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
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, vi, beforeEach } from "vitest";
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import {
3
3
  reconcilePr,
4
4
  reconcileSummary,
@@ -19,6 +19,7 @@ import {
19
19
  noIssueTokenMessage,
20
20
  postOrUpdateGithubIssue,
21
21
  ghCredentialEnv,
22
+ githubShapedForgeFrom,
22
23
  } from "./reconcile";
23
24
 
24
25
  // ── The `gh` stub (chant #2291) ──────────────────────────────────────────────
@@ -53,9 +54,106 @@ vi.mock("node:child_process", async (importOriginal) => {
53
54
  return { ...(await importOriginal<typeof import("node:child_process")>()), exec };
54
55
  });
55
56
 
57
+ // ── The fake GitHub-shaped forge (chant #3757) ───────────────────────────────
58
+ //
59
+ // `comment` and `issue` mode reach GitHub and Forgejo through the forge client
60
+ // in `pr-forge.ts`, over the global `fetch`. This stands in for one repository
61
+ // on either forge: it holds the pull request's comments and the repository's
62
+ // issues, pages them the way each forge pages (`per_page` on GitHub, `limit`
63
+ // on Forgejo), and applies each POST and PATCH, so a second run reads what the
64
+ // first one wrote rather than a canned reply.
65
+
66
+ interface ForgeCall {
67
+ method: string;
68
+ url: string;
69
+ authorization?: string;
70
+ body?: Record<string, unknown>;
71
+ }
72
+
73
+ interface FakeComment {
74
+ id: number;
75
+ pr: number;
76
+ body: string;
77
+ }
78
+
79
+ interface FakeIssue {
80
+ number: number;
81
+ title: string;
82
+ body: string;
83
+ state: "open" | "closed";
84
+ /** Set on a pull request, which the issues list returns too. */
85
+ pull_request?: Record<string, unknown> | null;
86
+ }
87
+
88
+ const forgeCalls: ForgeCall[] = [];
89
+ let forgeState: { comments: FakeComment[]; issues: FakeIssue[]; failWith?: number };
90
+
91
+ function forgeJson(value: unknown, status = 200): Response {
92
+ return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
93
+ }
94
+
95
+ async function fakeForgeFetch(url: string, init?: RequestInit): Promise<Response> {
96
+ const method = init?.method ?? "GET";
97
+ const headers = (init?.headers ?? {}) as Record<string, string>;
98
+ const body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : undefined;
99
+ forgeCalls.push({ method, url, authorization: headers.Authorization, ...(body ? { body } : {}) });
100
+ if (forgeState.failWith) return forgeJson({ message: "token is required" }, forgeState.failWith);
101
+
102
+ const u = new URL(url);
103
+ const web = u.origin === "https://api.github.com" ? "https://github.com" : u.origin;
104
+ const m = /\/repos\/([^/]+\/[^/]+)(\/.*)$/.exec(u.pathname);
105
+ if (!m) return forgeJson({ message: "Not Found" }, 404);
106
+ const [, repo, rest] = m;
107
+ const size = Number(u.searchParams.get("per_page") ?? u.searchParams.get("limit") ?? "30");
108
+ const page = Number(u.searchParams.get("page") ?? "1");
109
+ const paged = <T>(items: T[]) => items.slice((page - 1) * size, page * size);
110
+ const commentJson = (c: FakeComment) => ({ ...c, html_url: `${web}/${repo}/issues/${c.pr}#issuecomment-${c.id}` });
111
+ const issueJson = (i: FakeIssue) => ({ ...i, html_url: `${web}/${repo}/issues/${i.number}` });
112
+
113
+ let hit: RegExpExecArray | null;
114
+ if ((hit = /^\/issues\/(\d+)\/comments$/.exec(rest))) {
115
+ const pr = Number(hit[1]);
116
+ if (method === "GET") return forgeJson(paged(forgeState.comments.filter((c) => c.pr === pr)).map(commentJson));
117
+ const c = { id: 1000 + forgeState.comments.length, pr, body: String(body?.body) };
118
+ forgeState.comments.push(c);
119
+ return forgeJson(commentJson(c), 201);
120
+ }
121
+ if ((hit = /^\/issues\/comments\/(\d+)$/.exec(rest)) && method === "PATCH") {
122
+ const c = forgeState.comments.find((x) => x.id === Number(hit![1]));
123
+ if (!c) return forgeJson({ message: "Not Found" }, 404);
124
+ c.body = String(body?.body);
125
+ return forgeJson(commentJson(c));
126
+ }
127
+ if (rest === "/issues" && method === "GET") {
128
+ const state = u.searchParams.get("state") ?? "open";
129
+ return forgeJson(paged(forgeState.issues.filter((i) => state === "all" || i.state === state)).map(issueJson));
130
+ }
131
+ if (rest === "/issues" && method === "POST") {
132
+ const i: FakeIssue = {
133
+ number: forgeState.issues.length + 1,
134
+ title: String(body?.title),
135
+ body: String(body?.body),
136
+ state: "open",
137
+ pull_request: null,
138
+ };
139
+ forgeState.issues.push(i);
140
+ return forgeJson(issueJson(i), 201);
141
+ }
142
+ if ((hit = /^\/issues\/(\d+)$/.exec(rest)) && method === "PATCH") {
143
+ const i = forgeState.issues.find((x) => x.number === Number(hit![1]));
144
+ if (!i) return forgeJson({ message: "Not Found" }, 404);
145
+ Object.assign(i, { title: String(body?.title), body: String(body?.body) });
146
+ return forgeJson(issueJson(i));
147
+ }
148
+ return forgeJson({ message: "Not Found" }, 404);
149
+ }
150
+
56
151
  beforeEach(() => {
57
152
  ghCalls.length = 0;
58
153
  ghReplies = [];
154
+ forgeCalls.length = 0;
155
+ forgeState = { comments: [], issues: [] };
156
+ vi.stubGlobal("fetch", fakeForgeFetch);
59
157
  });
60
158
 
61
159
  const entries = [
@@ -278,8 +376,9 @@ describe("ghCredentialEnv (#2333)", () => {
278
376
  });
279
377
  });
280
378
 
281
- describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () => {
379
+ describe("reconcilePr comment mode posts through the forge client (#2291, #3757)", () => {
282
380
  const repo = "acme/infra";
381
+ const marker = "<!-- chant-reconcile:app -->";
283
382
 
284
383
  function stubPrEnv(apiUrl: string): void {
285
384
  vi.stubEnv("GITHUB_REPOSITORY", repo);
@@ -289,30 +388,24 @@ describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () =
289
388
  vi.stubEnv("GH_TOKEN", "forgejo-actions-token");
290
389
  vi.stubEnv("GITHUB_TOKEN", "");
291
390
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
391
+ vi.stubEnv("FORGEJO_ACTIONS", "");
392
+ vi.stubEnv("GITEA_ACTIONS", "");
292
393
  // Never the GitLab path: a GitHub/Forgejo run carries no merge request.
293
394
  vi.stubEnv("CI_MERGE_REQUEST_IID", "");
294
395
  }
295
396
 
296
- test("POSTs a new comment at the Forgejo instance's own /api/v1 base, not a bare path", async () => {
397
+ test("POSTs a new comment at the Forgejo instance's own /api/v1 base", async () => {
297
398
  stubPrEnv("http://forgejo.example/api/v1");
298
- ghReplies = [
299
- { match: "--paginate", stdout: "" }, // no owned comment yet
300
- { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
301
- ];
302
399
  try {
303
400
  const result = await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
304
- expect(result.commentUrl).toBe("http://forgejo.example/acme/infra/issues/5#issuecomment-1");
401
+ expect(result.commentUrl).toBe("http://forgejo.example/acme/infra/issues/5#issuecomment-1000");
305
402
  expect(result.pullRequest).toBe("acme/infra#5");
306
-
307
- const [list, post] = ghCalls;
308
- // The bug #2291 fixed: this used to be the bare path
309
- // `repos/acme/infra/issues/5/comments`, which `gh` resolves against
310
- // `/api/v3` for any non-github.com host — 404 on Forgejo. It is now the
311
- // full URL built from GITHUB_API_URL.
312
- expect(list.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/5/comments");
313
- expect(list.cmd).not.toMatch(/gh api 'repos\//);
314
- expect(post.cmd).toContain("--method POST");
315
- expect(post.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/5/comments");
403
+ expect(forgeCalls.map((c) => `${c.method} ${c.url}`)).toEqual([
404
+ "GET http://forgejo.example/api/v1/repos/acme/infra/issues/5/comments?limit=50&page=1",
405
+ "POST http://forgejo.example/api/v1/repos/acme/infra/issues/5/comments",
406
+ ]);
407
+ expect(forgeCalls[1].body).toEqual({ body: `${marker}\n\nthe plan` });
408
+ expect(ghCalls).toHaveLength(0);
316
409
  } finally {
317
410
  vi.unstubAllEnvs();
318
411
  }
@@ -320,20 +413,33 @@ describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () =
320
413
 
321
414
  test("a re-run PATCHes the comment it already owns instead of posting a second", async () => {
322
415
  stubPrEnv("http://forgejo.example/api/v1");
323
- ghReplies = [
324
- { match: "--paginate", stdout: "42\n" }, // the marker search found comment 42
325
- { match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-42\n" },
326
- ];
416
+ forgeState.comments.push({ id: 7, pr: 5, body: "lgtm" }, { id: 42, pr: 5, body: `${marker}\n\nold plan` });
327
417
  try {
328
418
  const result = await reconcilePr({ env: "app", mode: "comment", body: "a newer plan" });
329
419
  expect(result.commentUrl).toBe("http://forgejo.example/acme/infra/issues/5#issuecomment-42");
420
+ expect(forgeCalls.at(-1)).toMatchObject({
421
+ method: "PATCH",
422
+ url: "http://forgejo.example/api/v1/repos/acme/infra/issues/comments/42",
423
+ });
424
+ expect(forgeCalls.some((c) => c.method === "POST")).toBe(false);
425
+ expect(forgeState.comments.find((c) => c.id === 42)?.body).toBe(`${marker}\n\na newer plan`);
426
+ expect(forgeState.comments.find((c) => c.id === 7)?.body).toBe("lgtm");
427
+ } finally {
428
+ vi.unstubAllEnvs();
429
+ }
430
+ });
330
431
 
331
- const patchCall = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
332
- const postCall = ghCalls.find((c) => c.cmd.includes("--method POST"));
333
- expect(patchCall?.cmd).toContain(
334
- "http://forgejo.example/api/v1/repos/acme/infra/issues/comments/42",
335
- );
336
- expect(postCall).toBeUndefined();
432
+ test("finds its comment past the first page", async () => {
433
+ stubPrEnv("");
434
+ for (let i = 0; i < 100; i++) forgeState.comments.push({ id: i + 1, pr: 5, body: `chatter ${i}` });
435
+ forgeState.comments.push({ id: 500, pr: 5, body: `${marker}\n\nold` });
436
+ try {
437
+ await reconcilePr({ env: "app", mode: "comment", body: "new" });
438
+ expect(forgeCalls.map((c) => `${c.method} ${c.url}`)).toEqual([
439
+ "GET https://api.github.com/repos/acme/infra/issues/5/comments?per_page=100&page=1",
440
+ "GET https://api.github.com/repos/acme/infra/issues/5/comments?per_page=100&page=2",
441
+ "PATCH https://api.github.com/repos/acme/infra/issues/comments/500",
442
+ ]);
337
443
  } finally {
338
444
  vi.unstubAllEnvs();
339
445
  }
@@ -341,71 +447,53 @@ describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () =
341
447
 
342
448
  test("defaults to github.com's API base when GITHUB_API_URL is unset (plain GitHub run)", async () => {
343
449
  stubPrEnv("");
344
- ghReplies = [
345
- { match: "--paginate", stdout: "" },
346
- { match: "--method POST", stdout: "https://github.com/acme/infra/issues/5#issuecomment-1\n" },
347
- ];
348
450
  try {
349
- await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
350
- expect(ghCalls[0].cmd).toContain("https://api.github.com/repos/acme/infra/issues/5/comments");
451
+ const result = await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
452
+ expect(forgeCalls[0].url).toBe("https://api.github.com/repos/acme/infra/issues/5/comments?per_page=100&page=1");
453
+ expect(forgeCalls[0].authorization).toBe("Bearer forgejo-actions-token");
454
+ expect(result.commentUrl).toBe("https://github.com/acme/infra/issues/5#issuecomment-1000");
351
455
  } finally {
352
456
  vi.unstubAllEnvs();
353
457
  }
354
458
  });
355
459
 
356
- test("forwards the resolved token to `gh` as GH_TOKEN, CHANT_FORGEJO_TOKEN taking priority", async () => {
460
+ test("sends the resolved token itself, Forgejo's way, CHANT_FORGEJO_TOKEN taking priority", async () => {
357
461
  stubPrEnv("http://forgejo.example/api/v1");
358
462
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
359
- ghReplies = [
360
- { match: "--paginate", stdout: "" },
361
- { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
362
- ];
363
463
  try {
364
464
  await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
365
- for (const call of ghCalls) expect(call.opts.env?.GH_TOKEN).toBe("cross-instance-token");
465
+ expect(forgeCalls.length).toBeGreaterThan(0);
466
+ for (const call of forgeCalls) expect(call.authorization).toBe("token cross-instance-token");
366
467
  } finally {
367
468
  vi.unstubAllEnvs();
368
469
  }
369
470
  });
370
471
 
371
- // #2333: the full URL #2291 built reached Forgejo, but `gh` scopes
372
- // GH_TOKEN to github.com and ghe.com subdomains, so the POST/PATCH arrived
373
- // with no Authorization header and a real instance answered 401.
374
- test("every comment-mode `gh` call carries GH_ENTERPRISE_TOKEN, which is what a Forgejo host reads (#2333)", async () => {
375
- stubPrEnv("http://forgejo.example/api/v1");
376
- vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
377
- ghReplies = [
378
- { match: "--paginate", stdout: "" },
379
- { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
380
- ];
472
+ test("FORGEJO_ACTIONS marks a Forgejo run whatever its API base looks like", async () => {
473
+ stubPrEnv("https://code.example/api/forgejo");
474
+ vi.stubEnv("FORGEJO_ACTIONS", "true");
381
475
  try {
382
476
  await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
383
- expect(ghCalls.length).toBeGreaterThan(0);
384
- for (const call of ghCalls) {
385
- expect(call.opts.env?.GH_ENTERPRISE_TOKEN).toBe("cross-instance-token");
386
- }
477
+ expect(forgeCalls[0].url).toContain("limit=50");
478
+ expect(forgeCalls[0].authorization).toBe("token forgejo-actions-token");
387
479
  } finally {
388
480
  vi.unstubAllEnvs();
389
481
  }
390
482
  });
391
483
 
392
- test("the PATCH branch carries it too, not only the POST (#2333)", async () => {
484
+ test("a refused write fails the step with the forge's status and the token's source", async () => {
393
485
  stubPrEnv("http://forgejo.example/api/v1");
394
- vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
395
- ghReplies = [
396
- { match: "--paginate", stdout: "4242\n" },
397
- { match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
398
- ];
486
+ forgeState.failWith = 401;
399
487
  try {
400
- await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
401
- const patch = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
402
- expect(patch?.opts.env?.GH_ENTERPRISE_TOKEN).toBe("cross-instance-token");
488
+ await expect(reconcilePr({ env: "app", mode: "comment", body: "the plan" })).rejects.toThrow(
489
+ /answered 401.*token is required.*token from GH_TOKEN/s,
490
+ );
403
491
  } finally {
404
492
  vi.unstubAllEnvs();
405
493
  }
406
494
  });
407
495
 
408
- test("a pull request with no token at all is refused by name, before any `gh` call", async () => {
496
+ test("a pull request with no token at all is refused by name, before any call", async () => {
409
497
  stubPrEnv("http://forgejo.example/api/v1");
410
498
  vi.stubEnv("GH_TOKEN", "");
411
499
  vi.stubEnv("GITHUB_TOKEN", "");
@@ -414,6 +502,7 @@ describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () =
414
502
  await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
415
503
  noCommentTokenMessage(repo, 5),
416
504
  );
505
+ expect(forgeCalls).toHaveLength(0);
417
506
  expect(ghCalls).toHaveLength(0);
418
507
  } finally {
419
508
  vi.unstubAllEnvs();
@@ -421,6 +510,25 @@ describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () =
421
510
  });
422
511
  });
423
512
 
513
+ describe("githubShapedForgeFrom (#3757)", () => {
514
+ const token = { value: "t", source: "GH_TOKEN" };
515
+
516
+ test("github.com and GHES bases get the GitHub client", () => {
517
+ expect(githubShapedForgeFrom({}, "acme/infra", token).kind).toBe("github");
518
+ expect(githubShapedForgeFrom({ GITHUB_API_URL: "https://ghe.example/api/v3" }, "acme/infra", token).kind).toBe("github");
519
+ });
520
+
521
+ test("an /api/v1 base, FORGEJO_ACTIONS or GITEA_ACTIONS gets the Forgejo client", () => {
522
+ expect(githubShapedForgeFrom({ GITHUB_API_URL: "http://forgejo:3000/api/v1/" }, "a/b", token).kind).toBe("forgejo");
523
+ expect(githubShapedForgeFrom({ FORGEJO_ACTIONS: "true" }, "a/b", token).kind).toBe("forgejo");
524
+ expect(githubShapedForgeFrom({ GITEA_ACTIONS: "true" }, "a/b", token).kind).toBe("forgejo");
525
+ });
526
+
527
+ test("a Forgejo client is named by its API host", () => {
528
+ expect(githubShapedForgeFrom({ GITHUB_API_URL: "http://forgejo:3000/api/v1" }, "a/b", token).host).toBe("forgejo:3000");
529
+ });
530
+ });
531
+
424
532
  // ── The GitLab merge-request note (#2256) ───────────────────────────────────
425
533
 
426
534
  describe("mergeRequestContextFrom (#2256)", () => {
@@ -724,12 +832,12 @@ describe("reconcilePr issue mode refuses a step with no Op identity (#2319)", ()
724
832
  }
725
833
  });
726
834
 
727
- test("refuses before the shell-out, so nothing runs on the way to failing", async () => {
835
+ test("refuses before any call, so nothing runs on the way to failing", async () => {
728
836
  stubAnyCi();
729
- ghReplies = [{ match: "--paginate", stdout: "57\n" }];
730
837
  try {
731
838
  await expect(reconcilePr({ env: "app", mode: "issue", body: "plan" })).rejects.toThrow();
732
839
  expect(ghCalls).toHaveLength(0);
840
+ expect(forgeCalls).toHaveLength(0);
733
841
  } finally {
734
842
  vi.unstubAllEnvs();
735
843
  }
@@ -759,15 +867,14 @@ describe("reconcilePr issue mode refuses a step with no Op identity (#2319)", ()
759
867
  for (const blank of ["", " ", "\t\n"]) {
760
868
  test(`a blank marker (${JSON.stringify(blank)}) is no marker, not an identity`, async () => {
761
869
  stubAnyCi();
762
- ghReplies = [
763
- { match: "--paginate", stdout: "42\n" }, // a human's issue, first in the list
764
- { match: "--method PATCH", stdout: "https://github.com/acme/infra/issues/42\n" },
765
- ];
870
+ // A human's issue, first in the list: what `startswith("")` would match.
871
+ forgeState.issues.push({ number: 42, title: "mine", body: "a human's issue", state: "open" });
766
872
  try {
767
873
  await expect(
768
874
  reconcilePr({ env: "app", marker: blank, mode: "issue", body: "plan" }),
769
875
  ).rejects.toThrow(noIssueIdentityMessage("app"));
770
- expect(ghCalls).toHaveLength(0);
876
+ expect(forgeCalls).toHaveLength(0);
877
+ expect(forgeState.issues[0].body).toBe("a human's issue");
771
878
  } finally {
772
879
  vi.unstubAllEnvs();
773
880
  }
@@ -776,13 +883,11 @@ describe("reconcilePr issue mode refuses a step with no Op identity (#2319)", ()
776
883
 
777
884
  test("a blank marker alongside an op falls back to the op's marker, not to matching everything", async () => {
778
885
  stubAnyCi();
779
- ghReplies = [
780
- { match: "--paginate", stdout: "" },
781
- { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
782
- ];
886
+ forgeState.issues.push({ number: 1, title: "mine", body: "a human's issue", state: "open" });
783
887
  try {
784
888
  await reconcilePr({ env: "app", op: "nightly", marker: " ", mode: "issue", body: "plan" });
785
- expect(ghCalls[0].cmd).toContain('startswith("<!-- chant-reconcile-issue:nightly/app -->")');
889
+ expect(forgeState.issues[0].body).toBe("a human's issue");
890
+ expect(forgeState.issues[1].body).toBe("<!-- chant-reconcile-issue:nightly/app -->\n\nplan");
786
891
  } finally {
787
892
  vi.unstubAllEnvs();
788
893
  }
@@ -795,6 +900,7 @@ describe("reconcilePr issue mode refuses a step with no Op identity (#2319)", ()
795
900
  reconcilePr({ env: "app", op: " ", mode: "issue", body: "plan" }),
796
901
  ).rejects.toThrow(noIssueIdentityMessage("app"));
797
902
  expect(ghCalls).toHaveLength(0);
903
+ expect(forgeCalls).toHaveLength(0);
798
904
  } finally {
799
905
  vi.unstubAllEnvs();
800
906
  }
@@ -807,27 +913,20 @@ describe("reconcilePr issue mode refuses a step with no Op identity (#2319)", ()
807
913
  vi.stubEnv("GITHUB_API_URL", "");
808
914
  vi.stubEnv("CI_MERGE_REQUEST_IID", "");
809
915
  vi.stubEnv("GH_TOKEN", "ghs-x");
810
- ghReplies = [
811
- { match: "--paginate", stdout: "" },
812
- { match: "--method POST", stdout: "https://github.com/acme/infra/pull/7#issuecomment-1\n" },
813
- ];
916
+ forgeState.comments.push({ id: 1, pr: 7, body: "lgtm" });
814
917
  try {
815
918
  await reconcilePr({ env: "app", marker: "", mode: "comment", body: "plan" });
816
- expect(ghCalls[0].cmd).toContain('startswith("<!-- chant-reconcile:app -->")');
817
- expect(ghCalls[0].cmd).not.toContain('startswith("")');
919
+ expect(forgeState.comments[0].body).toBe("lgtm");
920
+ expect(forgeState.comments[1].body).toBe("<!-- chant-reconcile:app -->\n\nplan");
818
921
  } finally {
819
922
  vi.unstubAllEnvs();
820
923
  }
821
924
  });
822
925
  test("an explicit marker satisfies the requirement without an op", async () => {
823
926
  stubAnyCi();
824
- ghReplies = [
825
- { match: "--paginate", stdout: "" },
826
- { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
827
- ];
828
927
  try {
829
928
  await reconcilePr({ env: "app", marker: "<!-- mine -->", mode: "issue", body: "plan" });
830
- expect(ghCalls[1].cmd).toContain("<!-- mine -->\n\nplan");
929
+ expect(forgeState.issues[0].body).toBe("<!-- mine -->\n\nplan");
831
930
  } finally {
832
931
  vi.unstubAllEnvs();
833
932
  }
@@ -851,13 +950,9 @@ describe("reconcilePr issue mode refuses a step with no Op identity (#2319)", ()
851
950
  vi.stubEnv("GITHUB_API_URL", "");
852
951
  vi.stubEnv("CI_MERGE_REQUEST_IID", "");
853
952
  vi.stubEnv("GH_TOKEN", "ghs-x");
854
- ghReplies = [
855
- { match: "--paginate", stdout: "" },
856
- { match: "--method POST", stdout: "https://github.com/acme/infra/pull/7#issuecomment-1\n" },
857
- ];
858
953
  try {
859
954
  const result = await reconcilePr({ env: "app", mode: "comment", body: "plan" });
860
- expect(result.commentUrl).toContain("issuecomment-1");
955
+ expect(result.commentUrl).toBe("https://github.com/acme/infra/issues/7#issuecomment-1000");
861
956
  } finally {
862
957
  vi.unstubAllEnvs();
863
958
  }
@@ -891,6 +986,7 @@ describe("suppliedMarker rejects what the search filter cannot carry (#2319)", (
891
986
  reconcilePr({ env: "app", marker: '<!-- ") | not -->', mode: "issue", body: "plan" }),
892
987
  ).rejects.toThrow(/cannot use/);
893
988
  expect(ghCalls).toHaveLength(0);
989
+ expect(forgeCalls).toHaveLength(0);
894
990
  } finally {
895
991
  vi.unstubAllEnvs();
896
992
  }
@@ -1069,16 +1165,19 @@ describe("reconcilePr issue mode on GitLab opens/updates one issue (#2292)", ()
1069
1165
 
1070
1166
  // ── The GitHub/GHES/Forgejo sticky issue (#2297) ────────────────────────────
1071
1167
 
1072
- describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue (#2297)", () => {
1168
+ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue (#2297, #3757)", () => {
1073
1169
  const repo = "acme/infra";
1170
+ const marker = "<!-- chant-reconcile-issue:nightly/app -->";
1074
1171
 
1075
1172
  function stubGithubIssueEnv(apiUrl: string): void {
1076
1173
  vi.stubEnv("GITHUB_REPOSITORY", repo);
1077
1174
  vi.stubEnv("GITHUB_API_URL", apiUrl);
1078
- // What a GitHub Actions or Forgejo Actions job sets from `github.token`,
1079
- // and what the issue path now actually forwards (#2320).
1175
+ // What a GitHub Actions or Forgejo Actions job sets from `github.token`.
1080
1176
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1081
1177
  vi.stubEnv("GH_TOKEN", "ghs-ambient");
1178
+ vi.stubEnv("GITHUB_TOKEN", "");
1179
+ vi.stubEnv("FORGEJO_ACTIONS", "");
1180
+ vi.stubEnv("GITEA_ACTIONS", "");
1082
1181
  // Never the GitLab path or the comment/PR path.
1083
1182
  vi.stubEnv("CI_PROJECT_ID", "");
1084
1183
  vi.stubEnv("CI_MERGE_REQUEST_IID", "");
@@ -1086,42 +1185,38 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
1086
1185
  vi.stubEnv("GITHUB_EVENT_PATH", "");
1087
1186
  }
1088
1187
 
1089
- test("POSTs a new issue carrying the marker as the body's first line, at a full URL", async () => {
1188
+ test("POSTs a new issue carrying the marker as the body's first line", async () => {
1090
1189
  stubGithubIssueEnv("http://forgejo.example/api/v1");
1091
- ghReplies = [
1092
- { match: "--paginate", stdout: "" }, // no owned issue yet
1093
- { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/9\n" },
1094
- ];
1095
1190
  try {
1096
1191
  const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "the plan" });
1097
- expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/9");
1098
-
1099
- const [list, post] = ghCalls;
1100
- expect(list.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues?state=open");
1101
- expect(list.cmd).toContain("--paginate");
1102
- expect(post.cmd).toContain("--method POST");
1103
- expect(post.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues");
1104
- expect(post.cmd).toContain(`title=Reconcile app`);
1105
- expect(post.cmd).toContain("<!-- chant-reconcile-issue:nightly/app -->\n\nthe plan");
1192
+ expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/1");
1193
+ expect(forgeCalls.map((c) => `${c.method} ${c.url}`)).toEqual([
1194
+ "GET http://forgejo.example/api/v1/repos/acme/infra/issues?state=open&limit=50&page=1",
1195
+ "POST http://forgejo.example/api/v1/repos/acme/infra/issues",
1196
+ ]);
1197
+ expect(forgeCalls[1].body).toEqual({ title: "Reconcile app: 0 change(s) from live", body: `${marker}\n\nthe plan` });
1198
+ expect(ghCalls).toHaveLength(0);
1106
1199
  } finally {
1107
1200
  vi.unstubAllEnvs();
1108
1201
  }
1109
1202
  });
1110
1203
 
1111
- test("a re-run PATCHes the issue it already owns instead of opening a second one", async () => {
1204
+ test("a re-run PATCHes the issue it already owns, title and body, instead of opening a second", async () => {
1112
1205
  stubGithubIssueEnv("http://forgejo.example/api/v1");
1113
- ghReplies = [
1114
- { match: "--paginate", stdout: "57\n" }, // the marker search found issue 57
1115
- { match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/57\n" },
1116
- ];
1206
+ forgeState.issues.push(
1207
+ { number: 1, title: "a human's issue", body: "unrelated", state: "open", pull_request: null },
1208
+ { number: 2, title: "old", body: `${marker}\n\nold plan`, state: "open", pull_request: null },
1209
+ );
1117
1210
  try {
1118
- const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "a newer plan" });
1119
- expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/57");
1120
-
1121
- const patchCall = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
1122
- const postCall = ghCalls.find((c) => c.cmd.includes("--method POST"));
1123
- expect(patchCall?.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/57");
1124
- expect(postCall).toBeUndefined();
1211
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", title: "Drift: 3", body: "a newer plan" });
1212
+ expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/2");
1213
+ expect(forgeCalls.at(-1)).toMatchObject({
1214
+ method: "PATCH",
1215
+ url: "http://forgejo.example/api/v1/repos/acme/infra/issues/2",
1216
+ body: { title: "Drift: 3", body: `${marker}\n\na newer plan` },
1217
+ });
1218
+ expect(forgeCalls.some((c) => c.method === "POST")).toBe(false);
1219
+ expect(forgeState.issues[0].body).toBe("unrelated");
1125
1220
  } finally {
1126
1221
  vi.unstubAllEnvs();
1127
1222
  }
@@ -1129,29 +1224,16 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
1129
1224
 
1130
1225
  test("searches only OPEN issues, and never mistakes a pull request for one it owns", async () => {
1131
1226
  stubGithubIssueEnv("");
1132
- ghReplies = [
1133
- { match: "--paginate", stdout: "" },
1134
- { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
1135
- ];
1136
- try {
1137
- await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1138
- const [list] = ghCalls;
1139
- expect(list.cmd).toContain("state=open");
1140
- expect(list.cmd).toContain(".pull_request == null");
1141
- } finally {
1142
- vi.unstubAllEnvs();
1143
- }
1144
- });
1145
-
1146
- test("defaults to github.com's API base when GITHUB_API_URL is unset", async () => {
1147
- stubGithubIssueEnv("");
1148
- ghReplies = [
1149
- { match: "--paginate", stdout: "" },
1150
- { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
1151
- ];
1227
+ forgeState.issues.push(
1228
+ { number: 1, title: "closed by hand", body: `${marker}\n\nfixed`, state: "closed", pull_request: null },
1229
+ { number: 2, title: "a PR quoting it", body: `${marker}\n\nquoted`, state: "open", pull_request: { url: "x" } },
1230
+ );
1152
1231
  try {
1153
- await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1154
- expect(ghCalls[0].cmd).toContain("https://api.github.com/repos/acme/infra/issues?state=open");
1232
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1233
+ expect(forgeCalls[0].url).toBe("https://api.github.com/repos/acme/infra/issues?state=open&per_page=100&page=1");
1234
+ expect(result.issueUrl).toBe("https://github.com/acme/infra/issues/3");
1235
+ expect(forgeCalls.some((c) => c.method === "PATCH")).toBe(false);
1236
+ expect(forgeState.issues[0].state).toBe("closed");
1155
1237
  } finally {
1156
1238
  vi.unstubAllEnvs();
1157
1239
  }
@@ -1170,7 +1252,8 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
1170
1252
  expect(result.issueUrl).toBe("https://github.com/acme/infra/issues/3");
1171
1253
  expect(ghCalls).toHaveLength(1);
1172
1254
  expect(ghCalls[0].cmd).toContain("gh issue create");
1173
- expect(ghCalls[0].cmd).toContain("<!-- chant-reconcile-issue:nightly/app -->\n\nplan");
1255
+ expect(ghCalls[0].cmd).toContain(`${marker}\n\nplan`);
1256
+ expect(forgeCalls).toHaveLength(0);
1174
1257
  } finally {
1175
1258
  vi.unstubAllEnvs();
1176
1259
  }
@@ -1190,6 +1273,7 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
1190
1273
  try {
1191
1274
  const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1192
1275
  expect(result.issueUrl).toBe("https://gitlab.com/api/v4/projects/42/issues/5");
1276
+ expect(calls.every((c) => c.url.startsWith("https://gitlab.com/api/v4/"))).toBe(true);
1193
1277
  expect(ghCalls).toHaveLength(0);
1194
1278
  } finally {
1195
1279
  vi.unstubAllEnvs();
@@ -1201,104 +1285,40 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
1201
1285
  // ── The two-Op proof #2319 asked for ────────────────────────────────────────
1202
1286
 
1203
1287
  describe("two Ops over one env keep two issues, end to end (#2319)", () => {
1204
- // The stub answers by substring, so it cannot evaluate a jq predicate. This
1205
- // does it instead, and does it the honest way: both halves are read back out
1206
- // of the real `gh` commands rather than restated, so the search and the
1207
- // write are proved to agree rather than assumed to.
1208
-
1209
- /** The marker a `gh api … --paginate` search is filtering on. */
1210
- function markerOf(cmd: string): string {
1211
- const m = /startswith\("(.*?)"\)/.exec(cmd);
1212
- if (!m) throw new Error(`not a marker search: ${cmd}`);
1213
- return m[1];
1214
- }
1215
-
1216
- /** The body a `-f 'body=…'` write is sending. */
1217
- function bodyOf(cmd: string): string {
1218
- const m = /-f 'body=([\s\S]*?)' --jq/.exec(cmd);
1219
- if (!m) throw new Error(`not a body write: ${cmd}`);
1220
- return m[1];
1221
- }
1222
-
1223
- test("run B does not find run A's issue, and opens its own", async () => {
1288
+ // The fake forge holds what each run wrote, so run B searches the issue run
1289
+ // A actually opened rather than a reply restated for the test.
1290
+ function stubRepo(): void {
1224
1291
  vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
1225
1292
  vi.stubEnv("GITHUB_API_URL", "");
1226
1293
  vi.stubEnv("CI_PROJECT_ID", "");
1227
1294
  vi.stubEnv("CI_MERGE_REQUEST_IID", "");
1228
1295
  vi.stubEnv("GITHUB_REF", "");
1229
1296
  vi.stubEnv("GITHUB_EVENT_PATH", "");
1230
- // What the issue path now forwards to `gh` (#2320).
1231
1297
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1232
1298
  vi.stubEnv("GH_TOKEN", "ghs-ambient");
1299
+ }
1300
+
1301
+ test("run B does not find run A's issue and opens its own; A's next run edits A's", async () => {
1302
+ stubRepo();
1233
1303
  try {
1234
- // ── Run A: the stock drift watch over root "app". Nothing owned yet.
1235
- ghReplies = [
1236
- { match: "--paginate", stdout: "" },
1237
- { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
1238
- ];
1304
+ // Run A: the stock drift watch over root "app". Nothing owned yet.
1239
1305
  const a = await reconcilePr({ env: "app", op: "app-drift", mode: "issue", body: "finding from A" });
1240
1306
  expect(a.issueUrl).toBe("https://github.com/acme/infra/issues/1");
1241
- const aSearch = ghCalls.find((c) => c.cmd.includes("--paginate"))!.cmd;
1242
- // What the repository now holds as issue #1.
1243
- const aBody = bodyOf(ghCalls.find((c) => c.cmd.includes("--method POST"))!.cmd);
1244
- expect(aBody).toContain("finding from A");
1245
-
1246
- // ── Run B: the `live: true` watch over the same root. Same `env`, the
1247
- // pairing #2319 reports. The repository already holds A's issue.
1248
- ghCalls.length = 0;
1249
- ghReplies = [
1250
- { match: "--paginate", stdout: "" }, // justified two assertions down
1251
- { match: "--method POST", stdout: "https://github.com/acme/infra/issues/2\n" },
1252
- ];
1253
- const b = await reconcilePr({
1254
- env: "app",
1255
- op: "app-drift-live",
1256
- mode: "issue",
1257
- body: "finding from B",
1258
- });
1259
- const bSearch = ghCalls.find((c) => c.cmd.includes("--paginate"))!.cmd;
1260
1307
 
1261
- // The forge's answer to B's search, computed rather than stubbed: B's
1262
- // own `startswith` predicate, run against the body A actually wrote.
1263
- // Before #2319 this was true, and B went on to PATCH A's issue.
1264
- expect(aBody.startsWith(markerOf(bSearch))).toBe(false);
1265
-
1266
- // So "not found" is the right stub, and B opens its own issue.
1267
- expect(ghCalls.some((c) => c.cmd.includes("--method PATCH"))).toBe(false);
1308
+ // Run B: the `live: true` watch over the same root. Same `env`, the
1309
+ // pairing #2319 reports. Before #2319 B's marker matched A's issue and
1310
+ // B rewrote it.
1311
+ const b = await reconcilePr({ env: "app", op: "app-drift-live", mode: "issue", body: "finding from B" });
1268
1312
  expect(b.issueUrl).toBe("https://github.com/acme/infra/issues/2");
1269
- const bBody = bodyOf(ghCalls.find((c) => c.cmd.includes("--method POST"))!.cmd);
1270
- expect(bBody).toContain("finding from B");
1271
- expect(bBody).not.toContain("finding from A");
1272
-
1273
- // The converse, so this is stickiness preserved and not just two
1274
- // strangers: A's own search still matches the issue A wrote.
1275
- expect(aBody.startsWith(markerOf(aSearch))).toBe(true);
1276
- expect(markerOf(aSearch)).not.toBe(markerOf(bSearch));
1277
- } finally {
1278
- vi.unstubAllEnvs();
1279
- }
1280
- });
1313
+ expect(forgeState.issues[0].body).toContain("finding from A");
1314
+ expect(forgeState.issues[0].body).not.toContain("finding from B");
1281
1315
 
1282
- test("A's second run still edits A's issue rather than opening another", async () => {
1283
- vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
1284
- vi.stubEnv("GITHUB_API_URL", "");
1285
- vi.stubEnv("CI_PROJECT_ID", "");
1286
- vi.stubEnv("CI_MERGE_REQUEST_IID", "");
1287
- vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1288
- vi.stubEnv("GH_TOKEN", "ghs-ambient");
1289
- ghReplies = [
1290
- { match: "--paginate", stdout: "1\n" }, // A's marker found A's issue
1291
- { match: "--method PATCH", stdout: "https://github.com/acme/infra/issues/1\n" },
1292
- ];
1293
- try {
1294
- const a2 = await reconcilePr({
1295
- env: "app",
1296
- op: "app-drift",
1297
- mode: "issue",
1298
- body: "finding from A, later",
1299
- });
1316
+ // A again: stickiness preserved, not just two strangers.
1317
+ const a2 = await reconcilePr({ env: "app", op: "app-drift", mode: "issue", body: "finding from A, later" });
1300
1318
  expect(a2.issueUrl).toBe("https://github.com/acme/infra/issues/1");
1301
- expect(ghCalls.some((c) => c.cmd.includes("--method POST"))).toBe(false);
1319
+ expect(forgeState.issues).toHaveLength(2);
1320
+ expect(forgeState.issues[0].body).toContain("finding from A, later");
1321
+ expect(forgeState.issues[1].body).toContain("finding from B");
1302
1322
  } finally {
1303
1323
  vi.unstubAllEnvs();
1304
1324
  }
@@ -1320,57 +1340,15 @@ describe("reconcilePr issue mode sends the token it resolved (#2320)", () => {
1320
1340
  // The case `noCommentTokenMessage` was written for, on the mode that never
1321
1341
  // sent it: a run posting to a Forgejo instance other than the one the job
1322
1342
  // executes on, where the job's own `github.token` stops at its own host.
1323
- test("CHANT_FORGEJO_TOKEN reaches gh as GH_TOKEN, outranking the ambient one", async () => {
1343
+ test("CHANT_FORGEJO_TOKEN is the one sent, on every call, outranking the ambient one", async () => {
1324
1344
  stubForgejoIssueEnv();
1325
1345
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
1326
1346
  vi.stubEnv("GH_TOKEN", "ghs-this-instance-only");
1327
- ghReplies = [
1328
- { match: "--paginate", stdout: "" },
1329
- { match: "--method POST", stdout: "https://other.forgejo.example/acme/infra/issues/1\n" },
1330
- ];
1331
- try {
1332
- await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1333
- expect(ghCalls).toHaveLength(2);
1334
- for (const call of ghCalls) {
1335
- expect(call.opts.env?.GH_TOKEN).toBe("forgejo-cross-instance");
1336
- }
1337
- } finally {
1338
- vi.unstubAllEnvs();
1339
- }
1340
- });
1341
-
1342
- test("the PATCH branch forwards it too, not only the search and the POST", async () => {
1343
- stubForgejoIssueEnv();
1344
- vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
1345
- vi.stubEnv("GH_TOKEN", "");
1346
- ghReplies = [
1347
- { match: "--paginate", stdout: "57\n" },
1348
- { match: "--method PATCH", stdout: "https://other.forgejo.example/acme/infra/issues/57\n" },
1349
- ];
1347
+ forgeState.issues.push({ number: 1, title: "t", body: "<!-- chant-reconcile-issue:nightly/app -->\n\nold", state: "open" });
1350
1348
  try {
1351
1349
  await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1352
- const patch = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
1353
- expect(patch?.opts.env?.GH_TOKEN).toBe("forgejo-cross-instance");
1354
- } finally {
1355
- vi.unstubAllEnvs();
1356
- }
1357
- });
1358
-
1359
- // #2333: the same credential hole comment mode had. Issue mode's writes
1360
- // were already refused by the forgejo generator for exactly this reason.
1361
- test("every issue-mode `gh` call carries GH_ENTERPRISE_TOKEN as well (#2333)", async () => {
1362
- stubForgejoIssueEnv();
1363
- vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
1364
- ghReplies = [
1365
- { match: "--paginate", stdout: "" },
1366
- { match: "--method POST", stdout: "https://other.forgejo.example/acme/infra/issues/1\n" },
1367
- ];
1368
- try {
1369
- await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1370
- expect(ghCalls).toHaveLength(2);
1371
- for (const call of ghCalls) {
1372
- expect(call.opts.env?.GH_ENTERPRISE_TOKEN).toBe("forgejo-cross-instance");
1373
- }
1350
+ expect(forgeCalls.map((c) => c.method)).toEqual(["GET", "PATCH"]);
1351
+ for (const call of forgeCalls) expect(call.authorization).toBe("token forgejo-cross-instance");
1374
1352
  } finally {
1375
1353
  vi.unstubAllEnvs();
1376
1354
  }
@@ -1381,26 +1359,22 @@ describe("reconcilePr issue mode sends the token it resolved (#2320)", () => {
1381
1359
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1382
1360
  vi.stubEnv("GH_TOKEN", "");
1383
1361
  vi.stubEnv("GITHUB_TOKEN", "ghs-workflow");
1384
- ghReplies = [
1385
- { match: "--paginate", stdout: "" },
1386
- { match: "--method POST", stdout: "https://other.forgejo.example/acme/infra/issues/1\n" },
1387
- ];
1388
1362
  try {
1389
1363
  await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1390
- expect(ghCalls[0].opts.env?.GH_TOKEN).toBe("ghs-workflow");
1364
+ expect(forgeCalls[0].authorization).toBe("token ghs-workflow");
1391
1365
  } finally {
1392
1366
  vi.unstubAllEnvs();
1393
1367
  }
1394
1368
  });
1395
1369
 
1396
- test("no token at all is chant's named refusal, not gh's opaque one", async () => {
1370
+ test("no token at all is chant's named refusal, before any call", async () => {
1397
1371
  stubForgejoIssueEnv();
1398
1372
  for (const k of ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) vi.stubEnv(k, "");
1399
1373
  try {
1400
1374
  await expect(
1401
1375
  reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" }),
1402
1376
  ).rejects.toThrow(/mode "issue".*acme\/infra.*CHANT_FORGEJO_TOKEN/s);
1403
- // Refused before the shell-out, so gh is never asked to guess.
1377
+ expect(forgeCalls).toHaveLength(0);
1404
1378
  expect(ghCalls).toHaveLength(0);
1405
1379
  } finally {
1406
1380
  vi.unstubAllEnvs();
@@ -1493,3 +1467,33 @@ describe("postOrUpdateGithubIssue directly (#2297)", () => {
1493
1467
  }
1494
1468
  });
1495
1469
  });
1470
+
1471
+ describe("reconcilePr pull-request mode tells gh the forge's host (#3780)", () => {
1472
+ afterEach(() => {
1473
+ vi.unstubAllEnvs();
1474
+ });
1475
+
1476
+ test("on GitHub Enterprise Server, gh pr create runs with GH_HOST and the enterprise token", async () => {
1477
+ vi.stubEnv("GITHUB_SERVER_URL", "https://ghe.acme.internal");
1478
+ vi.stubEnv("GH_TOKEN", "job-token");
1479
+ vi.stubEnv("GH_HOST", "");
1480
+ vi.stubEnv("GH_ENTERPRISE_TOKEN", "");
1481
+ vi.stubEnv("GITHUB_ENTERPRISE_TOKEN", "");
1482
+ ghReplies = [{ match: "gh pr create", stdout: "https://ghe.acme.internal/acme/infra/pull/9\n" }];
1483
+ const result = await reconcilePr({ env: "prod", entries, mode: "pull-request" });
1484
+ expect(result.prUrl).toBe("https://ghe.acme.internal/acme/infra/pull/9");
1485
+ const create = ghCalls.find((c) => c.cmd.startsWith("gh pr create"))!;
1486
+ expect(create.opts.env?.GH_HOST).toBe("ghe.acme.internal");
1487
+ expect(create.opts.env?.GH_ENTERPRISE_TOKEN).toBe("job-token");
1488
+ // git keeps the process environment.
1489
+ expect(ghCalls.find((c) => c.cmd.startsWith("git push"))!.opts.env).toBeUndefined();
1490
+ });
1491
+
1492
+ test("on github.com, gh pr create gets no GH_HOST", async () => {
1493
+ vi.stubEnv("GITHUB_SERVER_URL", "https://github.com");
1494
+ vi.stubEnv("GH_HOST", "");
1495
+ await reconcilePr({ env: "prod", entries, mode: "pull-request" });
1496
+ const create = ghCalls.find((c) => c.cmd.startsWith("gh pr create"))!;
1497
+ expect(create.opts.env?.GH_HOST ?? "").toBe("");
1498
+ });
1499
+ });