@intentius/chant 0.124.6 → 0.124.8
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/lifecycle/rollback.d.ts.map +1 -1
- package/dist/op/activities/forge-pull-request.d.ts +49 -0
- package/dist/op/activities/forge-pull-request.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +7 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +6 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/pr-forge.d.ts +101 -7
- package/dist/pr-forge.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/lifecycle/rollback.ts +10 -2
- package/src/op/activities/forge-pull-request.test.ts +91 -0
- package/src/op/activities/forge-pull-request.ts +98 -0
- package/src/op/activities/lexicon-upgrade.test.ts +58 -0
- package/src/op/activities/lexicon-upgrade.ts +34 -8
- package/src/op/activities/propose-upgrade.ts +13 -0
- package/src/op/activities/reconcile.ts +10 -2
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.test.ts +212 -0
- package/src/pr-forge.ts +291 -13
|
@@ -739,3 +739,61 @@ describe("a lexicon with several pins (#3288)", () => {
|
|
|
739
739
|
expect(upgradePrTitle("sql", "17.11", "17.12", "postgres-17")).toBe("feat(sql): upgrade postgres-17 17.11 to 17.12");
|
|
740
740
|
});
|
|
741
741
|
});
|
|
742
|
+
|
|
743
|
+
// ── Forgejo and GitLab open the PR over REST (#3798) ──────────────────
|
|
744
|
+
|
|
745
|
+
describe("lexiconUpgrade pull request over REST", () => {
|
|
746
|
+
const pin = {
|
|
747
|
+
file: "src/pin.ts",
|
|
748
|
+
pattern: /V = "(.+)"/,
|
|
749
|
+
replace: (v: string) => `V = "${v}"`,
|
|
750
|
+
upstream: { owner: "o", repo: "r", kind: "releases" as const },
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
function fakeForge(open?: { number: number; url: string; body: string }) {
|
|
754
|
+
const calls: string[] = [];
|
|
755
|
+
const forge = {
|
|
756
|
+
findPullRequest: vi.fn(async (head: string) => (calls.push(`find ${head}`), open ? { ...open, title: "t", head } : undefined)),
|
|
757
|
+
openPullRequest: vi.fn(async (spec: { head: string; base?: string }) => (calls.push(`open ${spec.head} -> ${spec.base}`), { number: 5, url: "https://forge/pulls/5", title: "t", body: "b", head: spec.head })),
|
|
758
|
+
editPullRequest: vi.fn(async (n: number) => (calls.push(`edit ${n}`), undefined)),
|
|
759
|
+
addLabels: vi.fn(async (n: number, labels: readonly string[]) => void calls.push(`label ${n} ${labels.join(",")}`)),
|
|
760
|
+
};
|
|
761
|
+
return { forge: forge as unknown as import("../../pr-forge").PrForge, calls };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function run(forge: import("../../pr-forge").PrForge) {
|
|
765
|
+
const { gh, calls } = recordingGh();
|
|
766
|
+
const dir = join(tmpdir(), `chant-3798-${process.pid}`);
|
|
767
|
+
mkdirSync(dir, { recursive: true });
|
|
768
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "0.1.0" }));
|
|
769
|
+
const r = await lexiconUpgrade({
|
|
770
|
+
lexicon: "k3s",
|
|
771
|
+
lexiconDir: dir,
|
|
772
|
+
mode: "pull-request",
|
|
773
|
+
_loadPin: async () => pin,
|
|
774
|
+
_checkPinned: vi.fn(async () => pinnedResult()),
|
|
775
|
+
_applyBump: vi.fn(async () => ({ filePath: "/x/src/pin.ts" })),
|
|
776
|
+
_bumpPackageVersion: vi.fn(),
|
|
777
|
+
_gh: gh,
|
|
778
|
+
_forge: forge,
|
|
779
|
+
});
|
|
780
|
+
return { r, ghCalls: calls };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
test("opens the pull request and labels it through the forge, with no gh pr call", async () => {
|
|
784
|
+
const { forge, calls } = fakeForge();
|
|
785
|
+
const { r, ghCalls } = await run(forge);
|
|
786
|
+
expect(r.prUrl).toBe("https://forge/pulls/5");
|
|
787
|
+
expect(calls.filter((c) => c.startsWith("open"))).toEqual([`open ${upgradeBranchName("k3s")} -> main`]);
|
|
788
|
+
expect(calls.some((c) => c.startsWith("label"))).toBe(r.semverLabel !== undefined && r.semverLabel !== null);
|
|
789
|
+
expect(ghCalls.filter((c) => c.startsWith("gh "))).toEqual([]);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("edits the open pull request in place of opening another", async () => {
|
|
793
|
+
const { forge, calls } = fakeForge({ number: 9, url: "https://forge/pulls/9", body: "old" });
|
|
794
|
+
const { r } = await run(forge);
|
|
795
|
+
expect(r.prUrl).toBe("https://forge/pulls/9");
|
|
796
|
+
expect(calls).toContain("edit 9");
|
|
797
|
+
expect(calls.some((c) => c.startsWith("open"))).toBe(false);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
@@ -31,6 +31,8 @@ import type { UpgradeCheckResult, LexiconId, UpstreamPin } from "../../codegen/p
|
|
|
31
31
|
import type { RollingUpgradeResult, RollingLexicon } from "../../codegen/rolling-upgrade";
|
|
32
32
|
import { bumpForSeverity, bumpPackageJsonVersion } from "../../codegen/version-bump";
|
|
33
33
|
import { ghHostEnv } from "./gh-host";
|
|
34
|
+
import { restPullRequestForge } from "./forge-pull-request";
|
|
35
|
+
import type { PrForge } from "../../pr-forge";
|
|
34
36
|
import { postOrUpdateGithubIssue } from "./reconcile";
|
|
35
37
|
|
|
36
38
|
export { bumpPackageJsonVersion };
|
|
@@ -99,6 +101,12 @@ export interface LexiconUpgradeArgs {
|
|
|
99
101
|
* Defaults to bumpPackageJsonVersion.
|
|
100
102
|
*/
|
|
101
103
|
_bumpPackageVersion?: BumpPackageVersionFn;
|
|
104
|
+
/**
|
|
105
|
+
* The REST client the pull request is found, opened and labelled through,
|
|
106
|
+
* or null for `gh`. Default: one on a Forgejo or GitLab job, `gh`
|
|
107
|
+
* elsewhere (#3798). For tests.
|
|
108
|
+
*/
|
|
109
|
+
_forge?: PrForge | null;
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
export interface LexiconUpgradeResult {
|
|
@@ -416,7 +424,8 @@ async function postLexiconIssue(
|
|
|
416
424
|
* Check whether an open PR exists for the given branch.
|
|
417
425
|
* Returns the PR URL, or null if none.
|
|
418
426
|
*/
|
|
419
|
-
async function openPrUrl(branch: string, gh: GhRunner): Promise<string | null> {
|
|
427
|
+
async function openPrUrl(branch: string, gh: GhRunner, forge?: PrForge): Promise<string | null> {
|
|
428
|
+
if (forge) return (await forge.findPullRequest(branch))?.url ?? null;
|
|
420
429
|
try {
|
|
421
430
|
const { stdout } = await gh(
|
|
422
431
|
`gh pr view ${shellQuote(branch)} --json url,state --jq 'select(.state=="OPEN") | .url'`,
|
|
@@ -432,7 +441,8 @@ async function openPrUrl(branch: string, gh: GhRunner): Promise<string | null> {
|
|
|
432
441
|
* Read the body of the open PR for `branch`, if any.
|
|
433
442
|
* Returns null when there is no open PR or gh is unavailable.
|
|
434
443
|
*/
|
|
435
|
-
async function currentPrBody(branch: string, gh: GhRunner): Promise<string | null> {
|
|
444
|
+
async function currentPrBody(branch: string, gh: GhRunner, forge?: PrForge): Promise<string | null> {
|
|
445
|
+
if (forge) return (await forge.findPullRequest(branch))?.body.trim() || null;
|
|
436
446
|
try {
|
|
437
447
|
const { stdout } = await gh(
|
|
438
448
|
`gh pr view ${shellQuote(branch)} --json body,state --jq 'select(.state=="OPEN") | .body'`,
|
|
@@ -750,9 +760,12 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
|
|
|
750
760
|
|
|
751
761
|
// Idempotency check: if the open PR already has this summary, nothing changed.
|
|
752
762
|
// Compare trimmed — gh/git round-trips can alter trailing whitespace.
|
|
753
|
-
|
|
763
|
+
// `gh pr` speaks GraphQL, which Forgejo does not serve: a Forgejo or
|
|
764
|
+
// GitLab job finds, opens and labels the pull request over REST (#3798).
|
|
765
|
+
const forge = args._forge !== undefined ? (args._forge ?? undefined) : await restPullRequestForge(process.env);
|
|
766
|
+
const existingPrBody = await currentPrBody(branch, gh, forge);
|
|
754
767
|
if (existingPrBody !== null && existingPrBody.trim() === summary.trim()) {
|
|
755
|
-
const existingUrl = await openPrUrl(branch, gh);
|
|
768
|
+
const existingUrl = await openPrUrl(branch, gh, forge);
|
|
756
769
|
return {
|
|
757
770
|
lexicon,
|
|
758
771
|
mode,
|
|
@@ -825,10 +838,23 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
|
|
|
825
838
|
});
|
|
826
839
|
|
|
827
840
|
// Open or update the PR.
|
|
828
|
-
const existingUrl = await openPrUrl(branch, gh);
|
|
829
841
|
let prUrl: string;
|
|
830
|
-
|
|
831
|
-
|
|
842
|
+
const open = forge ? await forge.findPullRequest(branch) : undefined;
|
|
843
|
+
const existingUrl = forge ? null : await openPrUrl(branch, gh);
|
|
844
|
+
|
|
845
|
+
if (forge) {
|
|
846
|
+
let pr = open;
|
|
847
|
+
if (pr) {
|
|
848
|
+
const edited = await forge.editPullRequest(pr.number, { title, body: summary });
|
|
849
|
+
prUrl = edited?.url ?? pr.url ?? "";
|
|
850
|
+
} else {
|
|
851
|
+
pr = await forge.openPullRequest({ head: branch, base: "main", title, body: summary });
|
|
852
|
+
prUrl = pr.url ?? "";
|
|
853
|
+
}
|
|
854
|
+
// Best-effort, as below. Forgejo adds only a label the repository
|
|
855
|
+
// already has; GitHub and GitLab make one.
|
|
856
|
+
if (semverLabel) await forge.addLabels(pr.number, [semverLabel]).catch(() => {/* best-effort */});
|
|
857
|
+
} else if (existingUrl) {
|
|
832
858
|
await gh(`gh pr edit ${shellQuote(branch)} --title ${shellQuote(title)} --body ${shellQuote(summary)}`);
|
|
833
859
|
prUrl = existingUrl;
|
|
834
860
|
} else {
|
|
@@ -838,7 +864,7 @@ export async function lexiconUpgrade(args: LexiconUpgradeArgs): Promise<LexiconU
|
|
|
838
864
|
prUrl = stdout.trim();
|
|
839
865
|
}
|
|
840
866
|
|
|
841
|
-
if (semverLabel) {
|
|
867
|
+
if (semverLabel && !forge) {
|
|
842
868
|
await ensureLabelOnPr(branch, semverLabel, gh);
|
|
843
869
|
}
|
|
844
870
|
|
|
@@ -27,6 +27,8 @@ import { execFile } from "node:child_process";
|
|
|
27
27
|
import { promisify } from "node:util";
|
|
28
28
|
import type { StagedUpgrade } from "../../workspace/lineage-upgrade";
|
|
29
29
|
import { ghHostEnv } from "./gh-host";
|
|
30
|
+
import { restPullRequestForge, upsertPullRequest } from "./forge-pull-request";
|
|
31
|
+
import type { PrForge } from "../../pr-forge";
|
|
30
32
|
|
|
31
33
|
const execFileAsync = promisify(execFile);
|
|
32
34
|
|
|
@@ -52,6 +54,11 @@ export interface ProposeWorkspaceUpgradeArgs {
|
|
|
52
54
|
cwd?: string;
|
|
53
55
|
/** Replaces the git and gh child processes. For tests. */
|
|
54
56
|
_run?: CommandRunner;
|
|
57
|
+
/**
|
|
58
|
+
* The REST client the pull request is opened through, or null for `gh`.
|
|
59
|
+
* Default: one on a Forgejo or GitLab job, `gh` elsewhere (#3798). For tests.
|
|
60
|
+
*/
|
|
61
|
+
_forge?: PrForge | null;
|
|
55
62
|
/** Replaces build and lint in the worktree. For tests. */
|
|
56
63
|
_runChant?: import("../../workspace/lineage-upgrade").ChantRunner;
|
|
57
64
|
}
|
|
@@ -198,6 +205,12 @@ export async function proposeWorkspaceUpgrade(args: ProposeWorkspaceUpgradeArgs)
|
|
|
198
205
|
if (!hasRemote) throw new Error(`proposeWorkspaceUpgrade: no remote "${remote}" to open a pull request on`);
|
|
199
206
|
|
|
200
207
|
const text = body(staged, lines);
|
|
208
|
+
// `gh pr` speaks GraphQL, which Forgejo does not serve (#3798).
|
|
209
|
+
const forge = args._forge !== undefined ? args._forge : await restPullRequestForge(process.env, { cwd: staged.repo, remote });
|
|
210
|
+
if (forge) {
|
|
211
|
+
result.prUrl = (await upsertPullRequest(forge, { head: branch, base: base!, title, body: text })).pr.url;
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
201
214
|
const existing = (await run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url // empty"], staged.repo)).trim();
|
|
202
215
|
if (existing) {
|
|
203
216
|
await run("gh", ["pr", "edit", existing, "--title", title, "--body", text], staged.repo);
|
|
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { ForgeApiError, forgejoForge, githubForge, type ForgeFetch, type GithubShapedForge } from "../../pr-forge";
|
|
5
5
|
import { ghHostEnv } from "./gh-host";
|
|
6
|
+
import { restPullRequestForge } from "./forge-pull-request";
|
|
6
7
|
|
|
7
8
|
const execAsync = promisify(exec);
|
|
8
9
|
|
|
@@ -1363,8 +1364,15 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
|
|
|
1363
1364
|
await execAsync(`git add ${shellQuote(output)}`, { signal });
|
|
1364
1365
|
await execAsync(`git commit -m ${shellQuote(title)}`, { signal });
|
|
1365
1366
|
await execAsync(`git push -u origin ${shellQuote(branch)}`, { signal });
|
|
1366
|
-
// `gh pr
|
|
1367
|
-
//
|
|
1367
|
+
// `gh pr` speaks GraphQL, which Forgejo does not serve, and GitLab has no
|
|
1368
|
+
// `gh`: a Forgejo or GitLab job opens the pull request over REST (#3798).
|
|
1369
|
+
const forge = await restPullRequestForge(process.env, { fetch: (url, init) => globalThis.fetch(url, { ...init, ...(signal ? { signal } : {}) }) });
|
|
1370
|
+
if (forge) {
|
|
1371
|
+
const pr = await forge.openPullRequest({ head: branch, title, body: summary });
|
|
1372
|
+
return { mode, branch, summary, entries, prUrl: pr.url ?? "" };
|
|
1373
|
+
}
|
|
1374
|
+
// `gh pr create` names no host, so on GitHub Enterprise Server it is told
|
|
1375
|
+
// the job's own (#3780). See `./gh-host.ts`.
|
|
1368
1376
|
const { stdout } = await execAsync(
|
|
1369
1377
|
`gh pr create --title ${shellQuote(title)} --body ${shellQuote(summary)} --head ${shellQuote(branch)}`,
|
|
1370
1378
|
{ signal, env: await ghHostEnv(process.env) },
|
|
@@ -34,7 +34,7 @@ describe("the pr-forge bundle", () => {
|
|
|
34
34
|
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
35
35
|
}
|
|
36
36
|
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
|
|
37
|
-
// About
|
|
37
|
+
// About 27 KB today.
|
|
38
38
|
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(32 * 1024);
|
|
39
39
|
});
|
|
40
40
|
});
|
package/src/pr-forge.test.ts
CHANGED
|
@@ -14,9 +14,12 @@ import {
|
|
|
14
14
|
forgeTargetFromEnv,
|
|
15
15
|
forgejoForge,
|
|
16
16
|
forgePrincipalOf,
|
|
17
|
+
FORGE_STATUS_STATES,
|
|
17
18
|
githubForge,
|
|
18
19
|
gitlabForge,
|
|
20
|
+
statusStateFor,
|
|
19
21
|
type ForgeFetch,
|
|
22
|
+
type ForgeStatusState,
|
|
20
23
|
} from "./pr-forge";
|
|
21
24
|
import { forgePrincipal, parseForgeIdentity } from "./workspace/identity";
|
|
22
25
|
|
|
@@ -675,3 +678,212 @@ describe("addComment and findIssue (#3818)", () => {
|
|
|
675
678
|
expect(fj.calls[0].headers.Authorization).toBeUndefined();
|
|
676
679
|
});
|
|
677
680
|
});
|
|
681
|
+
|
|
682
|
+
describe("status states and author lists (#3834)", () => {
|
|
683
|
+
const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
|
|
684
|
+
|
|
685
|
+
test("each state goes to each forge as its nearest state", () => {
|
|
686
|
+
const table = Object.fromEntries(FORGE_STATUS_STATES.map((s) => [s, [statusStateFor("github", s), statusStateFor("forgejo", s), statusStateFor("gitlab", s)]]));
|
|
687
|
+
expect(table).toEqual({
|
|
688
|
+
pending: ["pending", "pending", "pending"],
|
|
689
|
+
running: ["pending", "pending", "running"],
|
|
690
|
+
success: ["success", "success", "success"],
|
|
691
|
+
failed: ["failure", "failure", "failed"],
|
|
692
|
+
failure: ["failure", "failure", "failed"],
|
|
693
|
+
canceled: ["error", "error", "canceled"],
|
|
694
|
+
error: ["error", "error", "failed"],
|
|
695
|
+
});
|
|
696
|
+
expect(() => statusStateFor("gitlab", "skipped" as ForgeStatusState)).toThrow(/one of pending, running/);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("a running status is running on GitLab and pending on GitHub and Forgejo", async () => {
|
|
700
|
+
const gl = fakeFetch({ [`POST /api/v4/projects/42/statuses/${SHA}`]: {} });
|
|
701
|
+
await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).setStatus(SHA, { context: "terragucci/plan", state: "running", description: "planning" });
|
|
702
|
+
await gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch: gl.fetch }).setStatus(SHA, { context: "terragucci/plan", state: "canceled", description: "stopped" });
|
|
703
|
+
expect(gl.calls.map((c) => (c.body as { state: string }).state)).toEqual(["running", "canceled"]);
|
|
704
|
+
const g = fakeFetch({ [`POST /repos/acme/infra/statuses/${SHA}`]: {} });
|
|
705
|
+
await githubForge({ ...gh, fetch: g.fetch }).setStatus(SHA, { context: "terragucci/plan", state: "running", description: "planning" });
|
|
706
|
+
await githubForge({ ...gh, fetch: g.fetch }).setStatus(SHA, { context: "terragucci/plan", state: "failed", description: "no" });
|
|
707
|
+
expect(g.calls.map((c) => (c.body as { state: string }).state)).toEqual(["pending", "failure"]);
|
|
708
|
+
const fj = fakeFetch({ [`POST /api/v1/repos/acme/infra/statuses/${SHA}`]: {} });
|
|
709
|
+
await forgejoForge({ ...gh, apiBase: "https://codeberg.org/api/v1", host: "codeberg.org", fetch: fj.fetch }).setStatus(SHA, { context: "c", state: "running", description: "d" });
|
|
710
|
+
expect(fj.calls[0].body).toMatchObject({ state: "pending" });
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const BOTS = ["github-actions[bot]", "gitea-actions", "forgejo-actions"];
|
|
714
|
+
|
|
715
|
+
test("a comment by any of several logins is the caller's own, without case", async () => {
|
|
716
|
+
const { fetch, calls } = fakeFetch({
|
|
717
|
+
"GET /api/v1/repos/acme/infra/issues/4/comments": [
|
|
718
|
+
{ id: 1, body: "<!-- tg -->\nplanted", user: { login: "mallory" } },
|
|
719
|
+
{ id: 2, body: "<!-- tg -->\nold", user: { login: "Forgejo-Actions" } },
|
|
720
|
+
],
|
|
721
|
+
"PATCH /api/v1/repos/acme/infra/issues/comments/2": { id: 2, html_url: "https://cb/2" },
|
|
722
|
+
});
|
|
723
|
+
const forge = forgejoForge({ ...gh, apiBase: "https://codeberg.org/api/v1", host: "codeberg.org", fetch });
|
|
724
|
+
expect(await forge.findComment(4, "<!-- tg -->", { author: BOTS })).toEqual({ id: 2, body: "<!-- tg -->\nold" });
|
|
725
|
+
expect(await forge.upsertComment(4, "<!-- tg -->", "<!-- tg -->\nnew", { author: BOTS })).toEqual({ action: "updated", id: 2, url: "https://cb/2" });
|
|
726
|
+
expect(calls.at(-1)?.url).toBe("https://codeberg.org/api/v1/repos/acme/infra/issues/comments/2");
|
|
727
|
+
expect(await forge.findComment(4, "<!-- tg -->", { author: [] })).toBeUndefined();
|
|
728
|
+
expect((await forge.findComment(4, "<!-- tg -->", { author: "mallory" }))?.id).toBe(1);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("an issue by any of several logins, for findIssue and trackIssue", async () => {
|
|
732
|
+
const { fetch, calls } = fakeFetch({
|
|
733
|
+
"GET /repos/acme/infra/issues": [
|
|
734
|
+
{ number: 3, title: "Drift", body: "<!-- d -->\nplanted", user: { login: "mallory" } },
|
|
735
|
+
{ number: 4, title: "Drift", body: "<!-- d -->\nold", user: { login: "github-actions[bot]" } },
|
|
736
|
+
],
|
|
737
|
+
"PATCH /repos/acme/infra/issues/4": { number: 4 },
|
|
738
|
+
});
|
|
739
|
+
const forge = githubForge({ ...gh, fetch });
|
|
740
|
+
expect((await forge.findIssue("<!-- d -->", { author: BOTS }))?.number).toBe(4);
|
|
741
|
+
expect(await forge.trackIssue("<!-- d -->", { title: "Drift", body: "<!-- d -->\nnew" }, { author: BOTS })).toMatchObject({ action: "updated", number: 4 });
|
|
742
|
+
expect(calls.at(-1)).toMatchObject({ method: "PATCH", url: "https://api.github.com/repos/acme/infra/issues/4" });
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
describe("pull requests over REST (#3798)", () => {
|
|
747
|
+
const gh = { apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k" };
|
|
748
|
+
const fjOpts = { ...gh, apiBase: "https://forge.example/api/v1", host: "forge.example" };
|
|
749
|
+
const gl = { apiBase: "https://gl/api/v4", project: "group/infra", token: "t" };
|
|
750
|
+
|
|
751
|
+
test("Forgejo opens a pull request into the default branch read from the repository", async () => {
|
|
752
|
+
const { fetch, calls } = fakeFetch({
|
|
753
|
+
"GET /api/v1/repos/acme/infra": { default_branch: "trunk" },
|
|
754
|
+
"POST /api/v1/repos/acme/infra/pulls": (c: Call) => ({ number: 9, html_url: "https://forge.example/acme/infra/pulls/9", title: (c.body as { title: string }).title, body: "b", head: { ref: "chant/x" }, base: { ref: "trunk" } }),
|
|
755
|
+
});
|
|
756
|
+
const pr = await forgejoForge({ ...fjOpts, fetch }).openPullRequest({ head: "chant/x", title: "t", body: "b" });
|
|
757
|
+
expect(pr).toEqual({ number: 9, url: "https://forge.example/acme/infra/pulls/9", title: "t", body: "b", head: "chant/x", base: "trunk" });
|
|
758
|
+
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual(["GET https://forge.example/api/v1/repos/acme/infra", "POST https://forge.example/api/v1/repos/acme/infra/pulls"]);
|
|
759
|
+
expect(calls[1].body).toEqual({ title: "t", body: "b", head: "chant/x", base: "trunk" });
|
|
760
|
+
expect(calls[1].headers.Authorization).toBe("token t0k");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("a draft is GitHub's draft field, Forgejo's WIP: and GitLab's Draft: prefix", async () => {
|
|
764
|
+
const g = fakeFetch({ "POST /repos/acme/infra/pulls": { number: 1 } });
|
|
765
|
+
await githubForge({ ...gh, fetch: g.fetch }).openPullRequest({ head: "h", base: "main", title: "t", body: "b", draft: true });
|
|
766
|
+
expect(g.calls[0].body).toEqual({ title: "t", body: "b", head: "h", base: "main", draft: true });
|
|
767
|
+
const f = fakeFetch({ "POST /api/v1/repos/acme/infra/pulls": { number: 1 } });
|
|
768
|
+
await forgejoForge({ ...fjOpts, fetch: f.fetch }).openPullRequest({ head: "h", base: "main", title: "t", body: "b", draft: true });
|
|
769
|
+
expect(f.calls[0].body).toEqual({ title: "WIP: t", body: "b", head: "h", base: "main" });
|
|
770
|
+
const l = fakeFetch({ "POST /api/v4/projects/group%2Finfra/merge_requests": { iid: 4, web_url: "https://gl/mr/4", source_branch: "h", target_branch: "main", title: "Draft: t", description: "b" } });
|
|
771
|
+
const mr = await gitlabForge({ ...gl, fetch: l.fetch }).openPullRequest({ head: "h", base: "main", title: "t", body: "b", draft: true });
|
|
772
|
+
expect(l.calls[0].body).toEqual({ source_branch: "h", target_branch: "main", title: "Draft: t", description: "b" });
|
|
773
|
+
expect(mr).toEqual({ number: 4, url: "https://gl/mr/4", title: "Draft: t", body: "b", head: "h", base: "main" });
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("GitLab reads the default branch off the project", async () => {
|
|
777
|
+
const { fetch, calls } = fakeFetch({
|
|
778
|
+
"GET /api/v4/projects/group%2Finfra": { default_branch: "main" },
|
|
779
|
+
"POST /api/v4/projects/group%2Finfra/merge_requests": { iid: 2 },
|
|
780
|
+
});
|
|
781
|
+
await gitlabForge({ ...gl, fetch }).openPullRequest({ head: "h", title: "t", body: "b" });
|
|
782
|
+
expect((calls[1].body as { target_branch: string }).target_branch).toBe("main");
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("findPullRequest takes the oldest open one from this repository, not a fork's", async () => {
|
|
786
|
+
const f = fakeFetch({
|
|
787
|
+
"GET /api/v1/repos/acme/infra/pulls": [
|
|
788
|
+
{ number: 12, title: "fork", body: "", head: { ref: "chant/x", repo: { full_name: "mallory/infra" } } },
|
|
789
|
+
{ number: 11, title: "newer", body: "", head: { ref: "chant/x", repo: { full_name: "acme/infra" } } },
|
|
790
|
+
{ number: 10, title: "mine", body: "b", head: { ref: "chant/x", repo: { full_name: "acme/infra" } }, base: { ref: "main" }, html_url: "https://forge.example/acme/infra/pulls/10" },
|
|
791
|
+
{ number: 3, title: "other", body: "", head: { ref: "other", repo: { full_name: "acme/infra" } } },
|
|
792
|
+
],
|
|
793
|
+
});
|
|
794
|
+
const forge = forgejoForge({ ...fjOpts, fetch: f.fetch });
|
|
795
|
+
expect(await forge.findPullRequest("chant/x")).toEqual({ number: 10, title: "mine", body: "b", head: "chant/x", base: "main", url: "https://forge.example/acme/infra/pulls/10" });
|
|
796
|
+
expect(f.calls[0].url).toBe("https://forge.example/api/v1/repos/acme/infra/pulls?state=open&limit=50&page=1");
|
|
797
|
+
expect(await forge.findPullRequest("none")).toBeUndefined();
|
|
798
|
+
|
|
799
|
+
const g = fakeFetch({ "GET /repos/acme/infra/pulls": [] });
|
|
800
|
+
expect(await githubForge({ ...gh, fetch: g.fetch }).findPullRequest("chant/x")).toBeUndefined();
|
|
801
|
+
expect(g.calls[0].url).toBe("https://api.github.com/repos/acme/infra/pulls?state=open&head=acme%3Achant%2Fx&per_page=100&page=1");
|
|
802
|
+
|
|
803
|
+
const l = fakeFetch({
|
|
804
|
+
"GET /api/v4/projects/group%2Finfra/merge_requests": [
|
|
805
|
+
{ iid: 8, source_branch: "chant/x", project_id: 1, source_project_id: 2, title: "fork", description: "" },
|
|
806
|
+
{ iid: 7, source_branch: "chant/x", project_id: 1, source_project_id: 1, title: "mine", description: "d", target_branch: "main" },
|
|
807
|
+
],
|
|
808
|
+
});
|
|
809
|
+
expect(await gitlabForge({ ...gl, fetch: l.fetch }).findPullRequest("chant/x")).toEqual({ number: 7, title: "mine", body: "d", head: "chant/x", base: "main" });
|
|
810
|
+
expect(l.calls[0].url).toBe("https://gl/api/v4/projects/group%2Finfra/merge_requests?state=opened&source_branch=chant%2Fx&per_page=100&page=1");
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test("editPullRequest and addLabels on each forge", async () => {
|
|
814
|
+
const f = fakeFetch({ "PATCH /api/v1/repos/acme/infra/pulls/10": { number: 10, title: "t2" }, "POST /api/v1/repos/acme/infra/issues/10/labels": [] });
|
|
815
|
+
const forge = forgejoForge({ ...fjOpts, fetch: f.fetch });
|
|
816
|
+
expect((await forge.editPullRequest(10, { title: "t2", body: "b2" }))?.title).toBe("t2");
|
|
817
|
+
await forge.addLabels(10, ["minor"]);
|
|
818
|
+
await forge.addLabels(10, []);
|
|
819
|
+
expect(f.calls.map((c) => [c.method, c.body])).toEqual([["PATCH", { title: "t2", body: "b2" }], ["POST", { labels: ["minor"] }]]);
|
|
820
|
+
const l = fakeFetch({ "PUT /api/v4/projects/group%2Finfra/merge_requests/7": { iid: 7 } });
|
|
821
|
+
const g = gitlabForge({ ...gl, fetch: l.fetch });
|
|
822
|
+
await g.editPullRequest(7, { body: "d" });
|
|
823
|
+
await g.addLabels(7, ["minor", "breaking"]);
|
|
824
|
+
expect(l.calls.map((c) => c.body)).toEqual([{ description: "d" }, { add_labels: "minor,breaking" }]);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("Forgejo's 404 on the pull list of a just-pushed empty repository is no pull request (#3845)", async () => {
|
|
828
|
+
const empty = fakeFetch({ "GET /api/v1/repos/acme/infra": { default_branch: "main" } });
|
|
829
|
+
expect(await forgejoForge({ ...fjOpts, fetch: empty.fetch }).findPullRequest("chant/x")).toBeUndefined();
|
|
830
|
+
// A repository that is really missing 404s on its own read, and that still throws.
|
|
831
|
+
const gone = fakeFetch({});
|
|
832
|
+
await expect(forgejoForge({ ...fjOpts, fetch: gone.fetch }).findPullRequest("chant/x")).rejects.toThrow(/404/);
|
|
833
|
+
// GitHub's list 404 stays an error.
|
|
834
|
+
await expect(githubForge({ ...gh, fetch: fakeFetch({}).fetch }).findPullRequest("chant/x")).rejects.toThrow(/404/);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
const answering = (status: number, body: unknown, calls: Call[] = []): ForgeFetch => async (url, init) => {
|
|
838
|
+
calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: JSON.parse(init.body) } : {}) });
|
|
839
|
+
return { ok: status < 400, status, text: async () => (body === undefined ? "" : JSON.stringify(body)) };
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
test("mergePullRequest merges only at the reviewed sha on GitHub", async () => {
|
|
843
|
+
const f = fakeFetch({
|
|
844
|
+
"PUT /repos/acme/infra/pulls/5/merge": { merged: true, sha: "m1" },
|
|
845
|
+
"GET /repos/acme/infra/pulls/5": { number: 5, head: { ref: "chant/x", repo: { full_name: "acme/infra" } } },
|
|
846
|
+
"DELETE /repos/acme/infra/git/refs/heads/chant/x": null,
|
|
847
|
+
});
|
|
848
|
+
const r = await githubForge({ ...gh, fetch: f.fetch }).mergePullRequest(5, { sha: SHA, method: "squash", title: "t", message: "m", deleteBranch: true });
|
|
849
|
+
expect(r).toEqual({ merged: true, sha: "m1" });
|
|
850
|
+
expect(f.calls[0].body).toEqual({ sha: SHA, merge_method: "squash", commit_title: "t", commit_message: "m" });
|
|
851
|
+
expect(f.calls.map((c) => c.method)).toEqual(["PUT", "GET", "DELETE"]);
|
|
852
|
+
const calls: Call[] = [];
|
|
853
|
+
const moved = await githubForge({ ...gh, fetch: answering(409, { message: "Head branch was modified. Review and try the merge again." }, calls) }).mergePullRequest(5, { sha: SHA });
|
|
854
|
+
expect(moved).toEqual({ merged: false, reason: "Head branch was modified. Review and try the merge again." });
|
|
855
|
+
expect(calls[0].body).toEqual({ sha: SHA, merge_method: "merge" });
|
|
856
|
+
const blocked = await githubForge({ ...gh, fetch: answering(405, { message: "Required status check is expected." }) }).mergePullRequest(5, { sha: SHA });
|
|
857
|
+
expect(blocked.merged).toBe(false);
|
|
858
|
+
await expect(githubForge({ ...gh, fetch: answering(401, { message: "Bad credentials" }) }).mergePullRequest(5, { sha: SHA })).rejects.toThrow(/401/);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("mergePullRequest on Forgejo sends head_commit_id and reads the merge commit back", async () => {
|
|
862
|
+
const f = fakeFetch({
|
|
863
|
+
"POST /api/v1/repos/acme/infra/pulls/5/merge": null,
|
|
864
|
+
"GET /api/v1/repos/acme/infra/pulls/5": { number: 5, merge_commit_sha: "m2" },
|
|
865
|
+
});
|
|
866
|
+
const r = await forgejoForge({ ...fjOpts, fetch: f.fetch }).mergePullRequest(5, { sha: SHA, method: "rebase", title: "t", message: "m", deleteBranch: true });
|
|
867
|
+
expect(r).toEqual({ merged: true, sha: "m2" });
|
|
868
|
+
expect(f.calls[0].body).toEqual({ Do: "rebase", head_commit_id: SHA, MergeTitleField: "t", MergeMessageField: "m", delete_branch_after_merge: true });
|
|
869
|
+
const moved = await forgejoForge({ ...fjOpts, fetch: answering(409, { message: "head commit id does not match" }) }).mergePullRequest(5, { sha: SHA });
|
|
870
|
+
expect(moved).toEqual({ merged: false, reason: "head commit id does not match" });
|
|
871
|
+
expect((await forgejoForge({ ...fjOpts, fetch: answering(405, undefined) }).mergePullRequest(5, { sha: SHA })).merged).toBe(false);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("mergePullRequest on GitLab passes sha and squash, and refuses rebase", async () => {
|
|
875
|
+
const f = fakeFetch({ "PUT /api/v4/projects/group%2Finfra/merge_requests/7/merge": { state: "merged", merge_commit_sha: "m3" } });
|
|
876
|
+
const g = gitlabForge({ ...gl, fetch: f.fetch });
|
|
877
|
+
expect(await g.mergePullRequest(7, { sha: SHA, method: "squash", title: "t", message: "m", deleteBranch: true })).toEqual({ merged: true, sha: "m3" });
|
|
878
|
+
expect(f.calls[0].body).toEqual({ sha: SHA, squash: true, squash_commit_message: "t\n\nm", merge_commit_message: "t\n\nm", should_remove_source_branch: true });
|
|
879
|
+
const moved = await gitlabForge({ ...gl, fetch: answering(409, { message: "SHA does not match HEAD of source branch" }) }).mergePullRequest(7, { sha: SHA });
|
|
880
|
+
expect(moved).toEqual({ merged: false, reason: "SHA does not match HEAD of source branch" });
|
|
881
|
+
expect((await gitlabForge({ ...gl, fetch: answering(405, { message: "Branch cannot be merged" }) }).mergePullRequest(7, { sha: SHA })).merged).toBe(false);
|
|
882
|
+
await expect(g.mergePullRequest(7, { sha: SHA, method: "rebase" })).rejects.toThrow(/rebase/);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("a repository with no default branch is refused by name", async () => {
|
|
886
|
+
const { fetch } = fakeFetch({ "GET /api/v1/repos/acme/infra": {} });
|
|
887
|
+
await expect(forgejoForge({ ...fjOpts, fetch }).openPullRequest({ head: "h", title: "t", body: "b" })).rejects.toThrow(/no default branch.*pass base/);
|
|
888
|
+
});
|
|
889
|
+
});
|