feat: add pr merge and update-branch (task 0012)
All checks were successful
CI / test (pull_request) Successful in 41s
CI / test (push) Successful in 40s

Add `pr merge` with all six Gitea methods, the three common-method
shorthands, `--auto`, `--delete-branch`, `--subject`, `--body`/`--body-file`,
and `--merge-commit-id` (required with and only valid for
`--method manually-merged`). Conflicting method flags and the
manually-merged/commit-id pairing are rejected as VALIDATION_ERROR before
any API call. An already-merged PR short-circuits to a `pull_request` entity
block with `merged_by`/`merged_at`; merge-blocked 405/409 responses surface
as VALIDATION_ERROR with update-branch/checkout remediation.

Add `pr update-branch` merging the base into the head via the update
endpoint with `--style <merge|rebase>` (default merge).
This commit was merged in pull request #12.
This commit is contained in:
2026-07-13 20:22:23 -04:00
parent 965ece306f
commit 40859b9e6b
4 changed files with 579 additions and 7 deletions

239
test/pr-merge.test.ts Normal file
View File

@@ -0,0 +1,239 @@
import { afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { runCliTest, tempFiles, testModeEnv, type TempFiles } from "./harness.js";
const REPO_PATH = "/api/v1/repos/testowner/testrepo";
const PR_PATH = `${REPO_PATH}/pulls/9`;
const MERGE_PATH = `${PR_PATH}/merge`;
let server: FixtureServer;
const files: TempFiles = tempFiles();
afterEach(async () => {
await server.close();
files.cleanup();
});
/** The open PR the idempotency GET reads before a merge is attempted. */
function openPull() {
return { method: "GET", path: PR_PATH, body: { number: 9, state: "open" } } as const;
}
function mergePosted() {
return server.requests.find(
(request) => request.method === "POST" && request.path === MERGE_PATH,
);
}
describe("pr merge", () => {
it.each([
["--method", "merge", "merge"],
["--method", "squash", "squash"],
["--method", "rebase", "rebase"],
["--method", "rebase-merge", "rebase-merge"],
["--method", "fast-forward-only", "fast-forward-only"],
])("sends Do=%s %s and reports the method", async (flag, value, expected) => {
server = await startFixtureServer([openPull(), { method: "POST", path: MERGE_PATH, body: {} }]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "9", flag, value], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("merged:");
expect(stdout).toContain("number: 9");
expect(stdout).toContain("status: ok");
expect(stdout).toContain(`method: ${expected}`);
expect(mergePosted()?.body).toEqual({ Do: value });
});
it("merges the manually-merged method with its required --merge-commit-id", async () => {
server = await startFixtureServer([openPull(), { method: "POST", path: MERGE_PATH, body: {} }]);
const { stdout, exitCode } = await runCliTest(
["pr", "merge", "9", "--method", "manually-merged", "--merge-commit-id", "abc123"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout).toContain("method: manually-merged");
expect(mergePosted()?.body).toEqual({ Do: "manually-merged", MergeCommitID: "abc123" });
});
it.each([
["--merge", "merge"],
["--squash", "squash"],
["--rebase", "rebase"],
])("maps the %s shorthand to Do=%s", async (shorthand, expected) => {
server = await startFixtureServer([openPull(), { method: "POST", path: MERGE_PATH, body: {} }]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "9", shorthand], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain(`method: ${expected}`);
expect(mergePosted()?.body).toEqual({ Do: expected });
});
it("defaults to Do=merge but reports method: default when none is given", async () => {
server = await startFixtureServer([openPull(), { method: "POST", path: MERGE_PATH, body: {} }]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "9"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("method: default");
expect(mergePosted()?.body).toEqual({ Do: "merge" });
});
it("forwards --auto, --delete-branch, --subject, and --body-file", async () => {
server = await startFixtureServer([openPull(), { method: "POST", path: MERGE_PATH, body: {} }]);
const path = files.write("msg.txt", "Body from a file");
const { exitCode } = await runCliTest(
[
"pr",
"merge",
"9",
"--squash",
"--auto",
"--delete-branch",
"--subject",
"Ship it",
"--body-file",
path,
],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(mergePosted()?.body).toEqual({
Do: "squash",
MergeTitleField: "Ship it",
MergeMessageField: "Body from a file",
merge_when_checks_succeed: true,
delete_branch_after_merge: true,
});
});
it.each([
["--merge", "--squash"],
["--method", "merge", "--rebase"],
])("rejects conflicting method flags before any API call", async (...args) => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "9", ...args], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("Choose only one merge method");
expect(server.requests).toHaveLength(0);
});
it("rejects an invalid --method value", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "9", "--method", "octopus"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(server.requests).toHaveLength(0);
});
it("rejects --merge-commit-id without --method manually-merged", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(
["pr", "merge", "9", "--squash", "--merge-commit-id", "abc123"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("--merge-commit-id is only valid with --method manually-merged");
expect(server.requests).toHaveLength(0);
});
it("rejects --method manually-merged without --merge-commit-id", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(
["pr", "merge", "9", "--method", "manually-merged"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("--method manually-merged requires --merge-commit-id");
expect(server.requests).toHaveLength(0);
});
it("short-circuits an already-merged pull request without calling the merge API", async () => {
server = await startFixtureServer([
{
method: "GET",
path: PR_PATH,
body: {
number: 9,
state: "closed",
merged: true,
merged_by: { login: "octocat" },
merged_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
},
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "9"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("pull_request:");
expect(stdout).toContain("state: merged");
expect(stdout).toContain("merged_by: octocat");
expect(stdout).toContain("merged_at: 2d ago");
expect(mergePosted()).toBeUndefined();
});
it("maps a 405 not-mergeable response to VALIDATION_ERROR with remediation help", async () => {
server = await startFixtureServer([
openPull(),
{
method: "POST",
path: MERGE_PATH,
status: 405,
body: { message: "The pull request cannot be merged, base branch is out of date" },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "9"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("base branch is out of date");
expect(stdout).toContain("pr update-branch 9");
expect(stdout).toContain("pr checkout 9");
});
it("maps a 409 conflict response to VALIDATION_ERROR", async () => {
server = await startFixtureServer([
openPull(),
{ method: "POST", path: MERGE_PATH, status: 409, body: { message: "merge conflict" } },
]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "9", "--rebase"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("merge conflict");
});
it("prints help with --help without calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "merge", "--help"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("usage: gitea-axi pr merge");
expect(server.requests).toHaveLength(0);
});
});