feat: add pr diff and checkout (task 0014)
All checks were successful
CI / test (pull_request) Successful in 42s

Add the two PR commands that touch content and the local worktree:

- `pr diff <n>` fetches the raw diff from the `.diff` endpoint (forcing a
  text response, which the JSON-defaulting client would otherwise discard),
  truncates at 4000 chars with separate `truncated`/`original_length` fields
  and a prepended `--full` suggestion; `--full` returns the raw diff.
- `pr checkout <n>` reads the PR head branch from the PR fetch and fetches
  `refs/pull/{n}/head` from origin (uniform for same-repo and fork PRs, ADR
  0011), three-cased on local branch state so re-checkout is idempotent and
  divergent local commits fail with `GIT_ERROR` rather than being discarded.

Introduces the `GIT_ERROR` mapping (`runGit`) carrying git's first stderr
line plus a remediation help line, and widens the PR 404 classifier so a
`.diff` path still resolves to `PR_NOT_FOUND`.
This commit is contained in:
2026-07-13 22:15:03 -04:00
parent ba5171bb59
commit 3db6d421d4
8 changed files with 675 additions and 9 deletions

View File

@@ -37,7 +37,8 @@ import {
splitFlag,
} from "../flags.js";
import { fetchChecks } from "../checks.js";
import { currentBranch } from "../git.js";
import { fetchPullDiff, truncateDiff } from "../diff.js";
import { checkoutPullHead, currentBranch } from "../git.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { fetchAllPages, readTotalCount } from "../paginate.js";
import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js";
@@ -50,6 +51,8 @@ export const PR_HELP = `usage: gitea-axi pr <command> [flags]
commands:
list List pull requests in the current repository
view Show a single pull request's details
diff Show a pull request's raw diff
checkout Check the pull request's head branch out locally
checks Show a pull request's CI check results
create Create a pull request
edit Edit a pull request's title, body, labels, assignees, reviewers, milestone, or base
@@ -180,6 +183,34 @@ global flags:
--login <name> Select a tea login profile by name
`;
export const PR_DIFF_HELP = `usage: gitea-axi pr diff <number> [flags]
Show a pull request's raw unified diff. The diff is truncated at 4000 chars
unless --full is given.
flags:
--full Return the complete diff without truncating it
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const PR_CHECKOUT_HELP = `usage: gitea-axi pr checkout <number>
Check a pull request's head branch out into the current working tree, fetching
it from origin under refs/pull/<number>/head (works for fork PRs too). Re-running
is idempotent.
flags:
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const PR_LIST_HELP = `usage: gitea-axi pr list [flags]
List pull requests in the current repository.
@@ -911,6 +942,60 @@ async function prChecks(deps: CliDeps, args: string[]): Promise<string> {
});
}
async function prDiff(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_DIFF_HELP;
}
const { flags, positionals } = parseFlags(args, { "--full": { takesValue: false } }, "pr diff");
const number = parsePositionalNumber(positionals, "pr diff", "pull request");
const full = flags["--full"] === true;
const context = await resolveRepoContext(deps);
const api = createClient(context);
const diff = await fetchPullDiff(api, context, number);
const result = truncateDiff(diff, full);
const item: Record<string, unknown> = { number, diff: result.diff };
// The `--full` next step is prepended above the standard `pr view` line only
// when the diff was actually cut short; the separate `truncated`/`original_length`
// fields signal the cut, keeping the diff text a verbatim prefix (spec).
const help = [suggestCommand(context, `pr view ${number}`, "to see the pull request in full")];
if (result.truncated) {
item.truncated = true;
item.original_length = result.original_length;
help.unshift(suggestCommand(context, `pr diff ${number} --full`, "to see the complete diff"));
}
return renderDetail({ noun: "pr_diff", item, help });
}
async function prCheckout(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CHECKOUT_HELP;
}
const { positionals } = parseFlags(args, {}, "pr checkout");
const number = parsePositionalNumber(positionals, "pr checkout", "pull request");
const context = await resolveRepoContext(deps);
const api = createClient(context);
// The head branch name comes from the PR fetch; the commit itself is fetched
// from refs/pull/<n>/head, so a fork head that is not a configured remote still
// resolves (ADR 0011). A PR without a head ref is the broken answer it is,
// rather than a branch name invented to fetch into.
const pull = await getPull(api, context, number);
const branch = pull.head?.ref;
if (!branch) {
throw axiError("Gitea returned a pull request with no head branch", "UNKNOWN");
}
await checkoutPullHead(deps, number, branch);
return renderDetail({
noun: "checkout",
item: { number, branch, status: "ok" },
help: [suggestCommand(context, `pr diff ${number}`, "to review the diff you checked out")],
});
}
async function prCreate(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CREATE_HELP;
@@ -1570,6 +1655,12 @@ export function prCommand(deps: CliDeps) {
if (subcommand === "view") {
return prView(deps, rest);
}
if (subcommand === "diff") {
return prDiff(deps, rest);
}
if (subcommand === "checkout") {
return prCheckout(deps, rest);
}
if (subcommand === "checks") {
return prChecks(deps, rest);
}