@intentius/chant 0.62.0 → 0.63.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 (107) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -0
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/handlers/run.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +3 -0
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/components/driver-output.d.ts.map +1 -1
  10. package/dist/components/driver.d.ts +12 -0
  11. package/dist/components/driver.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +10 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  16. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  17. package/dist/lifecycle/index.d.ts +1 -0
  18. package/dist/lifecycle/index.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-digest.d.ts +33 -0
  20. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  21. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  22. package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
  23. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  24. package/dist/op/activities/lifecycle.d.ts +27 -0
  25. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  26. package/dist/op/activities/reconcile.d.ts +196 -27
  27. package/dist/op/activities/reconcile.d.ts.map +1 -1
  28. package/dist/op/builders.d.ts +6 -0
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/composites/apply-op.d.ts +6 -0
  31. package/dist/op/composites/apply-op.d.ts.map +1 -1
  32. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  33. package/dist/op/gate-summary.d.ts +16 -0
  34. package/dist/op/gate-summary.d.ts.map +1 -1
  35. package/dist/op/gate.d.ts +104 -13
  36. package/dist/op/gate.d.ts.map +1 -1
  37. package/dist/op/index.d.ts +3 -2
  38. package/dist/op/index.d.ts.map +1 -1
  39. package/dist/op/local-executor.d.ts +17 -0
  40. package/dist/op/local-executor.d.ts.map +1 -1
  41. package/dist/op/local-output.d.ts.map +1 -1
  42. package/dist/op/op-ir.d.ts +8 -1
  43. package/dist/op/op-ir.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +2 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/types.d.ts +19 -0
  47. package/dist/op/types.d.ts.map +1 -1
  48. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  49. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  50. package/dist/terraform/graph.d.ts +18 -2
  51. package/dist/terraform/graph.d.ts.map +1 -1
  52. package/dist/terraform/parse.d.ts.map +1 -1
  53. package/dist/terraform/types.d.ts +7 -0
  54. package/dist/terraform/types.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/cli/handlers/operator.test.ts +130 -0
  57. package/src/cli/handlers/operator.ts +56 -2
  58. package/src/cli/handlers/run.ts +19 -0
  59. package/src/cli/main.ts +2 -0
  60. package/src/cli/registry.ts +2 -0
  61. package/src/components/cli-support.ts +29 -4
  62. package/src/components/driver-output.ts +10 -0
  63. package/src/components/driver.test.ts +31 -0
  64. package/src/components/driver.ts +54 -8
  65. package/src/discovery/fold-import.test.ts +55 -0
  66. package/src/fold/fold.test.ts +152 -0
  67. package/src/fold/fold.ts +102 -2
  68. package/src/fold/subset-doc-parity.test.ts +35 -1
  69. package/src/fold/subset.ts +10 -0
  70. package/src/lifecycle/gate-ledger.test.ts +133 -1
  71. package/src/lifecycle/gate-ledger.ts +108 -0
  72. package/src/lifecycle/index.ts +1 -0
  73. package/src/lifecycle/plan-digest.test.ts +49 -0
  74. package/src/lifecycle/plan-digest.ts +86 -0
  75. package/src/lifecycle/run-ledger.ts +1 -0
  76. package/src/op/activities/lexicon-upgrade.test.ts +24 -12
  77. package/src/op/activities/lexicon-upgrade.ts +19 -3
  78. package/src/op/activities/lifecycle.ts +51 -2
  79. package/src/op/activities/reconcile.test.ts +512 -26
  80. package/src/op/activities/reconcile.ts +307 -34
  81. package/src/op/builders.ts +7 -1
  82. package/src/op/composites/apply-op.ts +16 -0
  83. package/src/op/composites/composites.test.ts +15 -2
  84. package/src/op/composites/reconcile-op.test.ts +18 -0
  85. package/src/op/composites/reconcile-op.ts +7 -1
  86. package/src/op/gate-summary.test.ts +33 -0
  87. package/src/op/gate-summary.ts +31 -0
  88. package/src/op/gate.test.ts +111 -1
  89. package/src/op/gate.ts +181 -23
  90. package/src/op/index.ts +5 -2
  91. package/src/op/local-executor.test.ts +226 -3
  92. package/src/op/local-executor.ts +61 -12
  93. package/src/op/local-output.test.ts +38 -0
  94. package/src/op/local-output.ts +24 -1
  95. package/src/op/op-ir.test.ts +22 -0
  96. package/src/op/op-ir.ts +9 -0
  97. package/src/op/runtime.ts +2 -0
  98. package/src/op/types.ts +19 -0
  99. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  100. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  101. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  102. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  103. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  104. package/src/terraform/graph.test.ts +148 -1
  105. package/src/terraform/graph.ts +144 -6
  106. package/src/terraform/parse.ts +4 -1
  107. package/src/terraform/types.ts +7 -0
@@ -13,6 +13,10 @@ import {
13
13
  githubApiBaseFrom,
14
14
  commentTokenFrom,
15
15
  noCommentTokenMessage,
16
+ noIssueIdentityMessage,
17
+ unsafeMarkerMessage,
18
+ suppliedMarker,
19
+ noIssueTokenMessage,
16
20
  postOrUpdateGithubIssue,
17
21
  } from "./reconcile";
18
22
 
@@ -591,18 +595,248 @@ describe("the no-context refusal names both forges' variables (#2256)", () => {
591
595
 
592
596
  // ── The GitLab issue (#2292) ─────────────────────────────────────────────
593
597
 
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"));
598
+ describe("issueMarker (#2292, #2319)", () => {
599
+ test("deterministic per Op and env, and distinct from the comment-mode marker for the same env", () => {
600
+ expect(issueMarker("nightly", "app")).toBe("<!-- chant-reconcile-issue:nightly/app -->");
601
+ expect(issueMarker("nightly", "app")).toBe(issueMarker("nightly", "app"));
602
+ expect(issueMarker("nightly", "app")).not.toBe(commentMarker("app"));
599
603
  });
600
604
 
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- -->");
605
+ test("slugifies both halves the same way commentMarker does", () => {
606
+ const marker = issueMarker('drift" watch', 'us-east/1" or true; #');
607
+ expect(marker).toBe("<!-- chant-reconcile-issue:drift-watch/us-east-1-or-true- -->");
604
608
  expect(marker).not.toMatch(/["'\\]/);
605
609
  });
610
+
611
+ // #2319: the whole point. Before this, both of these rendered
612
+ // `<!-- chant-reconcile-issue:app -->` and each Op's nightly run PATCHed the
613
+ // title and body of the other Op's issue.
614
+ test("two Ops over one env get two markers", () => {
615
+ expect(issueMarker("app-drift", "app")).not.toBe(issueMarker("app-drift-live", "app"));
616
+ });
617
+
618
+ test("the Op/env split is unambiguous: neither half can contain the separator", () => {
619
+ // `a/b` as an Op name and `a` + `/b` as a boundary shift would alias if
620
+ // `/` survived the slugify. It does not, so the marker parses back to
621
+ // exactly one Op and one env.
622
+ expect(issueMarker("a/b", "c")).toBe("<!-- chant-reconcile-issue:a-b/c -->");
623
+ expect(issueMarker("a", "b/c")).toBe("<!-- chant-reconcile-issue:a/b-c -->");
624
+ expect(issueMarker("a/b", "c")).not.toBe(issueMarker("a", "b/c"));
625
+ });
626
+
627
+ // The migration stance, as an assertion rather than a claim in a comment: a
628
+ // marker from before #2319 and a marker from after never prefix-match each
629
+ // other in either direction, so neither adopts the other's issue.
630
+ test("no pre-#2319 marker prefix-matches a post-#2319 one, or the reverse", () => {
631
+ const legacy = "<!-- chant-reconcile-issue:app -->";
632
+ const current = issueMarker("nightly", "app");
633
+ expect(`${current}\n\nbody`.startsWith(legacy)).toBe(false);
634
+ expect(`${legacy}\n\nbody`.startsWith(current)).toBe(false);
635
+ });
636
+ });
637
+
638
+ // ── The marker has to name an Op (#2319) ────────────────────────────────────
639
+
640
+ describe("reconcilePr issue mode refuses a step with no Op identity (#2319)", () => {
641
+ function stubAnyCi(): void {
642
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
643
+ vi.stubEnv("GITHUB_API_URL", "");
644
+ vi.stubEnv("CI_PROJECT_ID", "");
645
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
646
+ // A token, so what these tests prove is the identity refusal (#2319) and
647
+ // not the token refusal (#2320) standing in front of it.
648
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
649
+ vi.stubEnv("GH_TOKEN", "ghs-ambient");
650
+ }
651
+
652
+ test("names the mode, the env it was given, and both ways to fix the step", async () => {
653
+ stubAnyCi();
654
+ try {
655
+ await expect(reconcilePr({ env: "app", mode: "issue", body: "plan" })).rejects.toThrow(
656
+ /mode "issue".*env "app".*`op`.*`marker`/s,
657
+ );
658
+ } finally {
659
+ vi.unstubAllEnvs();
660
+ }
661
+ });
662
+
663
+ test("refuses before the shell-out, so nothing runs on the way to failing", async () => {
664
+ stubAnyCi();
665
+ ghReplies = [{ match: "--paginate", stdout: "57\n" }];
666
+ try {
667
+ await expect(reconcilePr({ env: "app", mode: "issue", body: "plan" })).rejects.toThrow();
668
+ expect(ghCalls).toHaveLength(0);
669
+ } finally {
670
+ vi.unstubAllEnvs();
671
+ }
672
+ });
673
+
674
+ // The shape that actually reaches the plan derivation: no `body` and no
675
+ // `entries`, which is `ReconcileOp`'s own step and the realistic
676
+ // hand-written one. The refusal used to live inside the `issue` branch,
677
+ // below `derivePlanEntries`, so this shape ran `chant lifecycle plan` first
678
+ // and could fail with a plan error instead of the named refusal.
679
+ test("refuses ahead of `chant lifecycle plan`, not after it", async () => {
680
+ stubAnyCi();
681
+ try {
682
+ await expect(reconcilePr({ env: "app", mode: "issue", owned: true })).rejects.toThrow(
683
+ /mode "issue".*env "app"/s,
684
+ );
685
+ expect(ghCalls).toHaveLength(0);
686
+ } finally {
687
+ vi.unstubAllEnvs();
688
+ }
689
+ });
690
+
691
+ // #2319 pre-merge review. `""` is not `undefined`, so it satisfied the
692
+ // identity check, and `??` kept it — producing `startswith("")`, true of
693
+ // every issue body in the repository. The step then PATCHed the title and
694
+ // body of whichever OPEN non-pull-request issue the forge listed first.
695
+ for (const blank of ["", " ", "\t\n"]) {
696
+ test(`a blank marker (${JSON.stringify(blank)}) is no marker, not an identity`, async () => {
697
+ stubAnyCi();
698
+ ghReplies = [
699
+ { match: "--paginate", stdout: "42\n" }, // a human's issue, first in the list
700
+ { match: "--method PATCH", stdout: "https://github.com/acme/infra/issues/42\n" },
701
+ ];
702
+ try {
703
+ await expect(
704
+ reconcilePr({ env: "app", marker: blank, mode: "issue", body: "plan" }),
705
+ ).rejects.toThrow(noIssueIdentityMessage("app"));
706
+ expect(ghCalls).toHaveLength(0);
707
+ } finally {
708
+ vi.unstubAllEnvs();
709
+ }
710
+ });
711
+ }
712
+
713
+ test("a blank marker alongside an op falls back to the op's marker, not to matching everything", async () => {
714
+ stubAnyCi();
715
+ ghReplies = [
716
+ { match: "--paginate", stdout: "" },
717
+ { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
718
+ ];
719
+ try {
720
+ await reconcilePr({ env: "app", op: "nightly", marker: " ", mode: "issue", body: "plan" });
721
+ expect(ghCalls[0].cmd).toContain('startswith("<!-- chant-reconcile-issue:nightly/app -->")');
722
+ } finally {
723
+ vi.unstubAllEnvs();
724
+ }
725
+ });
726
+
727
+ test("a blank op is no identity either", async () => {
728
+ stubAnyCi();
729
+ try {
730
+ await expect(
731
+ reconcilePr({ env: "app", op: " ", mode: "issue", body: "plan" }),
732
+ ).rejects.toThrow(noIssueIdentityMessage("app"));
733
+ expect(ghCalls).toHaveLength(0);
734
+ } finally {
735
+ vi.unstubAllEnvs();
736
+ }
737
+ });
738
+
739
+ test("comment mode treats a blank marker as absent too, rather than matching every comment", async () => {
740
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
741
+ vi.stubEnv("GITHUB_REF", "refs/pull/7/merge");
742
+ vi.stubEnv("GITHUB_EVENT_PATH", "");
743
+ vi.stubEnv("GITHUB_API_URL", "");
744
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
745
+ vi.stubEnv("GH_TOKEN", "ghs-x");
746
+ ghReplies = [
747
+ { match: "--paginate", stdout: "" },
748
+ { match: "--method POST", stdout: "https://github.com/acme/infra/pull/7#issuecomment-1\n" },
749
+ ];
750
+ try {
751
+ await reconcilePr({ env: "app", marker: "", mode: "comment", body: "plan" });
752
+ expect(ghCalls[0].cmd).toContain('startswith("<!-- chant-reconcile:app -->")');
753
+ expect(ghCalls[0].cmd).not.toContain('startswith("")');
754
+ } finally {
755
+ vi.unstubAllEnvs();
756
+ }
757
+ });
758
+ test("an explicit marker satisfies the requirement without an op", async () => {
759
+ stubAnyCi();
760
+ ghReplies = [
761
+ { match: "--paginate", stdout: "" },
762
+ { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
763
+ ];
764
+ try {
765
+ await reconcilePr({ env: "app", marker: "<!-- mine -->", mode: "issue", body: "plan" });
766
+ expect(ghCalls[1].cmd).toContain("<!-- mine -->\n\nplan");
767
+ } finally {
768
+ vi.unstubAllEnvs();
769
+ }
770
+ });
771
+
772
+ test("the refusal covers the ambient `gh issue create` fallback too, not just the CI paths", async () => {
773
+ for (const k of ["GITHUB_REPOSITORY", "CI_PROJECT_ID", "CI_MERGE_REQUEST_IID"]) vi.stubEnv(k, "");
774
+ ghReplies = [{ match: "gh issue create", stdout: "https://github.com/acme/infra/issues/3\n" }];
775
+ try {
776
+ await expect(reconcilePr({ env: "app", mode: "issue", body: "plan" })).rejects.toThrow(/mode "issue"/);
777
+ expect(ghCalls).toHaveLength(0);
778
+ } finally {
779
+ vi.unstubAllEnvs();
780
+ }
781
+ });
782
+
783
+ test("comment mode is untouched by the requirement — its marker is scoped to one pull request", async () => {
784
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
785
+ vi.stubEnv("GITHUB_REF", "refs/pull/7/merge");
786
+ vi.stubEnv("GITHUB_EVENT_PATH", "");
787
+ vi.stubEnv("GITHUB_API_URL", "");
788
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
789
+ vi.stubEnv("GH_TOKEN", "ghs-x");
790
+ ghReplies = [
791
+ { match: "--paginate", stdout: "" },
792
+ { match: "--method POST", stdout: "https://github.com/acme/infra/pull/7#issuecomment-1\n" },
793
+ ];
794
+ try {
795
+ const result = await reconcilePr({ env: "app", mode: "comment", body: "plan" });
796
+ expect(result.commentUrl).toContain("issuecomment-1");
797
+ } finally {
798
+ vi.unstubAllEnvs();
799
+ }
800
+ });
801
+ });
802
+
803
+ // ── A supplied marker has to survive the jq filter (#2319 review) ───────────
804
+
805
+ describe("suppliedMarker rejects what the search filter cannot carry (#2319)", () => {
806
+ test("blank is absent, printable text is kept and trimmed", () => {
807
+ expect(suppliedMarker(undefined)).toBeUndefined();
808
+ expect(suppliedMarker("")).toBeUndefined();
809
+ expect(suppliedMarker(" \t\n ")).toBeUndefined();
810
+ expect(suppliedMarker(" <!-- mine --> ")).toBe("<!-- mine -->");
811
+ });
812
+
813
+ for (const bad of ['<!-- a" -->', "<!-- a\\b -->", "<!-- a\nb -->"]) {
814
+ test(`refuses ${JSON.stringify(bad)} by name`, () => {
815
+ expect(() => suppliedMarker(bad)).toThrow(/cannot use/);
816
+ expect(() => suppliedMarker(bad)).toThrow(unsafeMarkerMessage(bad.trim()));
817
+ });
818
+ }
819
+
820
+ test("the refusal is raised through reconcilePr before any shell-out", async () => {
821
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
822
+ vi.stubEnv("GITHUB_API_URL", "");
823
+ vi.stubEnv("CI_PROJECT_ID", "");
824
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
825
+ try {
826
+ await expect(
827
+ reconcilePr({ env: "app", marker: '<!-- ") | not -->', mode: "issue", body: "plan" }),
828
+ ).rejects.toThrow(/cannot use/);
829
+ expect(ghCalls).toHaveLength(0);
830
+ } finally {
831
+ vi.unstubAllEnvs();
832
+ }
833
+ });
834
+
835
+ test("what issueMarker itself builds always survives it", () => {
836
+ const marker = issueMarker('drift" watch\\', 'us-east/1" or true; #');
837
+ expect(suppliedMarker(marker)).toBe(marker);
838
+ });
839
+
606
840
  });
607
841
 
608
842
  describe("gitlabProjectContextFrom (#2292)", () => {
@@ -669,14 +903,14 @@ describe("reconcilePr issue mode on GitLab opens/updates one issue (#2292)", ()
669
903
  return gitlabResponse({ iid: 9 });
670
904
  });
671
905
  try {
672
- const result = await reconcilePr({ env: "app", mode: "issue", body: "the plan" });
906
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "the plan" });
673
907
  expect(result.issueUrl).toBe("https://gitlab.com/acme/infra/-/issues/9");
674
908
  const post = calls[calls.length - 1];
675
909
  expect(post.url).toBe("https://gitlab.com/api/v4/projects/42/issues");
676
910
  expect(post.init?.method).toBe("POST");
677
911
  expect((post.init?.headers as Record<string, string>)["PRIVATE-TOKEN"]).toBe("glpat-x");
678
912
  const sent = JSON.parse(String(post.init?.body));
679
- expect(sent.description).toBe("<!-- chant-reconcile-issue:app -->\n\nthe plan");
913
+ expect(sent.description).toBe("<!-- chant-reconcile-issue:nightly/app -->\n\nthe plan");
680
914
  expect(sent.title).toContain("Reconcile app");
681
915
  } finally {
682
916
  vi.unstubAllEnvs();
@@ -692,13 +926,13 @@ describe("reconcilePr issue mode on GitLab opens/updates one issue (#2292)", ()
692
926
  if (!init?.method || init.method === "GET") {
693
927
  return gitlabResponse([
694
928
  { iid: 1, description: "unrelated issue" },
695
- { iid: 4, description: "<!-- chant-reconcile-issue:app -->\n\nan older finding" },
929
+ { iid: 4, description: "<!-- chant-reconcile-issue:nightly/app -->\n\nan older finding" },
696
930
  ]);
697
931
  }
698
932
  return gitlabResponse({ iid: 4 });
699
933
  });
700
934
  try {
701
- const result = await reconcilePr({ env: "app", mode: "issue", body: "a newer finding" });
935
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "a newer finding" });
702
936
  const write = calls[calls.length - 1];
703
937
  expect(write.init?.method).toBe("PUT");
704
938
  expect(write.url).toBe("https://gitlab.com/api/v4/projects/42/issues/4");
@@ -720,9 +954,9 @@ describe("reconcilePr issue mode on GitLab opens/updates one issue (#2292)", ()
720
954
  return gitlabResponse({ iid: 9 });
721
955
  });
722
956
  try {
723
- await reconcilePr({ env: "app", mode: "issue", body: "new" });
957
+ await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "new" });
724
958
  expect(seen).toHaveLength(1);
725
- expect(seen[0]).toContain(`search=${encodeURIComponent(issueMarker("app"))}`);
959
+ expect(seen[0]).toContain(`search=${encodeURIComponent(issueMarker("nightly", "app"))}`);
726
960
  expect(seen[0]).toContain("in=description");
727
961
  } finally {
728
962
  vi.unstubAllEnvs();
@@ -743,7 +977,7 @@ describe("reconcilePr issue mode on GitLab opens/updates one issue (#2292)", ()
743
977
  } as unknown as Response;
744
978
  });
745
979
  try {
746
- await expect(reconcilePr({ env: "app", mode: "issue", body: "plan" })).rejects.toThrow(
980
+ await expect(reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" })).rejects.toThrow(
747
981
  /projects\/42\/issues.*403.*403 Forbidden/s,
748
982
  );
749
983
  } finally {
@@ -760,7 +994,7 @@ describe("reconcilePr issue mode on GitLab opens/updates one issue (#2292)", ()
760
994
  vi.stubEnv(k, "");
761
995
  }
762
996
  try {
763
- await expect(reconcilePr({ env: "app", mode: "issue", body: "plan" })).rejects.toThrow(
997
+ await expect(reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" })).rejects.toThrow(
764
998
  /mode "issue".*project 42.*GITLAB_TOKEN.*CI_JOB_TOKEN/s,
765
999
  );
766
1000
  } finally {
@@ -777,6 +1011,10 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
777
1011
  function stubGithubIssueEnv(apiUrl: string): void {
778
1012
  vi.stubEnv("GITHUB_REPOSITORY", repo);
779
1013
  vi.stubEnv("GITHUB_API_URL", apiUrl);
1014
+ // What a GitHub Actions or Forgejo Actions job sets from `github.token`,
1015
+ // and what the issue path now actually forwards (#2320).
1016
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1017
+ vi.stubEnv("GH_TOKEN", "ghs-ambient");
780
1018
  // Never the GitLab path or the comment/PR path.
781
1019
  vi.stubEnv("CI_PROJECT_ID", "");
782
1020
  vi.stubEnv("CI_MERGE_REQUEST_IID", "");
@@ -791,7 +1029,7 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
791
1029
  { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/9\n" },
792
1030
  ];
793
1031
  try {
794
- const result = await reconcilePr({ env: "app", mode: "issue", body: "the plan" });
1032
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "the plan" });
795
1033
  expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/9");
796
1034
 
797
1035
  const [list, post] = ghCalls;
@@ -800,7 +1038,7 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
800
1038
  expect(post.cmd).toContain("--method POST");
801
1039
  expect(post.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues");
802
1040
  expect(post.cmd).toContain(`title=Reconcile app`);
803
- expect(post.cmd).toContain("<!-- chant-reconcile-issue:app -->\n\nthe plan");
1041
+ expect(post.cmd).toContain("<!-- chant-reconcile-issue:nightly/app -->\n\nthe plan");
804
1042
  } finally {
805
1043
  vi.unstubAllEnvs();
806
1044
  }
@@ -813,7 +1051,7 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
813
1051
  { match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/57\n" },
814
1052
  ];
815
1053
  try {
816
- const result = await reconcilePr({ env: "app", mode: "issue", body: "a newer plan" });
1054
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "a newer plan" });
817
1055
  expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/57");
818
1056
 
819
1057
  const patchCall = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
@@ -832,7 +1070,7 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
832
1070
  { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
833
1071
  ];
834
1072
  try {
835
- await reconcilePr({ env: "app", mode: "issue", body: "plan" });
1073
+ await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
836
1074
  const [list] = ghCalls;
837
1075
  expect(list.cmd).toContain("state=open");
838
1076
  expect(list.cmd).toContain(".pull_request == null");
@@ -848,7 +1086,7 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
848
1086
  { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
849
1087
  ];
850
1088
  try {
851
- await reconcilePr({ env: "app", mode: "issue", body: "plan" });
1089
+ await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
852
1090
  expect(ghCalls[0].cmd).toContain("https://api.github.com/repos/acme/infra/issues?state=open");
853
1091
  } finally {
854
1092
  vi.unstubAllEnvs();
@@ -864,11 +1102,11 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
864
1102
  vi.stubEnv("GITHUB_EVENT_PATH", "");
865
1103
  ghReplies = [{ match: "gh issue create", stdout: "https://github.com/acme/infra/issues/3\n" }];
866
1104
  try {
867
- const result = await reconcilePr({ env: "app", mode: "issue", body: "plan" });
1105
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
868
1106
  expect(result.issueUrl).toBe("https://github.com/acme/infra/issues/3");
869
1107
  expect(ghCalls).toHaveLength(1);
870
1108
  expect(ghCalls[0].cmd).toContain("gh issue create");
871
- expect(ghCalls[0].cmd).toContain("<!-- chant-reconcile-issue:app -->\n\nplan");
1109
+ expect(ghCalls[0].cmd).toContain("<!-- chant-reconcile-issue:nightly/app -->\n\nplan");
872
1110
  } finally {
873
1111
  vi.unstubAllEnvs();
874
1112
  }
@@ -886,7 +1124,7 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
886
1124
  return gitlabResponse({ iid: 5 });
887
1125
  });
888
1126
  try {
889
- const result = await reconcilePr({ env: "app", mode: "issue", body: "plan" });
1127
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
890
1128
  expect(result.issueUrl).toBe("https://gitlab.com/api/v4/projects/42/issues/5");
891
1129
  expect(ghCalls).toHaveLength(0);
892
1130
  } finally {
@@ -896,6 +1134,252 @@ describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue
896
1134
  });
897
1135
  });
898
1136
 
1137
+ // ── The two-Op proof #2319 asked for ────────────────────────────────────────
1138
+
1139
+ describe("two Ops over one env keep two issues, end to end (#2319)", () => {
1140
+ // The stub answers by substring, so it cannot evaluate a jq predicate. This
1141
+ // does it instead, and does it the honest way: both halves are read back out
1142
+ // of the real `gh` commands rather than restated, so the search and the
1143
+ // write are proved to agree rather than assumed to.
1144
+
1145
+ /** The marker a `gh api … --paginate` search is filtering on. */
1146
+ function markerOf(cmd: string): string {
1147
+ const m = /startswith\("(.*?)"\)/.exec(cmd);
1148
+ if (!m) throw new Error(`not a marker search: ${cmd}`);
1149
+ return m[1];
1150
+ }
1151
+
1152
+ /** The body a `-f 'body=…'` write is sending. */
1153
+ function bodyOf(cmd: string): string {
1154
+ const m = /-f 'body=([\s\S]*?)' --jq/.exec(cmd);
1155
+ if (!m) throw new Error(`not a body write: ${cmd}`);
1156
+ return m[1];
1157
+ }
1158
+
1159
+ test("run B does not find run A's issue, and opens its own", async () => {
1160
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
1161
+ vi.stubEnv("GITHUB_API_URL", "");
1162
+ vi.stubEnv("CI_PROJECT_ID", "");
1163
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
1164
+ vi.stubEnv("GITHUB_REF", "");
1165
+ vi.stubEnv("GITHUB_EVENT_PATH", "");
1166
+ // What the issue path now forwards to `gh` (#2320).
1167
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1168
+ vi.stubEnv("GH_TOKEN", "ghs-ambient");
1169
+ try {
1170
+ // ── Run A: the stock drift watch over root "app". Nothing owned yet.
1171
+ ghReplies = [
1172
+ { match: "--paginate", stdout: "" },
1173
+ { match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
1174
+ ];
1175
+ const a = await reconcilePr({ env: "app", op: "app-drift", mode: "issue", body: "finding from A" });
1176
+ expect(a.issueUrl).toBe("https://github.com/acme/infra/issues/1");
1177
+ const aSearch = ghCalls.find((c) => c.cmd.includes("--paginate"))!.cmd;
1178
+ // What the repository now holds as issue #1.
1179
+ const aBody = bodyOf(ghCalls.find((c) => c.cmd.includes("--method POST"))!.cmd);
1180
+ expect(aBody).toContain("finding from A");
1181
+
1182
+ // ── Run B: the `live: true` watch over the same root. Same `env`, the
1183
+ // pairing #2319 reports. The repository already holds A's issue.
1184
+ ghCalls.length = 0;
1185
+ ghReplies = [
1186
+ { match: "--paginate", stdout: "" }, // justified two assertions down
1187
+ { match: "--method POST", stdout: "https://github.com/acme/infra/issues/2\n" },
1188
+ ];
1189
+ const b = await reconcilePr({
1190
+ env: "app",
1191
+ op: "app-drift-live",
1192
+ mode: "issue",
1193
+ body: "finding from B",
1194
+ });
1195
+ const bSearch = ghCalls.find((c) => c.cmd.includes("--paginate"))!.cmd;
1196
+
1197
+ // The forge's answer to B's search, computed rather than stubbed: B's
1198
+ // own `startswith` predicate, run against the body A actually wrote.
1199
+ // Before #2319 this was true, and B went on to PATCH A's issue.
1200
+ expect(aBody.startsWith(markerOf(bSearch))).toBe(false);
1201
+
1202
+ // So "not found" is the right stub, and B opens its own issue.
1203
+ expect(ghCalls.some((c) => c.cmd.includes("--method PATCH"))).toBe(false);
1204
+ expect(b.issueUrl).toBe("https://github.com/acme/infra/issues/2");
1205
+ const bBody = bodyOf(ghCalls.find((c) => c.cmd.includes("--method POST"))!.cmd);
1206
+ expect(bBody).toContain("finding from B");
1207
+ expect(bBody).not.toContain("finding from A");
1208
+
1209
+ // The converse, so this is stickiness preserved and not just two
1210
+ // strangers: A's own search still matches the issue A wrote.
1211
+ expect(aBody.startsWith(markerOf(aSearch))).toBe(true);
1212
+ expect(markerOf(aSearch)).not.toBe(markerOf(bSearch));
1213
+ } finally {
1214
+ vi.unstubAllEnvs();
1215
+ }
1216
+ });
1217
+
1218
+ test("A's second run still edits A's issue rather than opening another", async () => {
1219
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
1220
+ vi.stubEnv("GITHUB_API_URL", "");
1221
+ vi.stubEnv("CI_PROJECT_ID", "");
1222
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
1223
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1224
+ vi.stubEnv("GH_TOKEN", "ghs-ambient");
1225
+ ghReplies = [
1226
+ { match: "--paginate", stdout: "1\n" }, // A's marker found A's issue
1227
+ { match: "--method PATCH", stdout: "https://github.com/acme/infra/issues/1\n" },
1228
+ ];
1229
+ try {
1230
+ const a2 = await reconcilePr({
1231
+ env: "app",
1232
+ op: "app-drift",
1233
+ mode: "issue",
1234
+ body: "finding from A, later",
1235
+ });
1236
+ expect(a2.issueUrl).toBe("https://github.com/acme/infra/issues/1");
1237
+ expect(ghCalls.some((c) => c.cmd.includes("--method POST"))).toBe(false);
1238
+ } finally {
1239
+ vi.unstubAllEnvs();
1240
+ }
1241
+ });
1242
+ });
1243
+
1244
+ // ── The issue path's credential (#2320) ─────────────────────────────────────
1245
+
1246
+ describe("reconcilePr issue mode sends the token it resolved (#2320)", () => {
1247
+ function stubForgejoIssueEnv(): void {
1248
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
1249
+ vi.stubEnv("GITHUB_API_URL", "https://other.forgejo.example/api/v1");
1250
+ vi.stubEnv("CI_PROJECT_ID", "");
1251
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
1252
+ vi.stubEnv("GITHUB_REF", "");
1253
+ vi.stubEnv("GITHUB_EVENT_PATH", "");
1254
+ }
1255
+
1256
+ // The case `noCommentTokenMessage` was written for, on the mode that never
1257
+ // sent it: a run posting to a Forgejo instance other than the one the job
1258
+ // executes on, where the job's own `github.token` stops at its own host.
1259
+ test("CHANT_FORGEJO_TOKEN reaches gh as GH_TOKEN, outranking the ambient one", async () => {
1260
+ stubForgejoIssueEnv();
1261
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
1262
+ vi.stubEnv("GH_TOKEN", "ghs-this-instance-only");
1263
+ ghReplies = [
1264
+ { match: "--paginate", stdout: "" },
1265
+ { match: "--method POST", stdout: "https://other.forgejo.example/acme/infra/issues/1\n" },
1266
+ ];
1267
+ try {
1268
+ await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1269
+ expect(ghCalls).toHaveLength(2);
1270
+ for (const call of ghCalls) {
1271
+ expect(call.opts.env?.GH_TOKEN).toBe("forgejo-cross-instance");
1272
+ }
1273
+ } finally {
1274
+ vi.unstubAllEnvs();
1275
+ }
1276
+ });
1277
+
1278
+ test("the PATCH branch forwards it too, not only the search and the POST", async () => {
1279
+ stubForgejoIssueEnv();
1280
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
1281
+ vi.stubEnv("GH_TOKEN", "");
1282
+ ghReplies = [
1283
+ { match: "--paginate", stdout: "57\n" },
1284
+ { match: "--method PATCH", stdout: "https://other.forgejo.example/acme/infra/issues/57\n" },
1285
+ ];
1286
+ try {
1287
+ await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1288
+ const patch = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
1289
+ expect(patch?.opts.env?.GH_TOKEN).toBe("forgejo-cross-instance");
1290
+ } finally {
1291
+ vi.unstubAllEnvs();
1292
+ }
1293
+ });
1294
+
1295
+ test("falls back to GH_TOKEN then GITHUB_TOKEN, the same order comment mode uses", async () => {
1296
+ stubForgejoIssueEnv();
1297
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1298
+ vi.stubEnv("GH_TOKEN", "");
1299
+ vi.stubEnv("GITHUB_TOKEN", "ghs-workflow");
1300
+ ghReplies = [
1301
+ { match: "--paginate", stdout: "" },
1302
+ { match: "--method POST", stdout: "https://other.forgejo.example/acme/infra/issues/1\n" },
1303
+ ];
1304
+ try {
1305
+ await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1306
+ expect(ghCalls[0].opts.env?.GH_TOKEN).toBe("ghs-workflow");
1307
+ } finally {
1308
+ vi.unstubAllEnvs();
1309
+ }
1310
+ });
1311
+
1312
+ test("no token at all is chant's named refusal, not gh's opaque one", async () => {
1313
+ stubForgejoIssueEnv();
1314
+ for (const k of ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) vi.stubEnv(k, "");
1315
+ try {
1316
+ await expect(
1317
+ reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" }),
1318
+ ).rejects.toThrow(/mode "issue".*acme\/infra.*CHANT_FORGEJO_TOKEN/s);
1319
+ // Refused before the shell-out, so gh is never asked to guess.
1320
+ expect(ghCalls).toHaveLength(0);
1321
+ } finally {
1322
+ vi.unstubAllEnvs();
1323
+ }
1324
+ });
1325
+
1326
+ test("the refusal names the issue mode, not the comment mode's pull request", async () => {
1327
+ stubForgejoIssueEnv();
1328
+ for (const k of ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) vi.stubEnv(k, "");
1329
+ try {
1330
+ const err = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" }).catch(
1331
+ (e: Error) => e,
1332
+ );
1333
+ expect((err as Error).message).toBe(noIssueTokenMessage("acme/infra"));
1334
+ expect((err as Error).message).not.toContain("pull request");
1335
+ } finally {
1336
+ vi.unstubAllEnvs();
1337
+ }
1338
+ });
1339
+
1340
+ // The one path deliberately left on gh's own ambient credential: outside
1341
+ // every CI job, `gh auth login`'s stored token is the token, and
1342
+ // commentTokenFrom cannot see it.
1343
+ test("the ambient `gh issue create` fallback still runs with no token set", async () => {
1344
+ for (const k of [
1345
+ "GITHUB_REPOSITORY",
1346
+ "CI_PROJECT_ID",
1347
+ "CI_MERGE_REQUEST_IID",
1348
+ "CHANT_FORGEJO_TOKEN",
1349
+ "GH_TOKEN",
1350
+ "GITHUB_TOKEN",
1351
+ ]) {
1352
+ vi.stubEnv(k, "");
1353
+ }
1354
+ ghReplies = [{ match: "gh issue create", stdout: "https://github.com/acme/infra/issues/3\n" }];
1355
+ try {
1356
+ const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1357
+ expect(result.issueUrl).toBe("https://github.com/acme/infra/issues/3");
1358
+ } finally {
1359
+ vi.unstubAllEnvs();
1360
+ }
1361
+ });
1362
+
1363
+ test("GitLab keeps its own credential path, untouched by this", async () => {
1364
+ vi.stubEnv("GITHUB_REPOSITORY", "");
1365
+ vi.stubEnv("CI_PROJECT_ID", "42");
1366
+ vi.stubEnv("CI_API_V4_URL", "https://gitlab.com/api/v4");
1367
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
1368
+ vi.stubEnv("GITLAB_TOKEN", "");
1369
+ vi.stubEnv("CI_JOB_TOKEN", "");
1370
+ vi.stubEnv("CHANT_GITLAB_TOKEN", "");
1371
+ // A Forgejo token is not a GitLab credential and must not be read as one.
1372
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
1373
+ try {
1374
+ await expect(
1375
+ reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" }),
1376
+ ).rejects.toThrow(/GITLAB_TOKEN/);
1377
+ } finally {
1378
+ vi.unstubAllEnvs();
1379
+ }
1380
+ });
1381
+ });
1382
+
899
1383
  describe("postOrUpdateGithubIssue directly (#2297)", () => {
900
1384
  test("PATCHes with both title and body fields so the headline stays current", async () => {
901
1385
  const calls: string[] = [];
@@ -905,10 +1389,12 @@ describe("postOrUpdateGithubIssue directly (#2297)", () => {
905
1389
  return { stdout: "https://github.example/acme/infra/issues/12\n", stderr: "" };
906
1390
  };
907
1391
  vi.stubEnv("GITHUB_API_URL", "");
1392
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
1393
+ vi.stubEnv("GH_TOKEN", "ghs-ambient");
908
1394
  try {
909
1395
  const url = await postOrUpdateGithubIssue(
910
1396
  "acme/infra",
911
- "<!-- chant-reconcile-issue:app -->",
1397
+ issueMarker("nightly", "app"),
912
1398
  "Reconcile app: 3 change(s) from live",
913
1399
  "the body",
914
1400
  exec,
@@ -916,7 +1402,7 @@ describe("postOrUpdateGithubIssue directly (#2297)", () => {
916
1402
  expect(url).toBe("https://github.example/acme/infra/issues/12");
917
1403
  const patch = calls.find((c) => c.includes("--method PATCH"));
918
1404
  expect(patch).toContain("title=Reconcile app: 3 change(s) from live");
919
- expect(patch).toContain("<!-- chant-reconcile-issue:app -->\n\nthe body");
1405
+ expect(patch).toContain("<!-- chant-reconcile-issue:nightly/app -->\n\nthe body");
920
1406
  expect(patch).toContain("/repos/acme/infra/issues/12");
921
1407
  } finally {
922
1408
  vi.unstubAllEnvs();