feat: add pr diff and checkout (task 0014)
All checks were successful
CI / test (pull_request) Successful in 42s
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:
@@ -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
58
src/diff.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
102
src/git.ts
102
src/git.ts
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user