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