@intentius/chant 0.61.0 → 0.62.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 (35) hide show
  1. package/dist/cli/handlers/operator.d.ts +0 -18
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/lexicon.d.ts +51 -0
  4. package/dist/lexicon.d.ts.map +1 -1
  5. package/dist/lifecycle/git.d.ts +117 -0
  6. package/dist/lifecycle/git.d.ts.map +1 -1
  7. package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
  8. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  9. package/dist/op/activities/reconcile.d.ts +225 -14
  10. package/dist/op/activities/reconcile.d.ts.map +1 -1
  11. package/dist/op/gate.d.ts.map +1 -1
  12. package/dist/op/local-executor.d.ts +17 -2
  13. package/dist/op/local-executor.d.ts.map +1 -1
  14. package/dist/op/operator.d.ts.map +1 -1
  15. package/dist/op/runtimes/local.d.ts.map +1 -1
  16. package/dist/runtime-adapter.d.ts +8 -0
  17. package/dist/runtime-adapter.d.ts.map +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/handlers/operator.test.ts +102 -1
  20. package/src/cli/handlers/operator.ts +75 -5
  21. package/src/lexicon.ts +51 -0
  22. package/src/lifecycle/git.test.ts +49 -5
  23. package/src/lifecycle/git.ts +312 -11
  24. package/src/op/activities/lexicon-upgrade.test.ts +122 -39
  25. package/src/op/activities/lexicon-upgrade.ts +55 -9
  26. package/src/op/activities/reconcile.test.ts +527 -1
  27. package/src/op/activities/reconcile.ts +446 -23
  28. package/src/op/gate.test.ts +504 -0
  29. package/src/op/gate.ts +9 -1
  30. package/src/op/local-executor.test.ts +115 -0
  31. package/src/op/local-executor.ts +130 -21
  32. package/src/op/operator.test.ts +20 -0
  33. package/src/op/operator.ts +39 -1
  34. package/src/op/runtimes/local.ts +11 -0
  35. package/src/runtime-adapter.ts +17 -3
@@ -1,15 +1,58 @@
1
- import { describe, test, expect, vi } from "vitest";
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
2
  import {
3
3
  reconcilePr,
4
4
  reconcileSummary,
5
5
  reconcileBranchName,
6
6
  entriesFromPlan,
7
7
  commentMarker,
8
+ issueMarker,
8
9
  pullRequestContextFrom,
9
10
  mergeRequestContextFrom,
10
11
  gitlabNoteTokenFrom,
12
+ gitlabProjectContextFrom,
13
+ githubApiBaseFrom,
14
+ commentTokenFrom,
15
+ noCommentTokenMessage,
16
+ postOrUpdateGithubIssue,
11
17
  } from "./reconcile";
12
18
 
19
+ // ── The `gh` stub (chant #2291) ──────────────────────────────────────────────
20
+ //
21
+ // Same recipe as `lexicons/terraform/src/op/activities/terraform.test.ts` and
22
+ // `lexicons/k3s/src/op/activities/k3s.test.ts`: `node:child_process`'s `exec`
23
+ // carries a `nodejs.util.promisify.custom` implementation, which is what
24
+ // `promisify(exec)` picks up at module load, so every `gh` invocation
25
+ // `postOrUpdateComment` makes lands in `ghCalls` with its exact command
26
+ // string and options — including the `env` it was given, so a test can prove
27
+ // which token actually reached `gh`.
28
+
29
+ interface GhCall {
30
+ cmd: string;
31
+ opts: { signal?: AbortSignal; env?: Record<string, string | undefined> };
32
+ }
33
+
34
+ const ghCalls: GhCall[] = [];
35
+ /** cmd substring -> stdout to answer with. First match wins. */
36
+ let ghReplies: Array<{ match: string; stdout: string }> = [];
37
+
38
+ vi.mock("node:child_process", async (importOriginal) => {
39
+ const custom = Symbol.for("nodejs.util.promisify.custom");
40
+ const exec = ((_cmd: string, _opts: unknown, cb?: (...a: unknown[]) => void) => {
41
+ cb?.(new Error("unmocked exec path"));
42
+ }) as unknown as Record<symbol, unknown>;
43
+ exec[custom] = async (cmd: string, opts?: GhCall["opts"]) => {
44
+ ghCalls.push({ cmd, opts: opts ?? {} });
45
+ const hit = ghReplies.find(({ match }) => cmd.includes(match));
46
+ return { stdout: hit?.stdout ?? "", stderr: "" };
47
+ };
48
+ return { ...(await importOriginal<typeof import("node:child_process")>()), exec };
49
+ });
50
+
51
+ beforeEach(() => {
52
+ ghCalls.length = 0;
53
+ ghReplies = [];
54
+ });
55
+
13
56
  const entries = [
14
57
  { name: "bucket", action: "adopt", type: "AWS::S3::Bucket" },
15
58
  { name: "queue", action: "update", type: "AWS::SQS::Queue" },
@@ -162,6 +205,154 @@ describe("reconcilePr comment mode refuses a run with no pull request (#2231)",
162
205
  });
163
206
  });
164
207
 
208
+ // ── The GitHub/Forgejo comment, via `gh` at a full URL (#2291) ──────────────
209
+
210
+ describe("githubApiBaseFrom (#2291)", () => {
211
+ test("defaults to github.com's own API host when GITHUB_API_URL is unset", () => {
212
+ expect(githubApiBaseFrom({})).toBe("https://api.github.com");
213
+ expect(githubApiBaseFrom({ GITHUB_API_URL: "" })).toBe("https://api.github.com");
214
+ });
215
+
216
+ test("reads a Forgejo instance's own /api/v1 base, trailing slash stripped", () => {
217
+ expect(githubApiBaseFrom({ GITHUB_API_URL: "http://forgejo:3000/api/v1/" })).toBe(
218
+ "http://forgejo:3000/api/v1",
219
+ );
220
+ });
221
+
222
+ test("reads a GitHub Enterprise Server base unchanged", () => {
223
+ expect(githubApiBaseFrom({ GITHUB_API_URL: "https://ghe.example.com/api/v3" })).toBe(
224
+ "https://ghe.example.com/api/v3",
225
+ );
226
+ });
227
+ });
228
+
229
+ describe("commentTokenFrom (#2291)", () => {
230
+ test("GH_TOKEN — what the generated workflow sets from github.token — resolves out of the box", () => {
231
+ expect(commentTokenFrom({ GH_TOKEN: "ghs_x" })).toEqual({ value: "ghs_x", source: "GH_TOKEN" });
232
+ });
233
+
234
+ test("falls back to GITHUB_TOKEN when GH_TOKEN is unset", () => {
235
+ expect(commentTokenFrom({ GITHUB_TOKEN: "ghs_y" })).toEqual({ value: "ghs_y", source: "GITHUB_TOKEN" });
236
+ });
237
+
238
+ test("CHANT_FORGEJO_TOKEN wins over both, for the cross-instance case", () => {
239
+ expect(
240
+ commentTokenFrom({ CHANT_FORGEJO_TOKEN: "a", GH_TOKEN: "b", GITHUB_TOKEN: "c" })?.source,
241
+ ).toBe("CHANT_FORGEJO_TOKEN");
242
+ });
243
+
244
+ test("no token at all is undefined rather than an empty value", () => {
245
+ expect(commentTokenFrom({})).toBeUndefined();
246
+ expect(commentTokenFrom({ GH_TOKEN: "", GITHUB_TOKEN: "" })).toBeUndefined();
247
+ });
248
+ });
249
+
250
+ describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () => {
251
+ const repo = "acme/infra";
252
+
253
+ function stubPrEnv(apiUrl: string): void {
254
+ vi.stubEnv("GITHUB_REPOSITORY", repo);
255
+ vi.stubEnv("GITHUB_REF", "refs/pull/5/merge");
256
+ vi.stubEnv("GITHUB_EVENT_PATH", "");
257
+ vi.stubEnv("GITHUB_API_URL", apiUrl);
258
+ vi.stubEnv("GH_TOKEN", "forgejo-actions-token");
259
+ vi.stubEnv("GITHUB_TOKEN", "");
260
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
261
+ // Never the GitLab path: a GitHub/Forgejo run carries no merge request.
262
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
263
+ }
264
+
265
+ test("POSTs a new comment at the Forgejo instance's own /api/v1 base, not a bare path", async () => {
266
+ stubPrEnv("http://forgejo.example/api/v1");
267
+ ghReplies = [
268
+ { match: "--paginate", stdout: "" }, // no owned comment yet
269
+ { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
270
+ ];
271
+ try {
272
+ const result = await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
273
+ expect(result.commentUrl).toBe("http://forgejo.example/acme/infra/issues/5#issuecomment-1");
274
+ expect(result.pullRequest).toBe("acme/infra#5");
275
+
276
+ const [list, post] = ghCalls;
277
+ // The bug #2291 fixed: this used to be the bare path
278
+ // `repos/acme/infra/issues/5/comments`, which `gh` resolves against
279
+ // `/api/v3` for any non-github.com host — 404 on Forgejo. It is now the
280
+ // full URL built from GITHUB_API_URL.
281
+ expect(list.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/5/comments");
282
+ expect(list.cmd).not.toMatch(/gh api 'repos\//);
283
+ expect(post.cmd).toContain("--method POST");
284
+ expect(post.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/5/comments");
285
+ } finally {
286
+ vi.unstubAllEnvs();
287
+ }
288
+ });
289
+
290
+ test("a re-run PATCHes the comment it already owns instead of posting a second", async () => {
291
+ stubPrEnv("http://forgejo.example/api/v1");
292
+ ghReplies = [
293
+ { match: "--paginate", stdout: "42\n" }, // the marker search found comment 42
294
+ { match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-42\n" },
295
+ ];
296
+ try {
297
+ const result = await reconcilePr({ env: "app", mode: "comment", body: "a newer plan" });
298
+ expect(result.commentUrl).toBe("http://forgejo.example/acme/infra/issues/5#issuecomment-42");
299
+
300
+ const patchCall = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
301
+ const postCall = ghCalls.find((c) => c.cmd.includes("--method POST"));
302
+ expect(patchCall?.cmd).toContain(
303
+ "http://forgejo.example/api/v1/repos/acme/infra/issues/comments/42",
304
+ );
305
+ expect(postCall).toBeUndefined();
306
+ } finally {
307
+ vi.unstubAllEnvs();
308
+ }
309
+ });
310
+
311
+ test("defaults to github.com's API base when GITHUB_API_URL is unset (plain GitHub run)", async () => {
312
+ stubPrEnv("");
313
+ ghReplies = [
314
+ { match: "--paginate", stdout: "" },
315
+ { match: "--method POST", stdout: "https://github.com/acme/infra/issues/5#issuecomment-1\n" },
316
+ ];
317
+ try {
318
+ await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
319
+ expect(ghCalls[0].cmd).toContain("https://api.github.com/repos/acme/infra/issues/5/comments");
320
+ } finally {
321
+ vi.unstubAllEnvs();
322
+ }
323
+ });
324
+
325
+ test("forwards the resolved token to `gh` as GH_TOKEN, CHANT_FORGEJO_TOKEN taking priority", async () => {
326
+ stubPrEnv("http://forgejo.example/api/v1");
327
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
328
+ ghReplies = [
329
+ { match: "--paginate", stdout: "" },
330
+ { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
331
+ ];
332
+ try {
333
+ await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
334
+ for (const call of ghCalls) expect(call.opts.env?.GH_TOKEN).toBe("cross-instance-token");
335
+ } finally {
336
+ vi.unstubAllEnvs();
337
+ }
338
+ });
339
+
340
+ test("a pull request with no token at all is refused by name, before any `gh` call", async () => {
341
+ stubPrEnv("http://forgejo.example/api/v1");
342
+ vi.stubEnv("GH_TOKEN", "");
343
+ vi.stubEnv("GITHUB_TOKEN", "");
344
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
345
+ try {
346
+ await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
347
+ noCommentTokenMessage(repo, 5),
348
+ );
349
+ expect(ghCalls).toHaveLength(0);
350
+ } finally {
351
+ vi.unstubAllEnvs();
352
+ }
353
+ });
354
+ });
355
+
165
356
  // ── The GitLab merge-request note (#2256) ───────────────────────────────────
166
357
 
167
358
  describe("mergeRequestContextFrom (#2256)", () => {
@@ -397,3 +588,338 @@ describe("the no-context refusal names both forges' variables (#2256)", () => {
397
588
  }
398
589
  });
399
590
  });
591
+
592
+ // ── The GitLab issue (#2292) ─────────────────────────────────────────────
593
+
594
+ describe("issueMarker (#2292)", () => {
595
+ test("deterministic per env, and distinct from the comment-mode marker for the same env", () => {
596
+ expect(issueMarker("app")).toBe("<!-- chant-reconcile-issue:app -->");
597
+ expect(issueMarker("app")).toBe(issueMarker("app"));
598
+ expect(issueMarker("app")).not.toBe(commentMarker("app"));
599
+ });
600
+
601
+ test("slugifies the env the same way commentMarker does", () => {
602
+ const marker = issueMarker('us-east/1" or true; #');
603
+ expect(marker).toBe("<!-- chant-reconcile-issue:us-east-1-or-true- -->");
604
+ expect(marker).not.toMatch(/["'\\]/);
605
+ });
606
+ });
607
+
608
+ describe("gitlabProjectContextFrom (#2292)", () => {
609
+ const gitlabEnv = {
610
+ CI_API_V4_URL: "https://gitlab.com/api/v4",
611
+ CI_PROJECT_ID: "42",
612
+ CI_PROJECT_PATH: "acme/infra",
613
+ CI_PROJECT_URL: "https://gitlab.com/acme/infra",
614
+ };
615
+
616
+ test("reads the project off any GitLab CI job, with no merge request in sight", () => {
617
+ expect(gitlabProjectContextFrom(gitlabEnv)).toEqual({
618
+ api: "https://gitlab.com/api/v4",
619
+ project: "42",
620
+ path: "acme/infra",
621
+ webUrl: "https://gitlab.com/acme/infra",
622
+ });
623
+ });
624
+
625
+ test("derives the API base from CI_SERVER_URL when CI_API_V4_URL is unset", () => {
626
+ const { CI_API_V4_URL: _drop, ...rest } = gitlabEnv;
627
+ expect(gitlabProjectContextFrom({ ...rest, CI_SERVER_URL: "https://gl.example.com" })?.api).toBe(
628
+ "https://gl.example.com/api/v4",
629
+ );
630
+ });
631
+
632
+ test("a GitHub Actions run is not mistaken for a GitLab one", () => {
633
+ expect(
634
+ gitlabProjectContextFrom({ GITHUB_REPOSITORY: "INTENTIUS/chant", GITHUB_REF: "refs/pull/1/merge" }),
635
+ ).toBeUndefined();
636
+ });
637
+
638
+ test("no CI_PROJECT_ID at all is undefined", () => {
639
+ expect(gitlabProjectContextFrom({})).toBeUndefined();
640
+ expect(gitlabProjectContextFrom({ CI_PROJECT_ID: "" })).toBeUndefined();
641
+ });
642
+ });
643
+
644
+ describe("reconcilePr issue mode on GitLab opens/updates one issue (#2292)", () => {
645
+ const gitlabEnv: Record<string, string> = {
646
+ CI_API_V4_URL: "https://gitlab.com/api/v4",
647
+ CI_PROJECT_ID: "42",
648
+ CI_PROJECT_PATH: "acme/infra",
649
+ CI_PROJECT_URL: "https://gitlab.com/acme/infra",
650
+ GITLAB_TOKEN: "glpat-x",
651
+ };
652
+
653
+ function stubGitlabEnv(): void {
654
+ for (const [k, v] of Object.entries(gitlabEnv)) vi.stubEnv(k, v);
655
+ // Neither GitHub's variables nor a merge-request iid are set on a plain
656
+ // GitLab CI job (cron or push); make that explicit so the GitHub path and
657
+ // the note path can never be the ones under test here.
658
+ for (const k of ["GITHUB_REPOSITORY", "GITHUB_REF", "GITHUB_EVENT_PATH", "CI_MERGE_REQUEST_IID"]) {
659
+ vi.stubEnv(k, "");
660
+ }
661
+ }
662
+
663
+ test("POSTs a new issue carrying the marker as the description's first line", async () => {
664
+ stubGitlabEnv();
665
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
666
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
667
+ calls.push({ url, init });
668
+ if (!init?.method || init.method === "GET") return gitlabResponse([]);
669
+ return gitlabResponse({ iid: 9 });
670
+ });
671
+ try {
672
+ const result = await reconcilePr({ env: "app", mode: "issue", body: "the plan" });
673
+ expect(result.issueUrl).toBe("https://gitlab.com/acme/infra/-/issues/9");
674
+ const post = calls[calls.length - 1];
675
+ expect(post.url).toBe("https://gitlab.com/api/v4/projects/42/issues");
676
+ expect(post.init?.method).toBe("POST");
677
+ expect((post.init?.headers as Record<string, string>)["PRIVATE-TOKEN"]).toBe("glpat-x");
678
+ const sent = JSON.parse(String(post.init?.body));
679
+ expect(sent.description).toBe("<!-- chant-reconcile-issue:app -->\n\nthe plan");
680
+ expect(sent.title).toContain("Reconcile app");
681
+ } finally {
682
+ vi.unstubAllEnvs();
683
+ vi.unstubAllGlobals();
684
+ }
685
+ });
686
+
687
+ test("PUTs the issue it already owns instead of opening a second one", async () => {
688
+ stubGitlabEnv();
689
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
690
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
691
+ calls.push({ url, init });
692
+ if (!init?.method || init.method === "GET") {
693
+ return gitlabResponse([
694
+ { iid: 1, description: "unrelated issue" },
695
+ { iid: 4, description: "<!-- chant-reconcile-issue:app -->\n\nan older finding" },
696
+ ]);
697
+ }
698
+ return gitlabResponse({ iid: 4 });
699
+ });
700
+ try {
701
+ const result = await reconcilePr({ env: "app", mode: "issue", body: "a newer finding" });
702
+ const write = calls[calls.length - 1];
703
+ expect(write.init?.method).toBe("PUT");
704
+ expect(write.url).toBe("https://gitlab.com/api/v4/projects/42/issues/4");
705
+ expect(result.issueUrl).toBe("https://gitlab.com/acme/infra/-/issues/4");
706
+ } finally {
707
+ vi.unstubAllEnvs();
708
+ vi.unstubAllGlobals();
709
+ }
710
+ });
711
+
712
+ test("searches narrowly by marker rather than paging every issue the project has", async () => {
713
+ stubGitlabEnv();
714
+ const seen: string[] = [];
715
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
716
+ if (!init?.method || init.method === "GET") {
717
+ seen.push(url);
718
+ return gitlabResponse([]);
719
+ }
720
+ return gitlabResponse({ iid: 9 });
721
+ });
722
+ try {
723
+ await reconcilePr({ env: "app", mode: "issue", body: "new" });
724
+ expect(seen).toHaveLength(1);
725
+ expect(seen[0]).toContain(`search=${encodeURIComponent(issueMarker("app"))}`);
726
+ expect(seen[0]).toContain("in=description");
727
+ } finally {
728
+ vi.unstubAllEnvs();
729
+ vi.unstubAllGlobals();
730
+ }
731
+ });
732
+
733
+ test("a rejected write fails the step by name, carrying GitLab's own status", async () => {
734
+ stubGitlabEnv();
735
+ vi.stubGlobal("fetch", async (_url: string, init?: RequestInit) => {
736
+ if (!init?.method || init.method === "GET") return gitlabResponse([]);
737
+ return {
738
+ ok: false,
739
+ status: 403,
740
+ headers: { get: () => null },
741
+ json: async () => ({}),
742
+ text: async () => '{"message":"403 Forbidden"}',
743
+ } as unknown as Response;
744
+ });
745
+ try {
746
+ await expect(reconcilePr({ env: "app", mode: "issue", body: "plan" })).rejects.toThrow(
747
+ /projects\/42\/issues.*403.*403 Forbidden/s,
748
+ );
749
+ } finally {
750
+ vi.unstubAllEnvs();
751
+ vi.unstubAllGlobals();
752
+ }
753
+ });
754
+
755
+ test("a GitLab run with no token names the mode, the project, and every variable it looked for", async () => {
756
+ for (const [k, v] of Object.entries(gitlabEnv)) vi.stubEnv(k, k === "GITLAB_TOKEN" ? "" : v);
757
+ vi.stubEnv("CI_JOB_TOKEN", "");
758
+ vi.stubEnv("CHANT_GITLAB_TOKEN", "");
759
+ for (const k of ["GITHUB_REPOSITORY", "GITHUB_REF", "GITHUB_EVENT_PATH", "CI_MERGE_REQUEST_IID"]) {
760
+ vi.stubEnv(k, "");
761
+ }
762
+ try {
763
+ await expect(reconcilePr({ env: "app", mode: "issue", body: "plan" })).rejects.toThrow(
764
+ /mode "issue".*project 42.*GITLAB_TOKEN.*CI_JOB_TOKEN/s,
765
+ );
766
+ } finally {
767
+ vi.unstubAllEnvs();
768
+ }
769
+ });
770
+ });
771
+
772
+ // ── The GitHub/GHES/Forgejo sticky issue (#2297) ────────────────────────────
773
+
774
+ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue (#2297)", () => {
775
+ const repo = "acme/infra";
776
+
777
+ function stubGithubIssueEnv(apiUrl: string): void {
778
+ vi.stubEnv("GITHUB_REPOSITORY", repo);
779
+ vi.stubEnv("GITHUB_API_URL", apiUrl);
780
+ // Never the GitLab path or the comment/PR path.
781
+ vi.stubEnv("CI_PROJECT_ID", "");
782
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
783
+ vi.stubEnv("GITHUB_REF", "");
784
+ vi.stubEnv("GITHUB_EVENT_PATH", "");
785
+ }
786
+
787
+ test("POSTs a new issue carrying the marker as the body's first line, at a full URL", async () => {
788
+ stubGithubIssueEnv("http://forgejo.example/api/v1");
789
+ ghReplies = [
790
+ { match: "--paginate", stdout: "" }, // no owned issue yet
791
+ { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/9\n" },
792
+ ];
793
+ try {
794
+ const result = await reconcilePr({ env: "app", mode: "issue", body: "the plan" });
795
+ expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/9");
796
+
797
+ const [list, post] = ghCalls;
798
+ expect(list.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues?state=open");
799
+ expect(list.cmd).toContain("--paginate");
800
+ expect(post.cmd).toContain("--method POST");
801
+ expect(post.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues");
802
+ expect(post.cmd).toContain(`title=Reconcile app`);
803
+ expect(post.cmd).toContain("<!-- chant-reconcile-issue:app -->\n\nthe plan");
804
+ } finally {
805
+ vi.unstubAllEnvs();
806
+ }
807
+ });
808
+
809
+ test("a re-run PATCHes the issue it already owns instead of opening a second one", async () => {
810
+ stubGithubIssueEnv("http://forgejo.example/api/v1");
811
+ ghReplies = [
812
+ { match: "--paginate", stdout: "57\n" }, // the marker search found issue 57
813
+ { match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/57\n" },
814
+ ];
815
+ try {
816
+ const result = await reconcilePr({ env: "app", mode: "issue", body: "a newer plan" });
817
+ expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/57");
818
+
819
+ const patchCall = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
820
+ const postCall = ghCalls.find((c) => c.cmd.includes("--method POST"));
821
+ expect(patchCall?.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/57");
822
+ expect(postCall).toBeUndefined();
823
+ } finally {
824
+ vi.unstubAllEnvs();
825
+ }
826
+ });
827
+
828
+ test("searches only OPEN issues, and never mistakes a pull request for one it owns", async () => {
829
+ stubGithubIssueEnv("");
830
+ ghReplies = [
831
+ { match: "--paginate", stdout: "" },
832
+ { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
833
+ ];
834
+ try {
835
+ await reconcilePr({ env: "app", mode: "issue", body: "plan" });
836
+ const [list] = ghCalls;
837
+ expect(list.cmd).toContain("state=open");
838
+ expect(list.cmd).toContain(".pull_request == null");
839
+ } finally {
840
+ vi.unstubAllEnvs();
841
+ }
842
+ });
843
+
844
+ test("defaults to github.com's API base when GITHUB_API_URL is unset", async () => {
845
+ stubGithubIssueEnv("");
846
+ ghReplies = [
847
+ { match: "--paginate", stdout: "" },
848
+ { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
849
+ ];
850
+ try {
851
+ await reconcilePr({ env: "app", mode: "issue", body: "plan" });
852
+ expect(ghCalls[0].cmd).toContain("https://api.github.com/repos/acme/infra/issues?state=open");
853
+ } finally {
854
+ vi.unstubAllEnvs();
855
+ }
856
+ });
857
+
858
+ test("falls back to gh issue create, still marker-prefixed, outside any known CI job", async () => {
859
+ // Neither GITHUB_REPOSITORY nor CI_PROJECT_ID: a local or manual run.
860
+ vi.stubEnv("GITHUB_REPOSITORY", "");
861
+ vi.stubEnv("CI_PROJECT_ID", "");
862
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
863
+ vi.stubEnv("GITHUB_REF", "");
864
+ vi.stubEnv("GITHUB_EVENT_PATH", "");
865
+ ghReplies = [{ match: "gh issue create", stdout: "https://github.com/acme/infra/issues/3\n" }];
866
+ try {
867
+ const result = await reconcilePr({ env: "app", mode: "issue", body: "plan" });
868
+ expect(result.issueUrl).toBe("https://github.com/acme/infra/issues/3");
869
+ expect(ghCalls).toHaveLength(1);
870
+ expect(ghCalls[0].cmd).toContain("gh issue create");
871
+ expect(ghCalls[0].cmd).toContain("<!-- chant-reconcile-issue:app -->\n\nplan");
872
+ } finally {
873
+ vi.unstubAllEnvs();
874
+ }
875
+ });
876
+
877
+ test("GitLab takes priority when both a GitLab and a GitHub signal are somehow present", async () => {
878
+ stubGithubIssueEnv("");
879
+ vi.stubEnv("CI_PROJECT_ID", "42");
880
+ vi.stubEnv("CI_API_V4_URL", "https://gitlab.com/api/v4");
881
+ vi.stubEnv("GITLAB_TOKEN", "glpat-x");
882
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
883
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
884
+ calls.push({ url, init });
885
+ if (!init?.method || init.method === "GET") return gitlabResponse([]);
886
+ return gitlabResponse({ iid: 5 });
887
+ });
888
+ try {
889
+ const result = await reconcilePr({ env: "app", mode: "issue", body: "plan" });
890
+ expect(result.issueUrl).toBe("https://gitlab.com/api/v4/projects/42/issues/5");
891
+ expect(ghCalls).toHaveLength(0);
892
+ } finally {
893
+ vi.unstubAllEnvs();
894
+ vi.unstubAllGlobals();
895
+ }
896
+ });
897
+ });
898
+
899
+ describe("postOrUpdateGithubIssue directly (#2297)", () => {
900
+ test("PATCHes with both title and body fields so the headline stays current", async () => {
901
+ const calls: string[] = [];
902
+ const exec = async (cmd: string) => {
903
+ calls.push(cmd);
904
+ if (cmd.includes("--paginate")) return { stdout: "12\n", stderr: "" };
905
+ return { stdout: "https://github.example/acme/infra/issues/12\n", stderr: "" };
906
+ };
907
+ vi.stubEnv("GITHUB_API_URL", "");
908
+ try {
909
+ const url = await postOrUpdateGithubIssue(
910
+ "acme/infra",
911
+ "<!-- chant-reconcile-issue:app -->",
912
+ "Reconcile app: 3 change(s) from live",
913
+ "the body",
914
+ exec,
915
+ );
916
+ expect(url).toBe("https://github.example/acme/infra/issues/12");
917
+ const patch = calls.find((c) => c.includes("--method PATCH"));
918
+ expect(patch).toContain("title=Reconcile app: 3 change(s) from live");
919
+ expect(patch).toContain("<!-- chant-reconcile-issue:app -->\n\nthe body");
920
+ expect(patch).toContain("/repos/acme/infra/issues/12");
921
+ } finally {
922
+ vi.unstubAllEnvs();
923
+ }
924
+ });
925
+ });