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);
}

58
src/diff.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js";
import { classifyHttpError } from "./errors.js";
/** The raw-diff truncation limit, distinct from the body/comment limits. */
export const DIFF_TRUNCATE_LIMIT = 4000;
/**
* A rendered pull-request diff. `truncated`/`original_length` are present only
* when the diff was cut short — signalled as separate fields rather than an
* inline hint, so the diff text itself stays a verbatim (if partial) prefix.
*/
export interface DiffResult {
diff: string;
truncated?: true;
original_length?: number;
}
/**
* Fetch a pull request's raw unified diff from `GET /pulls/{index}.diff`. The
* generated client defaults every response to JSON parsing (its baseApiParams
* set `format: "json"`), which would discard a plain-text diff body — so `text`
* is forced per call to read the response verbatim.
*/
export async function fetchPullDiff(
api: GiteaClient,
context: RepoContext,
number: number,
): Promise<string> {
try {
const response = await api.repos.repoDownloadPullDiffOrPatch(
context.owner,
context.name,
number,
"diff",
undefined,
{ format: "text" },
);
return response.data ?? "";
} catch (error) {
throw classifyHttpError(error);
}
}
/**
* Truncate a diff to {@link DIFF_TRUNCATE_LIMIT}, reporting the original length
* so the caller can offer `--full`. `full` returns the raw diff untouched.
*/
export function truncateDiff(diff: string, full: boolean): DiffResult {
if (full || diff.length <= DIFF_TRUNCATE_LIMIT) {
return { diff };
}
return {
diff: diff.slice(0, DIFF_TRUNCATE_LIMIT),
truncated: true,
original_length: diff.length,
};
}

View File

@@ -55,7 +55,9 @@ function pathname(url: string): string {
}
const ISSUE_PATH = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(?:\/|$)/;
const PULL_PATH = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)(?:\/|$)/;
// The trailing `.` case covers the `.diff`/`.patch` download paths, whose PR
// number is followed by a suffix rather than a `/` or the end of the path.
const PULL_PATH = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)(?:[./]|$)/;
const REPO_PATH = /\/repos\/([^/]+)\/([^/]+)(?:\/|$)/;
function classify404(response: HttpResponseLike): AxiError {

View File

@@ -1,5 +1,6 @@
import type { CliDeps } from "./deps.js";
import { runSubprocess } from "./subprocess.js";
import { axiError } from "./errors.js";
import { runSubprocess, type SubprocessResult } from "./subprocess.js";
export interface RemoteRepo {
host: string;
@@ -62,6 +63,105 @@ export async function currentBranch(deps: CliDeps): Promise<string | null> {
return branch && branch !== "HEAD" ? branch : null;
}
/** git's first non-empty stderr line — the diagnostic surfaced in a GIT_ERROR. */
function firstStderrLine(stderr: string): string {
for (const line of stderr.split("\n")) {
const trimmed = line.trim();
if (trimmed) {
return trimmed;
}
}
return "";
}
/**
* Run git, mapping a spawn failure or non-zero exit to `GIT_ERROR` carrying
* git's first stderr line and the caller's remediation help. `help` names the
* specific fix for the step that failed, so each git step can point at its own.
*/
export async function runGit(
deps: CliDeps,
args: string[],
help: string[],
fallbackMessage?: string,
): Promise<SubprocessResult> {
const result = await runSubprocess("git", args, { cwd: deps.cwd, env: deps.env });
if (result.enoent) {
throw axiError("git is not installed or not on the PATH", "GIT_ERROR", [
"Install git and ensure it is available on the PATH",
]);
}
if (result.code !== 0) {
throw axiError(
firstStderrLine(result.stderr) ||
fallbackMessage ||
`git ${args[0] ?? ""} exited with a non-zero status`,
"GIT_ERROR",
help,
);
}
return result;
}
/** Whether a local branch of this name exists in the working tree's repository. */
export async function branchExists(deps: CliDeps, branch: string): Promise<boolean> {
const result = await runSubprocess(
"git",
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
{ cwd: deps.cwd, env: deps.env },
);
return !result.enoent && result.code === 0;
}
/**
* Check out a pull request's head commit into a local branch, fetching it from
* origin under `refs/pull/{number}/head` — the ref the base repo exposes for
* same-repo and fork PRs alike (ADR 0011). Three-cased on the local branch's
* state so re-running is idempotent:
* - absent: fetch into the branch, then check it out;
* - present but not checked out: force-fetch (the local branch is defined as a
* mirror of the PR head), then check it out;
* - currently checked out: fetch the ref, then fast-forward merge — local
* commits that diverge from the PR head fail with `GIT_ERROR` rather than
* being discarded silently.
*/
export async function checkoutPullHead(
deps: CliDeps,
number: number,
branch: string,
): Promise<void> {
const ref = `pull/${number}/head`;
const fetchHelp = [
"Check your network connection and that `origin` points at the Gitea instance",
];
const current = await currentBranch(deps);
if (current === branch) {
await runGit(deps, ["fetch", "origin", ref], fetchHelp);
// A ff-only merge fails when local commits diverge from the PR head; that
// surfaces as GIT_ERROR with divergence-specific help, never discarding them.
await runGit(
deps,
["merge", "--ff-only", "FETCH_HEAD"],
[
`The checked-out ${branch} has local commits that are not on the PR head, so it cannot fast-forward — they were left intact`,
"Reconcile them (e.g. `git rebase FETCH_HEAD`), or reset the branch once you have preserved them",
],
"git merge --ff-only could not fast-forward",
);
return;
}
// A fresh branch is fetched into directly; an existing one is force-updated,
// since it is a mirror of the PR head and a moved head is not fast-forwardable.
const exists = await branchExists(deps, branch);
const refspec = exists ? `+${ref}:${branch}` : `${ref}:${branch}`;
await runGit(deps, ["fetch", "origin", refspec], fetchHelp);
await runGit(deps, ["checkout", branch], [
`Commit or stash your changes before checking out ${branch}`,
]);
}
export async function detectRemote(deps: CliDeps): Promise<RemoteRepo | null> {
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
cwd: deps.cwd,