@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.
- package/dist/cli/handlers/operator.d.ts +13 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +196 -27
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +130 -0
- package/src/cli/handlers/operator.ts +56 -2
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +24 -12
- package/src/op/activities/lexicon-upgrade.ts +19 -3
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +512 -26
- package/src/op/activities/reconcile.ts +307 -34
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +111 -1
- package/src/op/gate.ts +181 -23
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +226 -3
- package/src/op/local-executor.ts +61 -12
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/types.ts +19 -0
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- 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
|
|
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
|
-
"
|
|
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();
|