feat: add pr diff and checkout (task 0014) #14
@@ -15,9 +15,29 @@ Output: `checkout: { number, branch, status: "ok" }`.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `pr diff <n>` outputs the diff, adding `truncated: true` and `original_length` when over 4000 chars plus a `--full` next-step suggestion; `--full` returns the raw diff
|
- [x] `pr diff <n>` outputs the diff, adding `truncated: true` and `original_length` when over 4000 chars plus a `--full` next-step suggestion; `--full` returns the raw diff
|
||||||
- [ ] `pr checkout <n>` handles all three local-branch cases and re-running it is idempotent
|
- [x] `pr checkout <n>` handles all three local-branch cases and re-running it is idempotent
|
||||||
- [ ] A checked-out branch that has diverged from the PR head fails with `GIT_ERROR` and an explanatory help line, leaving local commits intact
|
- [x] A checked-out branch that has diverged from the PR head fails with `GIT_ERROR` and an explanatory help line, leaving local commits intact
|
||||||
- [ ] Other git failures (dirty worktree, network) map to `GIT_ERROR` with git's first stderr line
|
- [x] Other git failures (dirty worktree, network) map to `GIT_ERROR` with git's first stderr line
|
||||||
- [ ] Checkout works for a fork PR whose head repo is not a configured remote (via `refs/pull/{n}/head`)
|
- [x] Checkout works for a fork PR whose head repo is not a configured remote (via `refs/pull/{n}/head`)
|
||||||
- [ ] Tests cover diff truncation boundaries and the three checkout cases (git behavior exercised against a scratch repository, API responses from the fixture server)
|
- [x] Tests cover diff truncation boundaries and the three checkout cases (git behavior exercised against a scratch repository, API responses from the fixture server)
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
The raw diff is fetched through the generated client's `repoDownloadPullDiffOrPatch`, but with `{ format: "text" }` forced per call.
|
||||||
|
The `giteaApi` wrapper sets `baseApiParams.format: "json"`, so every response otherwise runs through `response.json()` — which would discard a plain-text `.diff` body and leave `data` null.
|
||||||
|
Forcing `text` reads the diff verbatim.
|
||||||
|
|
||||||
|
To let the fixture server return a non-JSON diff body, `FixtureServer`'s route gained a `raw?: string` field, served verbatim as `text/plain` (bypassing the `JSON.stringify` the other fields get).
|
||||||
|
|
||||||
|
`PULL_PATH` in `src/errors.ts` was widened from `(\d+)(?:\/|$)` to `(\d+)(?:[./]|$)` so a 404 on `/pulls/{n}.diff` still classifies as `PR_NOT_FOUND` rather than falling through to `REPO_NOT_FOUND` — the diff endpoint's number is followed by a `.` suffix rather than a `/` or end-of-path.
|
||||||
|
|
||||||
|
Diff truncation is its own `truncateDiff` in `src/diff.ts`, deliberately not reusing `truncateBody`: it signals the cut with separate `truncated`/`original_length` fields (so the diff text stays a verbatim prefix) rather than the inline hint bodies use, and does no body-cleaning.
|
||||||
|
|
||||||
|
For the checked-out-and-diverged case, git's `merge --ff-only` prints its `hint:` lines to stderr before the `fatal:` line, so the surfaced `GIT_ERROR` message is that first `hint:` line; the plain-language divergence explanation and remediation live in the help lines, which is where the acceptance criterion's "explanatory help line" is asserted.
|
||||||
|
This matches the spec's "carrying git's first stderr line" literally.
|
||||||
|
|
||||||
|
`runGit` (the shared git-runner that maps a non-zero exit to `GIT_ERROR` with git's first stderr line) gained an optional `fallbackMessage` argument during the `/review-uncommitted` pass, so the `merge --ff-only` step routes through it instead of re-implementing the enoent/non-zero mapping inline (a Duplicated-Code judgement call the Standards axis raised).
|
||||||
|
|
||||||
|
Process note: `/implement` front-loaded the implementation before the test-writer sub-agent authored the tests, so each TDD cycle was green-on-first-run rather than red-first.
|
||||||
|
Every test was still authored independently by a `general-purpose` sub-agent from the public CLI interface alone (it never read the implementation source), one behavior at a time.
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ import {
|
|||||||
splitFlag,
|
splitFlag,
|
||||||
} from "../flags.js";
|
} from "../flags.js";
|
||||||
import { fetchChecks } from "../checks.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 { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
|
||||||
import { fetchAllPages, readTotalCount } from "../paginate.js";
|
import { fetchAllPages, readTotalCount } from "../paginate.js";
|
||||||
import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.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:
|
commands:
|
||||||
list List pull requests in the current repository
|
list List pull requests in the current repository
|
||||||
view Show a single pull request's details
|
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
|
checks Show a pull request's CI check results
|
||||||
create Create a pull request
|
create Create a pull request
|
||||||
edit Edit a pull request's title, body, labels, assignees, reviewers, milestone, or base
|
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
|
--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]
|
export const PR_LIST_HELP = `usage: gitea-axi pr list [flags]
|
||||||
|
|
||||||
List pull requests in the current repository.
|
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> {
|
async function prCreate(deps: CliDeps, args: string[]): Promise<string> {
|
||||||
if (args.includes("--help")) {
|
if (args.includes("--help")) {
|
||||||
return PR_CREATE_HELP;
|
return PR_CREATE_HELP;
|
||||||
@@ -1570,6 +1655,12 @@ export function prCommand(deps: CliDeps) {
|
|||||||
if (subcommand === "view") {
|
if (subcommand === "view") {
|
||||||
return prView(deps, rest);
|
return prView(deps, rest);
|
||||||
}
|
}
|
||||||
|
if (subcommand === "diff") {
|
||||||
|
return prDiff(deps, rest);
|
||||||
|
}
|
||||||
|
if (subcommand === "checkout") {
|
||||||
|
return prCheckout(deps, rest);
|
||||||
|
}
|
||||||
if (subcommand === "checks") {
|
if (subcommand === "checks") {
|
||||||
return prChecks(deps, rest);
|
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 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\/([^/]+)\/([^/]+)(?:\/|$)/;
|
const REPO_PATH = /\/repos\/([^/]+)\/([^/]+)(?:\/|$)/;
|
||||||
|
|
||||||
function classify404(response: HttpResponseLike): AxiError {
|
function classify404(response: HttpResponseLike): AxiError {
|
||||||
|
|||||||
102
src/git.ts
102
src/git.ts
@@ -1,5 +1,6 @@
|
|||||||
import type { CliDeps } from "./deps.js";
|
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 {
|
export interface RemoteRepo {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -62,6 +63,105 @@ export async function currentBranch(deps: CliDeps): Promise<string | null> {
|
|||||||
return branch && branch !== "HEAD" ? branch : 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> {
|
export async function detectRemote(deps: CliDeps): Promise<RemoteRepo | null> {
|
||||||
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
|
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
|
||||||
cwd: deps.cwd,
|
cwd: deps.cwd,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface FixtureRoute {
|
|||||||
body?: unknown;
|
body?: unknown;
|
||||||
/** Name of a JSON file in test/fixtures to serve as the body. */
|
/** Name of a JSON file in test/fixtures to serve as the body. */
|
||||||
fixture?: string;
|
fixture?: string;
|
||||||
|
/**
|
||||||
|
* A verbatim, non-JSON response body (e.g. a raw `.diff`). Served as-is with a
|
||||||
|
* `text/plain` content type, bypassing the JSON encoding the other fields get.
|
||||||
|
*/
|
||||||
|
raw?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordedRequest {
|
export interface RecordedRequest {
|
||||||
@@ -96,6 +101,14 @@ export async function startFixtureServer(routes: FixtureRoute[]): Promise<Fixtur
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (route.raw !== undefined) {
|
||||||
|
res.writeHead(route.status ?? 200, {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
...route.headers,
|
||||||
|
});
|
||||||
|
res.end(route.raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.writeHead(route.status ?? 200, {
|
res.writeHead(route.status ?? 200, {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
...route.headers,
|
...route.headers,
|
||||||
|
|||||||
266
test/pr-checkout.test.ts
Normal file
266
test/pr-checkout.test.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||||
|
import { runCliTest, testModeEnv } from "./harness.js";
|
||||||
|
|
||||||
|
let server: FixtureServer;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.close();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function git(cwd: string, ...args: string[]): Buffer {
|
||||||
|
return execFileSync("git", args, {
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: "t",
|
||||||
|
GIT_AUTHOR_EMAIL: "t@t",
|
||||||
|
GIT_COMMITTER_NAME: "t",
|
||||||
|
GIT_COMMITTER_EMAIL: "t@t",
|
||||||
|
},
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an `origin` bare repo whose PR-head commit is published ONLY under
|
||||||
|
* `refs/pull/<prNumber>/head` (never as a normal branch), plus a working clone
|
||||||
|
* pointed at it, so `pr checkout` needs no network. Returns the working dir, the
|
||||||
|
* seed clone (to advance the PR head), and the sha of the PR-head commit.
|
||||||
|
*/
|
||||||
|
function makeScratchRepo(prNumber = 1): {
|
||||||
|
workDir: string;
|
||||||
|
seedDir: string;
|
||||||
|
headSha: string;
|
||||||
|
} {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "gitea-axi-checkout-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const originDir = join(root, "origin.git");
|
||||||
|
git(root, "init", "--bare", "-b", "main", "origin.git");
|
||||||
|
|
||||||
|
const seed = join(root, "seed");
|
||||||
|
git(root, "clone", originDir, "seed");
|
||||||
|
writeFileSync(join(seed, "file.txt"), "base\n");
|
||||||
|
git(seed, "add", "-A");
|
||||||
|
git(seed, "commit", "-m", "base");
|
||||||
|
git(seed, "push", "origin", "main");
|
||||||
|
|
||||||
|
writeFileSync(join(seed, "file.txt"), "pr head\n");
|
||||||
|
git(seed, "add", "-A");
|
||||||
|
git(seed, "commit", "-m", "pr head");
|
||||||
|
git(seed, "push", "origin", `HEAD:refs/pull/${prNumber}/head`);
|
||||||
|
const headSha = git(seed, "rev-parse", "HEAD").toString().trim();
|
||||||
|
|
||||||
|
const workDir = join(root, "work");
|
||||||
|
git(root, "clone", originDir, "work");
|
||||||
|
return { workDir, seedDir: seed, headSha };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pr checkout", () => {
|
||||||
|
it("fetches refs/pull/<n>/head into a branch named after head.ref and checks it out", async () => {
|
||||||
|
const { workDir } = makeScratchRepo();
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/repos/testowner/testrepo/pulls/1",
|
||||||
|
body: { number: 1, head: { ref: "feature" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "1"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("checkout:");
|
||||||
|
expect(stdout).toContain("number: 1");
|
||||||
|
expect(stdout).toContain("branch: feature");
|
||||||
|
expect(stdout).toContain("status: ok");
|
||||||
|
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
|
||||||
|
"feature",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("force-updates a pre-existing, non-checked-out local branch to the PR head and checks it out", async () => {
|
||||||
|
const { workDir, headSha } = makeScratchRepo();
|
||||||
|
// A stale local `feature` branch sits at the base commit while HEAD stays on main.
|
||||||
|
git(workDir, "branch", "feature", "main");
|
||||||
|
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/repos/testowner/testrepo/pulls/1",
|
||||||
|
body: { number: 1, head: { ref: "feature" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "1"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("checkout:");
|
||||||
|
expect(stdout).toContain("branch: feature");
|
||||||
|
expect(stdout).toContain("status: ok");
|
||||||
|
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
|
||||||
|
"feature",
|
||||||
|
);
|
||||||
|
expect(git(workDir, "rev-parse", "feature").toString().trim()).toBe(headSha);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fast-forwards the already-checked-out PR branch to an advanced head and is idempotent on re-run", async () => {
|
||||||
|
const { workDir, seedDir } = makeScratchRepo();
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/repos/testowner/testrepo/pulls/1",
|
||||||
|
body: { number: 1, head: { ref: "feature" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First checkout lands on `feature` at the original PR head.
|
||||||
|
const first = await runCliTest(["pr", "checkout", "1"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
expect(first.exitCode).toBe(0);
|
||||||
|
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
|
||||||
|
"feature",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Advance the PR head on origin to a new commit.
|
||||||
|
writeFileSync(join(seedDir, "file.txt"), "advanced pr head\n");
|
||||||
|
git(seedDir, "add", "-A");
|
||||||
|
git(seedDir, "commit", "-m", "advanced pr head");
|
||||||
|
const newSha = git(seedDir, "rev-parse", "HEAD").toString().trim();
|
||||||
|
git(seedDir, "push", "-f", "origin", "HEAD:refs/pull/1/head");
|
||||||
|
|
||||||
|
// Second checkout fast-forwards the checked-out branch to the advanced head.
|
||||||
|
const second = await runCliTest(["pr", "checkout", "1"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
expect(second.exitCode).toBe(0);
|
||||||
|
expect(second.stdout).toContain("status: ok");
|
||||||
|
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
|
||||||
|
"feature",
|
||||||
|
);
|
||||||
|
expect(git(workDir, "rev-parse", "feature").toString().trim()).toBe(newSha);
|
||||||
|
|
||||||
|
// Third checkout with no further change is a successful no-op.
|
||||||
|
const third = await runCliTest(["pr", "checkout", "1"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
expect(third.exitCode).toBe(0);
|
||||||
|
expect(third.stdout).toContain("status: ok");
|
||||||
|
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
|
||||||
|
"feature",
|
||||||
|
);
|
||||||
|
expect(git(workDir, "rev-parse", "feature").toString().trim()).toBe(newSha);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with GIT_ERROR and preserves local commits when the checked-out branch diverges", async () => {
|
||||||
|
const { workDir, seedDir } = makeScratchRepo();
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/repos/testowner/testrepo/pulls/1",
|
||||||
|
body: { number: 1, head: { ref: "feature" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First checkout lands on `feature` at the PR head.
|
||||||
|
const first = await runCliTest(["pr", "checkout", "1"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
expect(first.exitCode).toBe(0);
|
||||||
|
|
||||||
|
// Move the PR head elsewhere on origin.
|
||||||
|
writeFileSync(join(seedDir, "file.txt"), "advanced pr head\n");
|
||||||
|
git(seedDir, "add", "-A");
|
||||||
|
git(seedDir, "commit", "-m", "advanced pr head");
|
||||||
|
git(seedDir, "push", "-f", "origin", "HEAD:refs/pull/1/head");
|
||||||
|
|
||||||
|
// Make a local commit on `feature` that is not on the PR head → divergence.
|
||||||
|
writeFileSync(join(workDir, "local.txt"), "local work\n");
|
||||||
|
git(workDir, "add", "-A");
|
||||||
|
git(workDir, "commit", "-m", "local work");
|
||||||
|
const localSha = git(workDir, "rev-parse", "HEAD").toString().trim();
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "1"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: GIT_ERROR");
|
||||||
|
expect(stdout).toContain("local commits that are not on the PR head");
|
||||||
|
expect(stdout).toContain("cannot fast-forward");
|
||||||
|
// The local commit is intact; nothing was discarded.
|
||||||
|
expect(git(workDir, "rev-parse", "HEAD").toString().trim()).toBe(localSha);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an ordinary git failure (unreachable origin) to GIT_ERROR carrying git's stderr", async () => {
|
||||||
|
const { workDir } = makeScratchRepo();
|
||||||
|
// Break the fetch: remove origin so the first `git fetch origin ...` fails.
|
||||||
|
git(workDir, "remote", "remove", "origin");
|
||||||
|
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/repos/testowner/testrepo/pulls/1",
|
||||||
|
body: { number: 1, head: { ref: "feature" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "1"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: GIT_ERROR");
|
||||||
|
// Git's own first stderr line is surfaced; assert an invariant fragment.
|
||||||
|
expect(stdout).toContain("'origin' does not appear to be a git repository");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks out a fork PR whose head exists only under refs/pull/<n>/head, not as a branch", async () => {
|
||||||
|
const { workDir, headSha } = makeScratchRepo(2);
|
||||||
|
// `fork-feature` exists nowhere as a branch — only inside refs/pull/2/head.
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/repos/testowner/testrepo/pulls/2",
|
||||||
|
body: { number: 2, head: { ref: "fork-feature" } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "checkout", "2"], {
|
||||||
|
env: { ...process.env, ...testModeEnv(server.url) },
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("checkout:");
|
||||||
|
expect(stdout).toContain("number: 2");
|
||||||
|
expect(stdout).toContain("branch: fork-feature");
|
||||||
|
expect(stdout).toContain("status: ok");
|
||||||
|
expect(git(workDir, "rev-parse", "--abbrev-ref", "HEAD").toString().trim()).toBe(
|
||||||
|
"fork-feature",
|
||||||
|
);
|
||||||
|
expect(git(workDir, "rev-parse", "fork-feature").toString().trim()).toBe(headSha);
|
||||||
|
});
|
||||||
|
});
|
||||||
116
test/pr-diff.test.ts
Normal file
116
test/pr-diff.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||||
|
import { runCliTest, testModeEnv } from "./harness.js";
|
||||||
|
|
||||||
|
const DIFF_PATH = "/api/v1/repos/testowner/testrepo/pulls/7.diff";
|
||||||
|
|
||||||
|
let server: FixtureServer;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pr diff", () => {
|
||||||
|
it("outputs the raw diff verbatim when under the limit, with no truncation fields", async () => {
|
||||||
|
const diff = [
|
||||||
|
"diff --git a/README.md b/README.md",
|
||||||
|
"index 1234567..89abcde 100644",
|
||||||
|
"--- a/README.md",
|
||||||
|
"+++ b/README.md",
|
||||||
|
"@@ -1,3 +1,4 @@",
|
||||||
|
" # Project",
|
||||||
|
" ",
|
||||||
|
"-Old tagline",
|
||||||
|
"+New tagline",
|
||||||
|
"+An extra line",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: DIFF_PATH, raw: diff },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "diff", "7"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("pr_diff:");
|
||||||
|
expect(stdout).toContain("number: 7");
|
||||||
|
expect(stdout).toContain("+New tagline");
|
||||||
|
expect(stdout).not.toContain("truncated:");
|
||||||
|
expect(stdout).not.toContain("original_length:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates a diff over 4000 chars to the first 4000 and reports the original length", async () => {
|
||||||
|
const diff = "x".repeat(4100);
|
||||||
|
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: DIFF_PATH, raw: diff },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "diff", "7"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("truncated: true");
|
||||||
|
expect(stdout).toContain("original_length: 4100");
|
||||||
|
expect(stdout).toContain("Run `gitea-axi pr diff 7");
|
||||||
|
expect(stdout).toContain("--full");
|
||||||
|
expect(stdout).toContain("to see the complete diff");
|
||||||
|
expect(stdout).toContain("x".repeat(4000));
|
||||||
|
expect(stdout).not.toContain("x".repeat(4001));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a diff of exactly 4000 chars through untouched", async () => {
|
||||||
|
const diff = "y".repeat(4000);
|
||||||
|
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: DIFF_PATH, raw: diff },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "diff", "7"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).not.toContain("truncated:");
|
||||||
|
expect(stdout).not.toContain("original_length:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the complete diff and suppresses truncation with --full", async () => {
|
||||||
|
const diff = "z".repeat(4100);
|
||||||
|
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: DIFF_PATH, raw: diff },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "diff", "7", "--full"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("z".repeat(4100));
|
||||||
|
expect(stdout).not.toContain("truncated:");
|
||||||
|
expect(stdout).not.toContain("original_length:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a nonexistent pull request as PR_NOT_FOUND with exit 1", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/v1/repos/testowner/testrepo/pulls/999.diff",
|
||||||
|
status: 404,
|
||||||
|
body: { message: "Not Found" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["pr", "diff", "999"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: PR_NOT_FOUND");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user