@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.
@@ -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
- const existingPrBody = await currentPrBody(branch, gh);
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
- if (existingUrl) {
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 create` names no host, so on GitHub Enterprise Server or Forgejo
1367
- // it is told the job's own (#3780). See `./gh-host.ts`.
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 20 KB today.
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
  });
@@ -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
+ });