@intentius/chant 0.61.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 -18
- 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/lexicon.d.ts +51 -0
- package/dist/lexicon.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/git.d.ts +117 -0
- package/dist/lifecycle/git.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 +33 -3
- 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 +394 -14
- 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 +34 -2
- 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/operator.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/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.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 +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- 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/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- 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 +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- 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 +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- 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/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- 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
|
@@ -1,15 +1,62 @@
|
|
|
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
|
+
noIssueIdentityMessage,
|
|
17
|
+
unsafeMarkerMessage,
|
|
18
|
+
suppliedMarker,
|
|
19
|
+
noIssueTokenMessage,
|
|
20
|
+
postOrUpdateGithubIssue,
|
|
11
21
|
} from "./reconcile";
|
|
12
22
|
|
|
23
|
+
// ── The `gh` stub (chant #2291) ──────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// Same recipe as `lexicons/terraform/src/op/activities/terraform.test.ts` and
|
|
26
|
+
// `lexicons/k3s/src/op/activities/k3s.test.ts`: `node:child_process`'s `exec`
|
|
27
|
+
// carries a `nodejs.util.promisify.custom` implementation, which is what
|
|
28
|
+
// `promisify(exec)` picks up at module load, so every `gh` invocation
|
|
29
|
+
// `postOrUpdateComment` makes lands in `ghCalls` with its exact command
|
|
30
|
+
// string and options — including the `env` it was given, so a test can prove
|
|
31
|
+
// which token actually reached `gh`.
|
|
32
|
+
|
|
33
|
+
interface GhCall {
|
|
34
|
+
cmd: string;
|
|
35
|
+
opts: { signal?: AbortSignal; env?: Record<string, string | undefined> };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ghCalls: GhCall[] = [];
|
|
39
|
+
/** cmd substring -> stdout to answer with. First match wins. */
|
|
40
|
+
let ghReplies: Array<{ match: string; stdout: string }> = [];
|
|
41
|
+
|
|
42
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
43
|
+
const custom = Symbol.for("nodejs.util.promisify.custom");
|
|
44
|
+
const exec = ((_cmd: string, _opts: unknown, cb?: (...a: unknown[]) => void) => {
|
|
45
|
+
cb?.(new Error("unmocked exec path"));
|
|
46
|
+
}) as unknown as Record<symbol, unknown>;
|
|
47
|
+
exec[custom] = async (cmd: string, opts?: GhCall["opts"]) => {
|
|
48
|
+
ghCalls.push({ cmd, opts: opts ?? {} });
|
|
49
|
+
const hit = ghReplies.find(({ match }) => cmd.includes(match));
|
|
50
|
+
return { stdout: hit?.stdout ?? "", stderr: "" };
|
|
51
|
+
};
|
|
52
|
+
return { ...(await importOriginal<typeof import("node:child_process")>()), exec };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
ghCalls.length = 0;
|
|
57
|
+
ghReplies = [];
|
|
58
|
+
});
|
|
59
|
+
|
|
13
60
|
const entries = [
|
|
14
61
|
{ name: "bucket", action: "adopt", type: "AWS::S3::Bucket" },
|
|
15
62
|
{ name: "queue", action: "update", type: "AWS::SQS::Queue" },
|
|
@@ -162,6 +209,154 @@ describe("reconcilePr comment mode refuses a run with no pull request (#2231)",
|
|
|
162
209
|
});
|
|
163
210
|
});
|
|
164
211
|
|
|
212
|
+
// ── The GitHub/Forgejo comment, via `gh` at a full URL (#2291) ──────────────
|
|
213
|
+
|
|
214
|
+
describe("githubApiBaseFrom (#2291)", () => {
|
|
215
|
+
test("defaults to github.com's own API host when GITHUB_API_URL is unset", () => {
|
|
216
|
+
expect(githubApiBaseFrom({})).toBe("https://api.github.com");
|
|
217
|
+
expect(githubApiBaseFrom({ GITHUB_API_URL: "" })).toBe("https://api.github.com");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("reads a Forgejo instance's own /api/v1 base, trailing slash stripped", () => {
|
|
221
|
+
expect(githubApiBaseFrom({ GITHUB_API_URL: "http://forgejo:3000/api/v1/" })).toBe(
|
|
222
|
+
"http://forgejo:3000/api/v1",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("reads a GitHub Enterprise Server base unchanged", () => {
|
|
227
|
+
expect(githubApiBaseFrom({ GITHUB_API_URL: "https://ghe.example.com/api/v3" })).toBe(
|
|
228
|
+
"https://ghe.example.com/api/v3",
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("commentTokenFrom (#2291)", () => {
|
|
234
|
+
test("GH_TOKEN — what the generated workflow sets from github.token — resolves out of the box", () => {
|
|
235
|
+
expect(commentTokenFrom({ GH_TOKEN: "ghs_x" })).toEqual({ value: "ghs_x", source: "GH_TOKEN" });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("falls back to GITHUB_TOKEN when GH_TOKEN is unset", () => {
|
|
239
|
+
expect(commentTokenFrom({ GITHUB_TOKEN: "ghs_y" })).toEqual({ value: "ghs_y", source: "GITHUB_TOKEN" });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("CHANT_FORGEJO_TOKEN wins over both, for the cross-instance case", () => {
|
|
243
|
+
expect(
|
|
244
|
+
commentTokenFrom({ CHANT_FORGEJO_TOKEN: "a", GH_TOKEN: "b", GITHUB_TOKEN: "c" })?.source,
|
|
245
|
+
).toBe("CHANT_FORGEJO_TOKEN");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("no token at all is undefined rather than an empty value", () => {
|
|
249
|
+
expect(commentTokenFrom({})).toBeUndefined();
|
|
250
|
+
expect(commentTokenFrom({ GH_TOKEN: "", GITHUB_TOKEN: "" })).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () => {
|
|
255
|
+
const repo = "acme/infra";
|
|
256
|
+
|
|
257
|
+
function stubPrEnv(apiUrl: string): void {
|
|
258
|
+
vi.stubEnv("GITHUB_REPOSITORY", repo);
|
|
259
|
+
vi.stubEnv("GITHUB_REF", "refs/pull/5/merge");
|
|
260
|
+
vi.stubEnv("GITHUB_EVENT_PATH", "");
|
|
261
|
+
vi.stubEnv("GITHUB_API_URL", apiUrl);
|
|
262
|
+
vi.stubEnv("GH_TOKEN", "forgejo-actions-token");
|
|
263
|
+
vi.stubEnv("GITHUB_TOKEN", "");
|
|
264
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
|
|
265
|
+
// Never the GitLab path: a GitHub/Forgejo run carries no merge request.
|
|
266
|
+
vi.stubEnv("CI_MERGE_REQUEST_IID", "");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
test("POSTs a new comment at the Forgejo instance's own /api/v1 base, not a bare path", async () => {
|
|
270
|
+
stubPrEnv("http://forgejo.example/api/v1");
|
|
271
|
+
ghReplies = [
|
|
272
|
+
{ match: "--paginate", stdout: "" }, // no owned comment yet
|
|
273
|
+
{ match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
|
|
274
|
+
];
|
|
275
|
+
try {
|
|
276
|
+
const result = await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
|
|
277
|
+
expect(result.commentUrl).toBe("http://forgejo.example/acme/infra/issues/5#issuecomment-1");
|
|
278
|
+
expect(result.pullRequest).toBe("acme/infra#5");
|
|
279
|
+
|
|
280
|
+
const [list, post] = ghCalls;
|
|
281
|
+
// The bug #2291 fixed: this used to be the bare path
|
|
282
|
+
// `repos/acme/infra/issues/5/comments`, which `gh` resolves against
|
|
283
|
+
// `/api/v3` for any non-github.com host — 404 on Forgejo. It is now the
|
|
284
|
+
// full URL built from GITHUB_API_URL.
|
|
285
|
+
expect(list.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/5/comments");
|
|
286
|
+
expect(list.cmd).not.toMatch(/gh api 'repos\//);
|
|
287
|
+
expect(post.cmd).toContain("--method POST");
|
|
288
|
+
expect(post.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/5/comments");
|
|
289
|
+
} finally {
|
|
290
|
+
vi.unstubAllEnvs();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a re-run PATCHes the comment it already owns instead of posting a second", async () => {
|
|
295
|
+
stubPrEnv("http://forgejo.example/api/v1");
|
|
296
|
+
ghReplies = [
|
|
297
|
+
{ match: "--paginate", stdout: "42\n" }, // the marker search found comment 42
|
|
298
|
+
{ match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-42\n" },
|
|
299
|
+
];
|
|
300
|
+
try {
|
|
301
|
+
const result = await reconcilePr({ env: "app", mode: "comment", body: "a newer plan" });
|
|
302
|
+
expect(result.commentUrl).toBe("http://forgejo.example/acme/infra/issues/5#issuecomment-42");
|
|
303
|
+
|
|
304
|
+
const patchCall = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
|
|
305
|
+
const postCall = ghCalls.find((c) => c.cmd.includes("--method POST"));
|
|
306
|
+
expect(patchCall?.cmd).toContain(
|
|
307
|
+
"http://forgejo.example/api/v1/repos/acme/infra/issues/comments/42",
|
|
308
|
+
);
|
|
309
|
+
expect(postCall).toBeUndefined();
|
|
310
|
+
} finally {
|
|
311
|
+
vi.unstubAllEnvs();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("defaults to github.com's API base when GITHUB_API_URL is unset (plain GitHub run)", async () => {
|
|
316
|
+
stubPrEnv("");
|
|
317
|
+
ghReplies = [
|
|
318
|
+
{ match: "--paginate", stdout: "" },
|
|
319
|
+
{ match: "--method POST", stdout: "https://github.com/acme/infra/issues/5#issuecomment-1\n" },
|
|
320
|
+
];
|
|
321
|
+
try {
|
|
322
|
+
await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
|
|
323
|
+
expect(ghCalls[0].cmd).toContain("https://api.github.com/repos/acme/infra/issues/5/comments");
|
|
324
|
+
} finally {
|
|
325
|
+
vi.unstubAllEnvs();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("forwards the resolved token to `gh` as GH_TOKEN, CHANT_FORGEJO_TOKEN taking priority", async () => {
|
|
330
|
+
stubPrEnv("http://forgejo.example/api/v1");
|
|
331
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
|
|
332
|
+
ghReplies = [
|
|
333
|
+
{ match: "--paginate", stdout: "" },
|
|
334
|
+
{ match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
|
|
335
|
+
];
|
|
336
|
+
try {
|
|
337
|
+
await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
|
|
338
|
+
for (const call of ghCalls) expect(call.opts.env?.GH_TOKEN).toBe("cross-instance-token");
|
|
339
|
+
} finally {
|
|
340
|
+
vi.unstubAllEnvs();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("a pull request with no token at all is refused by name, before any `gh` call", async () => {
|
|
345
|
+
stubPrEnv("http://forgejo.example/api/v1");
|
|
346
|
+
vi.stubEnv("GH_TOKEN", "");
|
|
347
|
+
vi.stubEnv("GITHUB_TOKEN", "");
|
|
348
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
|
|
349
|
+
try {
|
|
350
|
+
await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
|
|
351
|
+
noCommentTokenMessage(repo, 5),
|
|
352
|
+
);
|
|
353
|
+
expect(ghCalls).toHaveLength(0);
|
|
354
|
+
} finally {
|
|
355
|
+
vi.unstubAllEnvs();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
165
360
|
// ── The GitLab merge-request note (#2256) ───────────────────────────────────
|
|
166
361
|
|
|
167
362
|
describe("mergeRequestContextFrom (#2256)", () => {
|
|
@@ -397,3 +592,820 @@ describe("the no-context refusal names both forges' variables (#2256)", () => {
|
|
|
397
592
|
}
|
|
398
593
|
});
|
|
399
594
|
});
|
|
595
|
+
|
|
596
|
+
// ── The GitLab issue (#2292) ─────────────────────────────────────────────
|
|
597
|
+
|
|
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"));
|
|
603
|
+
});
|
|
604
|
+
|
|
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- -->");
|
|
608
|
+
expect(marker).not.toMatch(/["'\\]/);
|
|
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
|
+
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
describe("gitlabProjectContextFrom (#2292)", () => {
|
|
843
|
+
const gitlabEnv = {
|
|
844
|
+
CI_API_V4_URL: "https://gitlab.com/api/v4",
|
|
845
|
+
CI_PROJECT_ID: "42",
|
|
846
|
+
CI_PROJECT_PATH: "acme/infra",
|
|
847
|
+
CI_PROJECT_URL: "https://gitlab.com/acme/infra",
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
test("reads the project off any GitLab CI job, with no merge request in sight", () => {
|
|
851
|
+
expect(gitlabProjectContextFrom(gitlabEnv)).toEqual({
|
|
852
|
+
api: "https://gitlab.com/api/v4",
|
|
853
|
+
project: "42",
|
|
854
|
+
path: "acme/infra",
|
|
855
|
+
webUrl: "https://gitlab.com/acme/infra",
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("derives the API base from CI_SERVER_URL when CI_API_V4_URL is unset", () => {
|
|
860
|
+
const { CI_API_V4_URL: _drop, ...rest } = gitlabEnv;
|
|
861
|
+
expect(gitlabProjectContextFrom({ ...rest, CI_SERVER_URL: "https://gl.example.com" })?.api).toBe(
|
|
862
|
+
"https://gl.example.com/api/v4",
|
|
863
|
+
);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("a GitHub Actions run is not mistaken for a GitLab one", () => {
|
|
867
|
+
expect(
|
|
868
|
+
gitlabProjectContextFrom({ GITHUB_REPOSITORY: "INTENTIUS/chant", GITHUB_REF: "refs/pull/1/merge" }),
|
|
869
|
+
).toBeUndefined();
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("no CI_PROJECT_ID at all is undefined", () => {
|
|
873
|
+
expect(gitlabProjectContextFrom({})).toBeUndefined();
|
|
874
|
+
expect(gitlabProjectContextFrom({ CI_PROJECT_ID: "" })).toBeUndefined();
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
describe("reconcilePr issue mode on GitLab opens/updates one issue (#2292)", () => {
|
|
879
|
+
const gitlabEnv: Record<string, string> = {
|
|
880
|
+
CI_API_V4_URL: "https://gitlab.com/api/v4",
|
|
881
|
+
CI_PROJECT_ID: "42",
|
|
882
|
+
CI_PROJECT_PATH: "acme/infra",
|
|
883
|
+
CI_PROJECT_URL: "https://gitlab.com/acme/infra",
|
|
884
|
+
GITLAB_TOKEN: "glpat-x",
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
function stubGitlabEnv(): void {
|
|
888
|
+
for (const [k, v] of Object.entries(gitlabEnv)) vi.stubEnv(k, v);
|
|
889
|
+
// Neither GitHub's variables nor a merge-request iid are set on a plain
|
|
890
|
+
// GitLab CI job (cron or push); make that explicit so the GitHub path and
|
|
891
|
+
// the note path can never be the ones under test here.
|
|
892
|
+
for (const k of ["GITHUB_REPOSITORY", "GITHUB_REF", "GITHUB_EVENT_PATH", "CI_MERGE_REQUEST_IID"]) {
|
|
893
|
+
vi.stubEnv(k, "");
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
test("POSTs a new issue carrying the marker as the description's first line", async () => {
|
|
898
|
+
stubGitlabEnv();
|
|
899
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
900
|
+
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
|
901
|
+
calls.push({ url, init });
|
|
902
|
+
if (!init?.method || init.method === "GET") return gitlabResponse([]);
|
|
903
|
+
return gitlabResponse({ iid: 9 });
|
|
904
|
+
});
|
|
905
|
+
try {
|
|
906
|
+
const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "the plan" });
|
|
907
|
+
expect(result.issueUrl).toBe("https://gitlab.com/acme/infra/-/issues/9");
|
|
908
|
+
const post = calls[calls.length - 1];
|
|
909
|
+
expect(post.url).toBe("https://gitlab.com/api/v4/projects/42/issues");
|
|
910
|
+
expect(post.init?.method).toBe("POST");
|
|
911
|
+
expect((post.init?.headers as Record<string, string>)["PRIVATE-TOKEN"]).toBe("glpat-x");
|
|
912
|
+
const sent = JSON.parse(String(post.init?.body));
|
|
913
|
+
expect(sent.description).toBe("<!-- chant-reconcile-issue:nightly/app -->\n\nthe plan");
|
|
914
|
+
expect(sent.title).toContain("Reconcile app");
|
|
915
|
+
} finally {
|
|
916
|
+
vi.unstubAllEnvs();
|
|
917
|
+
vi.unstubAllGlobals();
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("PUTs the issue it already owns instead of opening a second one", async () => {
|
|
922
|
+
stubGitlabEnv();
|
|
923
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
924
|
+
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
|
925
|
+
calls.push({ url, init });
|
|
926
|
+
if (!init?.method || init.method === "GET") {
|
|
927
|
+
return gitlabResponse([
|
|
928
|
+
{ iid: 1, description: "unrelated issue" },
|
|
929
|
+
{ iid: 4, description: "<!-- chant-reconcile-issue:nightly/app -->\n\nan older finding" },
|
|
930
|
+
]);
|
|
931
|
+
}
|
|
932
|
+
return gitlabResponse({ iid: 4 });
|
|
933
|
+
});
|
|
934
|
+
try {
|
|
935
|
+
const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "a newer finding" });
|
|
936
|
+
const write = calls[calls.length - 1];
|
|
937
|
+
expect(write.init?.method).toBe("PUT");
|
|
938
|
+
expect(write.url).toBe("https://gitlab.com/api/v4/projects/42/issues/4");
|
|
939
|
+
expect(result.issueUrl).toBe("https://gitlab.com/acme/infra/-/issues/4");
|
|
940
|
+
} finally {
|
|
941
|
+
vi.unstubAllEnvs();
|
|
942
|
+
vi.unstubAllGlobals();
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("searches narrowly by marker rather than paging every issue the project has", async () => {
|
|
947
|
+
stubGitlabEnv();
|
|
948
|
+
const seen: string[] = [];
|
|
949
|
+
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
|
950
|
+
if (!init?.method || init.method === "GET") {
|
|
951
|
+
seen.push(url);
|
|
952
|
+
return gitlabResponse([]);
|
|
953
|
+
}
|
|
954
|
+
return gitlabResponse({ iid: 9 });
|
|
955
|
+
});
|
|
956
|
+
try {
|
|
957
|
+
await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "new" });
|
|
958
|
+
expect(seen).toHaveLength(1);
|
|
959
|
+
expect(seen[0]).toContain(`search=${encodeURIComponent(issueMarker("nightly", "app"))}`);
|
|
960
|
+
expect(seen[0]).toContain("in=description");
|
|
961
|
+
} finally {
|
|
962
|
+
vi.unstubAllEnvs();
|
|
963
|
+
vi.unstubAllGlobals();
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
test("a rejected write fails the step by name, carrying GitLab's own status", async () => {
|
|
968
|
+
stubGitlabEnv();
|
|
969
|
+
vi.stubGlobal("fetch", async (_url: string, init?: RequestInit) => {
|
|
970
|
+
if (!init?.method || init.method === "GET") return gitlabResponse([]);
|
|
971
|
+
return {
|
|
972
|
+
ok: false,
|
|
973
|
+
status: 403,
|
|
974
|
+
headers: { get: () => null },
|
|
975
|
+
json: async () => ({}),
|
|
976
|
+
text: async () => '{"message":"403 Forbidden"}',
|
|
977
|
+
} as unknown as Response;
|
|
978
|
+
});
|
|
979
|
+
try {
|
|
980
|
+
await expect(reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" })).rejects.toThrow(
|
|
981
|
+
/projects\/42\/issues.*403.*403 Forbidden/s,
|
|
982
|
+
);
|
|
983
|
+
} finally {
|
|
984
|
+
vi.unstubAllEnvs();
|
|
985
|
+
vi.unstubAllGlobals();
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test("a GitLab run with no token names the mode, the project, and every variable it looked for", async () => {
|
|
990
|
+
for (const [k, v] of Object.entries(gitlabEnv)) vi.stubEnv(k, k === "GITLAB_TOKEN" ? "" : v);
|
|
991
|
+
vi.stubEnv("CI_JOB_TOKEN", "");
|
|
992
|
+
vi.stubEnv("CHANT_GITLAB_TOKEN", "");
|
|
993
|
+
for (const k of ["GITHUB_REPOSITORY", "GITHUB_REF", "GITHUB_EVENT_PATH", "CI_MERGE_REQUEST_IID"]) {
|
|
994
|
+
vi.stubEnv(k, "");
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
await expect(reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" })).rejects.toThrow(
|
|
998
|
+
/mode "issue".*project 42.*GITLAB_TOKEN.*CI_JOB_TOKEN/s,
|
|
999
|
+
);
|
|
1000
|
+
} finally {
|
|
1001
|
+
vi.unstubAllEnvs();
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// ── The GitHub/GHES/Forgejo sticky issue (#2297) ────────────────────────────
|
|
1007
|
+
|
|
1008
|
+
describe("reconcilePr issue mode on GitHub/GHES/Forgejo opens/updates one issue (#2297)", () => {
|
|
1009
|
+
const repo = "acme/infra";
|
|
1010
|
+
|
|
1011
|
+
function stubGithubIssueEnv(apiUrl: string): void {
|
|
1012
|
+
vi.stubEnv("GITHUB_REPOSITORY", repo);
|
|
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");
|
|
1018
|
+
// Never the GitLab path or the comment/PR path.
|
|
1019
|
+
vi.stubEnv("CI_PROJECT_ID", "");
|
|
1020
|
+
vi.stubEnv("CI_MERGE_REQUEST_IID", "");
|
|
1021
|
+
vi.stubEnv("GITHUB_REF", "");
|
|
1022
|
+
vi.stubEnv("GITHUB_EVENT_PATH", "");
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
test("POSTs a new issue carrying the marker as the body's first line, at a full URL", async () => {
|
|
1026
|
+
stubGithubIssueEnv("http://forgejo.example/api/v1");
|
|
1027
|
+
ghReplies = [
|
|
1028
|
+
{ match: "--paginate", stdout: "" }, // no owned issue yet
|
|
1029
|
+
{ match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/9\n" },
|
|
1030
|
+
];
|
|
1031
|
+
try {
|
|
1032
|
+
const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "the plan" });
|
|
1033
|
+
expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/9");
|
|
1034
|
+
|
|
1035
|
+
const [list, post] = ghCalls;
|
|
1036
|
+
expect(list.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues?state=open");
|
|
1037
|
+
expect(list.cmd).toContain("--paginate");
|
|
1038
|
+
expect(post.cmd).toContain("--method POST");
|
|
1039
|
+
expect(post.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues");
|
|
1040
|
+
expect(post.cmd).toContain(`title=Reconcile app`);
|
|
1041
|
+
expect(post.cmd).toContain("<!-- chant-reconcile-issue:nightly/app -->\n\nthe plan");
|
|
1042
|
+
} finally {
|
|
1043
|
+
vi.unstubAllEnvs();
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
test("a re-run PATCHes the issue it already owns instead of opening a second one", async () => {
|
|
1048
|
+
stubGithubIssueEnv("http://forgejo.example/api/v1");
|
|
1049
|
+
ghReplies = [
|
|
1050
|
+
{ match: "--paginate", stdout: "57\n" }, // the marker search found issue 57
|
|
1051
|
+
{ match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/57\n" },
|
|
1052
|
+
];
|
|
1053
|
+
try {
|
|
1054
|
+
const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "a newer plan" });
|
|
1055
|
+
expect(result.issueUrl).toBe("http://forgejo.example/acme/infra/issues/57");
|
|
1056
|
+
|
|
1057
|
+
const patchCall = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
|
|
1058
|
+
const postCall = ghCalls.find((c) => c.cmd.includes("--method POST"));
|
|
1059
|
+
expect(patchCall?.cmd).toContain("http://forgejo.example/api/v1/repos/acme/infra/issues/57");
|
|
1060
|
+
expect(postCall).toBeUndefined();
|
|
1061
|
+
} finally {
|
|
1062
|
+
vi.unstubAllEnvs();
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
test("searches only OPEN issues, and never mistakes a pull request for one it owns", async () => {
|
|
1067
|
+
stubGithubIssueEnv("");
|
|
1068
|
+
ghReplies = [
|
|
1069
|
+
{ match: "--paginate", stdout: "" },
|
|
1070
|
+
{ match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
|
|
1071
|
+
];
|
|
1072
|
+
try {
|
|
1073
|
+
await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
|
|
1074
|
+
const [list] = ghCalls;
|
|
1075
|
+
expect(list.cmd).toContain("state=open");
|
|
1076
|
+
expect(list.cmd).toContain(".pull_request == null");
|
|
1077
|
+
} finally {
|
|
1078
|
+
vi.unstubAllEnvs();
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
test("defaults to github.com's API base when GITHUB_API_URL is unset", async () => {
|
|
1083
|
+
stubGithubIssueEnv("");
|
|
1084
|
+
ghReplies = [
|
|
1085
|
+
{ match: "--paginate", stdout: "" },
|
|
1086
|
+
{ match: "--method POST", stdout: "https://github.com/acme/infra/issues/1\n" },
|
|
1087
|
+
];
|
|
1088
|
+
try {
|
|
1089
|
+
await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
|
|
1090
|
+
expect(ghCalls[0].cmd).toContain("https://api.github.com/repos/acme/infra/issues?state=open");
|
|
1091
|
+
} finally {
|
|
1092
|
+
vi.unstubAllEnvs();
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
test("falls back to gh issue create, still marker-prefixed, outside any known CI job", async () => {
|
|
1097
|
+
// Neither GITHUB_REPOSITORY nor CI_PROJECT_ID: a local or manual run.
|
|
1098
|
+
vi.stubEnv("GITHUB_REPOSITORY", "");
|
|
1099
|
+
vi.stubEnv("CI_PROJECT_ID", "");
|
|
1100
|
+
vi.stubEnv("CI_MERGE_REQUEST_IID", "");
|
|
1101
|
+
vi.stubEnv("GITHUB_REF", "");
|
|
1102
|
+
vi.stubEnv("GITHUB_EVENT_PATH", "");
|
|
1103
|
+
ghReplies = [{ match: "gh issue create", stdout: "https://github.com/acme/infra/issues/3\n" }];
|
|
1104
|
+
try {
|
|
1105
|
+
const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
|
|
1106
|
+
expect(result.issueUrl).toBe("https://github.com/acme/infra/issues/3");
|
|
1107
|
+
expect(ghCalls).toHaveLength(1);
|
|
1108
|
+
expect(ghCalls[0].cmd).toContain("gh issue create");
|
|
1109
|
+
expect(ghCalls[0].cmd).toContain("<!-- chant-reconcile-issue:nightly/app -->\n\nplan");
|
|
1110
|
+
} finally {
|
|
1111
|
+
vi.unstubAllEnvs();
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test("GitLab takes priority when both a GitLab and a GitHub signal are somehow present", async () => {
|
|
1116
|
+
stubGithubIssueEnv("");
|
|
1117
|
+
vi.stubEnv("CI_PROJECT_ID", "42");
|
|
1118
|
+
vi.stubEnv("CI_API_V4_URL", "https://gitlab.com/api/v4");
|
|
1119
|
+
vi.stubEnv("GITLAB_TOKEN", "glpat-x");
|
|
1120
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
1121
|
+
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
|
1122
|
+
calls.push({ url, init });
|
|
1123
|
+
if (!init?.method || init.method === "GET") return gitlabResponse([]);
|
|
1124
|
+
return gitlabResponse({ iid: 5 });
|
|
1125
|
+
});
|
|
1126
|
+
try {
|
|
1127
|
+
const result = await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
|
|
1128
|
+
expect(result.issueUrl).toBe("https://gitlab.com/api/v4/projects/42/issues/5");
|
|
1129
|
+
expect(ghCalls).toHaveLength(0);
|
|
1130
|
+
} finally {
|
|
1131
|
+
vi.unstubAllEnvs();
|
|
1132
|
+
vi.unstubAllGlobals();
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
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
|
+
|
|
1383
|
+
describe("postOrUpdateGithubIssue directly (#2297)", () => {
|
|
1384
|
+
test("PATCHes with both title and body fields so the headline stays current", async () => {
|
|
1385
|
+
const calls: string[] = [];
|
|
1386
|
+
const exec = async (cmd: string) => {
|
|
1387
|
+
calls.push(cmd);
|
|
1388
|
+
if (cmd.includes("--paginate")) return { stdout: "12\n", stderr: "" };
|
|
1389
|
+
return { stdout: "https://github.example/acme/infra/issues/12\n", stderr: "" };
|
|
1390
|
+
};
|
|
1391
|
+
vi.stubEnv("GITHUB_API_URL", "");
|
|
1392
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
|
|
1393
|
+
vi.stubEnv("GH_TOKEN", "ghs-ambient");
|
|
1394
|
+
try {
|
|
1395
|
+
const url = await postOrUpdateGithubIssue(
|
|
1396
|
+
"acme/infra",
|
|
1397
|
+
issueMarker("nightly", "app"),
|
|
1398
|
+
"Reconcile app: 3 change(s) from live",
|
|
1399
|
+
"the body",
|
|
1400
|
+
exec,
|
|
1401
|
+
);
|
|
1402
|
+
expect(url).toBe("https://github.example/acme/infra/issues/12");
|
|
1403
|
+
const patch = calls.find((c) => c.includes("--method PATCH"));
|
|
1404
|
+
expect(patch).toContain("title=Reconcile app: 3 change(s) from live");
|
|
1405
|
+
expect(patch).toContain("<!-- chant-reconcile-issue:nightly/app -->\n\nthe body");
|
|
1406
|
+
expect(patch).toContain("/repos/acme/infra/issues/12");
|
|
1407
|
+
} finally {
|
|
1408
|
+
vi.unstubAllEnvs();
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
});
|