feat: add pr create and comment (task 0010)
All checks were successful
CI / test (pull_request) Successful in 30s
CI / test (push) Successful in 29s

`pr create` takes --title (required), --body/--body-file, --base, --head,
--assignee, --reviewer, repeatable name-resolved --label, and --milestone.
An omitted --head defaults to the current local branch; an omitted --base to
the repository's default branch. Before creating, an existing open PR for the
same base/head pair short-circuits to `pull_request: { number, url, already:
true }` rather than opening a duplicate; only an open PR does, since a closed
one's branches are free to be proposed again.

`pr comment <n>` posts through the shared issue-comment endpoint and returns
the created comment as `comment: { number, author, created, body }` (ADR 0008),
reporting a 404 as PR_NOT_FOUND since the caller asked about a pull request.

That comment block is now built in one place (src/comment.ts) for both issue
and pr comment, as ADR 0008 requires them to stay identical.
This commit was merged in pull request #4.
This commit is contained in:
2026-07-11 21:03:40 -04:00
parent f82414a933
commit 590691fc96
13 changed files with 1151 additions and 69 deletions

View File

@@ -14,10 +14,53 @@ Success output is the action block `created: { number, url }` — action block w
## Acceptance criteria ## Acceptance criteria
- [ ] `pr create --title` creates a PR and outputs `created: { number, url }` - [x] `pr create --title` creates a PR and outputs `created: { number, url }`
- [ ] Omitted `--head` resolves to the current local branch; omitted `--base` resolves to the repo's default branch - [x] Omitted `--head` resolves to the current local branch; omitted `--base` resolves to the repo's default branch
- [ ] An existing open PR for the same branch pair short-circuits to `pull_request: { number, url, already: true }` with no duplicate created - [x] An existing open PR for the same branch pair short-circuits to `pull_request: { number, url, already: true }` with no duplicate created
- [ ] `--label` and `--milestone` resolve names case-insensitively with `VALIDATION_ERROR` on unknown names; `--assignee` and `--reviewer` pass through - [x] `--label` and `--milestone` resolve names case-insensitively with `VALIDATION_ERROR` on unknown names; `--assignee` and `--reviewer` pass through
- [ ] `pr comment <n> --body` outputs `comment: { number, author, created, body }` from the POST response, body truncated at 800 chars - [x] `pr comment <n> --body` outputs `comment: { number, author, created, body }` from the POST response, body truncated at 800 chars
- [ ] Missing required inputs (`--title` on create, body on comment) fail with `VALIDATION_ERROR` (exit 2) before any API call - [x] Missing required inputs (`--title` on create, body on comment) fail with `VALIDATION_ERROR` (exit 2) before any API call
- [ ] Fixture-server tests cover creation with defaults, the idempotent short-circuit, name resolution failures, and the comment output shape - [x] Fixture-server tests cover creation with defaults, the idempotent short-circuit, name resolution failures, and the comment output shape
## Implementation Notes
**Reused wholesale from task 0004.**
`resolveBodySource`/`requireBodySource`, `resolveLabelIds`/`resolveMilestoneId`, and the `repeatable` flag kind all carried over untouched — `pr create` added no new shared machinery of its own beyond what is listed below.
**New shared machinery.**
`src/comment.ts` (`COMMENT_FLAGS`, `commentItem`) now owns the `comment: { number, author, created, body }` block that ADR 0008 requires `issue comment` and `pr comment` to emit identically; it existed twice after the first draft, which is exactly the drift that ADR forbids, so it was extracted and `issue comment` moved onto it.
`parsePositionalNumber` (in `src/flags.ts`) replaces `issue.ts`'s private `parseIssueNumber`, taking the noun ("issue", "pull request") as a parameter; the issue-side messages are unchanged.
`httpStatus` (in `src/errors.ts`) exposes the status of a failed call for the callers that give one status a meaning of their own before falling back to `classifyHttpError`.
`currentBranch` (in `src/git.ts`) reads `git rev-parse --abbrev-ref HEAD`, as the spec names.
**A closed PR for the same branch pair does not short-circuit.**
Gitea's by-base-head lookup matches on the branches alone, so it can answer with a closed or merged PR.
The spec says the check is for "an existing *open* PR", and the branches of a closed one are free to be proposed again, so only an open PR short-circuits.
**Names are resolved before the existence check, not after.**
Whether a label name is real does not depend on remote state, so a typo is reported the same way whether or not the PR already exists.
The alternative ordering saves one API call on the short-circuit path but makes a misspelled `--label` fail on the first run and pass silently on the second.
**Deviation: `pr comment` also accepts `--full`.**
The spec lists only `--body`/`--body-file` for it, but the shared 800-char truncation hint reads "use `--full` to see complete body", and without the flag that hint names a command that errors out.
This is the same deviation, for the same reason, that `issue comment` took in task 0004.
**Deviation: a 404 from `pr comment` is `PR_NOT_FOUND`, not `ISSUE_NOT_FOUND`.**
The spec's status table classifies 404s by path, and PR comments go through `/issues/{index}/comments`, which would report a missing PR as a missing issue.
The table's own header is "HTTP status | Context | Error code", and the command knows its target is a pull request, so the calling context wins over the path here.
**Deviation: next-step suggestions point at `pr comment`, not `pr view`.**
gh-axi's reference suggests `pr view <id>` after both commands, but `pr view` does not exist until task 0009, and task 0004 already established that this tool does not hand back a command guaranteed to fail.
**Follow-up:** task 0009 should upgrade the `pr create` and `pr comment` help lines to `pr view` once it lands.
**Beyond the ask: the end-to-end tier.**
The criteria call only for fixture-server tests, but fixtures can only replay an answer they were told to give, and the whole idempotency check rests on how live Gitea's by-base-head lookup actually behaves (404 when no PR matches; the open PR when one does).
`test/e2e/mutations.test.ts` now seeds a branch and asserts both against a live instance, so a wrong assumption fails CI rather than surfacing as a duplicate PR.
The two e2e suites share one provisioned instance via `instanceOnce()`.
**Review findings left unaddressed.**
The optional-field payload assembly in `prCreate` mirrors `issueCreate`'s, and `repoOnBranch`/`gitEnv` in `test/pr-create.test.ts` overlap with `detection.test.ts`'s private git-sandbox helpers; both are shapes rather than logic, and collapsing them would mean either a generic `assignDefined` helper or dragging the fake-`tea` sandbox machinery into `harness.ts`.
Left alone deliberately, to be revisited if a third caller appears.
**Follow-up worth flagging.**
Coverage is now 95.9% statements / 90.1% branches against thresholds of 92/87; the ratchet in `vitest.config.ts` invites raising them, but that belongs in its own commit rather than a feature task, as the last raise was.

View File

@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js"; import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
import { issueCommand } from "./commands/issue.js"; import { issueCommand } from "./commands/issue.js";
import { prCommand } from "./commands/pr.js";
import { resolveRepoContext } from "./context.js"; import { resolveRepoContext } from "./context.js";
import type { CliDeps, GlobalFlags } from "./deps.js"; import type { CliDeps, GlobalFlags } from "./deps.js";
import { consumeFlagValue, splitFlag } from "./flags.js"; import { consumeFlagValue, splitFlag } from "./flags.js";
@@ -16,6 +17,8 @@ commands:
issue view Show a single issue's details issue view Show a single issue's details
issue create Create an issue issue create Create an issue
issue comment Post a comment on an issue or pull request issue comment Post a comment on an issue or pull request
pr create Create a pull request
pr comment Post a comment on a pull request
global flags: global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote -R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
@@ -111,6 +114,7 @@ export async function runCli(options: RunCliOptions): Promise<number> {
topLevelHelp: TOP_LEVEL_HELP, topLevelHelp: TOP_LEVEL_HELP,
commands: { commands: {
issue: issueCommand(deps), issue: issueCommand(deps),
pr: prCommand(deps),
}, },
home: homeCommand(deps), home: homeCommand(deps),
stdout: options.stdout, stdout: options.stdout,

View File

@@ -1,11 +1,8 @@
import type { Comment, CreateIssueOption, Issue } from "gitea-js"; import type { Comment, CreateIssueOption, Issue } from "gitea-js";
import { import { BODY_TRUNCATE_LIMIT, COMMENT_TRUNCATE_LIMIT, truncateBody } from "../body.js";
BODY_TRUNCATE_LIMIT,
COMMENT_TRUNCATE_LIMIT,
truncateBody,
} from "../body.js";
import { requireBodySource, resolveBodySource } from "../body-source.js"; import { requireBodySource, resolveBodySource } from "../body-source.js";
import { createClient } from "../client.js"; import { createClient } from "../client.js";
import { COMMENT_FLAGS, commentItem } from "../comment.js";
import { resolveRepoContext, type RepoContext } from "../context.js"; import { resolveRepoContext, type RepoContext } from "../context.js";
import type { CliDeps } from "../deps.js"; import type { CliDeps } from "../deps.js";
import { axiError, classifyHttpError } from "../errors.js"; import { axiError, classifyHttpError } from "../errors.js";
@@ -18,7 +15,7 @@ import {
selectExtraFields, selectExtraFields,
type FieldDef, type FieldDef,
} from "../fields.js"; } from "../fields.js";
import { flagValue, parseFlags } from "../flags.js"; import { flagValue, parseFlags, parsePositionalNumber } from "../flags.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js"; import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
import { relativeTime } from "../time.js"; import { relativeTime } from "../time.js";
@@ -214,32 +211,6 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
}); });
} }
function parseIssueNumber(positionals: string[], command: string): number {
const helpSuggestion = [`Run \`gitea-axi ${command} --help\` to see available flags`];
if (positionals.length === 0) {
throw axiError(`${command} requires an issue number`, "VALIDATION_ERROR", [
`Run \`gitea-axi ${command} <number>\``,
]);
}
if (positionals.length > 1) {
throw axiError(
`Unexpected argument: ${positionals[1]}`,
"VALIDATION_ERROR",
helpSuggestion,
);
}
const raw = positionals[0]!;
const number = Number(raw);
if (!Number.isInteger(number) || number < 1) {
throw axiError(
`Invalid issue number: ${raw} (expected a positive integer)`,
"VALIDATION_ERROR",
helpSuggestion,
);
}
return number;
}
// The default detail fields reuse the same declarative extraction as the list // The default detail fields reuse the same declarative extraction as the list
// path; only `body` (truncation) and `comment_count` need bespoke handling. // path; only `body` (truncation) and `comment_count` need bespoke handling.
const ISSUE_VIEW_FIELDS: FieldDef<Issue>[] = [ const ISSUE_VIEW_FIELDS: FieldDef<Issue>[] = [
@@ -309,7 +280,7 @@ async function issueView(deps: CliDeps, args: string[]): Promise<string> {
{ "--comments": { takesValue: false }, "--full": { takesValue: false } }, { "--comments": { takesValue: false }, "--full": { takesValue: false } },
"issue view", "issue view",
); );
const number = parseIssueNumber(positionals, "issue view"); const number = parsePositionalNumber(positionals, "issue view", "issue");
const full = flags["--full"] === true; const full = flags["--full"] === true;
const withComments = flags["--comments"] === true; const withComments = flags["--comments"] === true;
@@ -460,16 +431,8 @@ async function issueComment(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) { if (args.includes("--help")) {
return ISSUE_COMMENT_HELP; return ISSUE_COMMENT_HELP;
} }
const { flags, positionals } = parseFlags( const { flags, positionals } = parseFlags(args, COMMENT_FLAGS, "issue comment");
args, const number = parsePositionalNumber(positionals, "issue comment", "issue");
{
"--body": { takesValue: true },
"--body-file": { takesValue: true },
"--full": { takesValue: false },
},
"issue comment",
);
const number = parseIssueNumber(positionals, "issue comment");
const body = requireBodySource(deps, flags, "issue comment"); const body = requireBodySource(deps, flags, "issue comment");
const full = flags["--full"] === true; const full = flags["--full"] === true;
@@ -487,14 +450,7 @@ async function issueComment(deps: CliDeps, args: string[]): Promise<string> {
throw classifyHttpError(error); throw classifyHttpError(error);
} }
const raw = comment.body ?? ""; const item = commentItem(comment, { number, full, host: context.host, now: new Date() });
const item = {
// The issue the comment was posted to — the comment's own id is not output.
number,
author: comment.user?.login ?? "",
created: relativeTime(comment.created_at, new Date()),
body: full ? raw : truncateBody(raw, COMMENT_TRUNCATE_LIMIT, context.host),
};
// Gitea marks a comment posted on a pull request with `pull_request_url`. Only // Gitea marks a comment posted on a pull request with `pull_request_url`. Only
// an issue target gets the `issue view` suggestion: `issue view` type-guards // an issue target gets the `issue view` suggestion: `issue view` type-guards

311
src/commands/pr.ts Normal file
View File

@@ -0,0 +1,311 @@
import type { Comment, CreatePullRequestOption, PullRequest, Repository } from "gitea-js";
import { requireBodySource, resolveBodySource } from "../body-source.js";
import { createClient, type GiteaClient } from "../client.js";
import { COMMENT_FLAGS, commentItem } from "../comment.js";
import { resolveRepoContext, type RepoContext } from "../context.js";
import type { CliDeps } from "../deps.js";
import { axiError, classifyHttpError, httpStatus } from "../errors.js";
import { flagValue, parseFlags, parsePositionalNumber } from "../flags.js";
import { currentBranch } from "../git.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { renderDetail } from "../render.js";
import { suggestCommand } from "../suggestions.js";
export const PR_HELP = `usage: gitea-axi pr <command> [flags]
commands:
create Create a pull request
comment Post a comment on a pull request
Run \`gitea-axi pr <command> --help\` for the flags of a command.
`;
export const PR_CREATE_HELP = `usage: gitea-axi pr create --title <text> [flags]
Create a pull request in the current repository. An open pull request already
existing for the same base and head branches is reported instead of duplicated.
flags:
--title <text> Pull request title (required)
--body <text> Pull request body
--body-file <path> Read the body from a file (mutually exclusive with --body)
--base <branch> Branch to merge into (default: the repository's default branch)
--head <branch> Branch to merge from (default: the current local branch)
--assignee <login> Assign the pull request to a user
--reviewer <login> Request a review from a user
--label <name> Apply a label by name (repeatable, case-insensitive)
--milestone <name> Assign a milestone by name (case-insensitive)
--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_COMMENT_HELP = `usage: gitea-axi pr comment <number> [flags]
Post a comment on a pull request.
flags:
--body <text> Comment body (required unless --body-file is given)
--body-file <path> Read the comment body from a file (mutually exclusive with --body)
--full Echo the posted body in full, without truncating it at 800 chars
--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
`;
const PR_CREATE_HELP_SUGGESTION = [
"Run `gitea-axi pr create --help` to see available flags",
];
/** The branch to merge from: the caller's `--head`, else the local checkout's. */
async function resolveHead(deps: CliDeps, head: string | undefined): Promise<string> {
if (head !== undefined) {
return head;
}
const branch = await currentBranch(deps);
if (branch === null) {
throw axiError(
"Could not determine the current branch to use as the head branch",
"VALIDATION_ERROR",
["Pass `--head <branch>` to name the branch to merge from"],
);
}
return branch;
}
/** The branch to merge into: the caller's `--base`, else the repository's default. */
async function resolveBase(
api: GiteaClient,
context: RepoContext,
base: string | undefined,
): Promise<string> {
if (base !== undefined) {
return base;
}
let repo: Repository;
try {
const response = await api.repos.repoGet(context.owner, context.name);
repo = response.data;
} catch (error) {
throw classifyHttpError(error);
}
if (!repo.default_branch) {
throw axiError(
`Repository ${context.owner}/${context.name} reports no default branch`,
"VALIDATION_ERROR",
["Pass `--base <branch>` to name the branch to merge into"],
);
}
return repo.default_branch;
}
/**
* The open pull request for a base/head pair, or undefined when there is none.
* Gitea answers its by-base-head lookup with a 404 when no pull request matches
* the pair at all, which is the ordinary "nothing to short-circuit to" case
* rather than a failure. The lookup matches on the branches alone, so a closed
* or merged pull request can come back too — that must not block a fresh one,
* since its branches are free to be proposed again.
*/
async function findOpenPull(
api: GiteaClient,
context: RepoContext,
base: string,
head: string,
): Promise<PullRequest | undefined> {
let pull: PullRequest;
try {
const response = await api.repos.repoGetPullRequestByBaseHead(
context.owner,
context.name,
base,
head,
);
pull = response.data;
} catch (error) {
if (httpStatus(error) === 404) {
return undefined;
}
throw classifyHttpError(error);
}
return pull.state === "open" ? pull : undefined;
}
/**
* The number Gitea gave a pull request. The generated client types it optional,
* but every real pull request has one, and a number invented to fill the gap
* would be reported as fact and interpolated into the next command to run — so a
* response without one is treated as the broken answer it is.
*/
function pullNumber(pull: PullRequest): number {
if (pull.number === undefined) {
throw axiError("Gitea returned a pull request with no number", "UNKNOWN");
}
return pull.number;
}
async function prCreate(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CREATE_HELP;
}
const { flags, lists, positionals } = parseFlags(
args,
{
"--title": { takesValue: true },
"--body": { takesValue: true },
"--body-file": { takesValue: true },
"--base": { takesValue: true },
"--head": { takesValue: true },
"--assignee": { takesValue: true },
"--reviewer": { takesValue: true },
"--label": { takesValue: true, repeatable: true },
"--milestone": { takesValue: true },
},
"pr create",
);
if (positionals.length > 0) {
throw axiError(
`Unexpected argument: ${positionals[0]}`,
"VALIDATION_ERROR",
PR_CREATE_HELP_SUGGESTION,
);
}
// Everything that can fail on the caller's own input — including the head
// branch, which git alone can answer — is settled before any request goes out,
// so a rejected invocation never half-creates a pull request.
const title = flagValue(flags, "--title");
if (title === undefined) {
throw axiError("pr create requires --title <text>", "VALIDATION_ERROR", [
"Run `gitea-axi pr create --title <text>`",
]);
}
const body = resolveBodySource(deps, flags, "pr create");
const assignee = flagValue(flags, "--assignee");
const reviewer = flagValue(flags, "--reviewer");
const milestoneName = flagValue(flags, "--milestone");
const labelNames = lists["--label"] ?? [];
const head = await resolveHead(deps, flagValue(flags, "--head"));
const context = await resolveRepoContext(deps);
const api = createClient(context);
const base = await resolveBase(api, context, flagValue(flags, "--base"));
// Names are resolved before the existence check, not after: whether a label
// name is real does not depend on remote state, so a typo must be reported the
// same way whether or not the pull request happens to exist already.
const labelIds = await resolveLabelIds(api, context, labelNames);
const milestoneId =
milestoneName !== undefined
? await resolveMilestoneId(api, context, milestoneName)
: undefined;
const existing = await findOpenPull(api, context, base, head);
if (existing) {
const number = pullNumber(existing);
return renderDetail({
noun: "pull_request",
item: { number, url: existing.html_url ?? "", already: true },
help: [
suggestCommand(
context,
`pr comment ${number} --body <text>`,
"to comment on the existing pull request",
),
],
});
}
const payload: CreatePullRequestOption = { title, base, head };
if (body !== undefined) {
payload.body = body;
}
if (assignee !== undefined) {
payload.assignees = [assignee];
}
if (reviewer !== undefined) {
payload.reviewers = [reviewer];
}
if (labelIds.length > 0) {
payload.labels = labelIds;
}
if (milestoneId !== undefined) {
payload.milestone = milestoneId;
}
let pull: PullRequest;
try {
const response = await api.repos.repoCreatePullRequest(context.owner, context.name, payload);
pull = response.data;
} catch (error) {
throw classifyHttpError(error);
}
// The mutation ran, so the block is named for the action; the no-op path above
// reports the entity instead.
const number = pullNumber(pull);
return renderDetail({
noun: "created",
item: { number, url: pull.html_url ?? "" },
help: [
suggestCommand(context, `pr comment ${number} --body <text>`, "to comment on the pull request"),
],
});
}
async function prComment(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_COMMENT_HELP;
}
const { flags, positionals } = parseFlags(args, COMMENT_FLAGS, "pr comment");
const number = parsePositionalNumber(positionals, "pr comment", "pull request");
const body = requireBodySource(deps, flags, "pr comment");
const full = flags["--full"] === true;
const context = await resolveRepoContext(deps);
const api = createClient(context);
let comment: Comment;
try {
// Pull requests share the issue comment endpoint, and it answers with the
// created comment — no follow-up view call is needed to report it (ADR 0008).
const response = await api.repos.issueCreateComment(context.owner, context.name, number, {
body,
});
comment = response.data;
} catch (error) {
// The endpoint's path says `issues`, but the caller asked about a pull
// request, so a missing target is reported as the pull request it is.
if (httpStatus(error) === 404) {
throw axiError(`Pull request #${number} not found`, "PR_NOT_FOUND");
}
throw classifyHttpError(error);
}
return renderDetail({
noun: "comment",
item: commentItem(comment, { number, full, host: context.host, now: new Date() }),
help: [suggestCommand(context, "pr comment --help", "to see all pr comment flags")],
});
}
export function prCommand(deps: CliDeps) {
return async (args: string[]): Promise<string> => {
const [subcommand, ...rest] = args;
if (!subcommand || subcommand === "--help") {
return PR_HELP;
}
if (subcommand === "create") {
return prCreate(deps, rest);
}
if (subcommand === "comment") {
return prComment(deps, rest);
}
throw axiError(`Unknown pr command: ${subcommand}`, "VALIDATION_ERROR", [
"Run `gitea-axi pr --help` to see available pr commands",
]);
};
}

40
src/comment.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Comment } from "gitea-js";
import { COMMENT_TRUNCATE_LIMIT, truncateBody } from "./body.js";
import type { FlagSpec } from "./flags.js";
import { relativeTime } from "./time.js";
/**
* The one comment-posting shape `issue comment` and `pr comment` share. ADR 0008
* requires both to emit the same `comment` block with the same schema, so the
* block is built in exactly one place; what the two commands genuinely differ on
* — how a missing target is reported, and what to suggest next — stays with them.
*/
export const COMMENT_FLAGS: FlagSpec = {
"--body": { takesValue: true },
"--body-file": { takesValue: true },
"--full": { takesValue: false },
};
export interface CommentItemOptions {
/** The issue or pull request commented on — not the comment's own id. */
number: number;
/** Echo the body untruncated, as `--full` asks. */
full: boolean;
host: string;
now: Date;
}
/** The `comment: { number, author, created, body }` block, body truncated at 800 chars. */
export function commentItem(
comment: Comment,
options: CommentItemOptions,
): Record<string, unknown> {
const body = comment.body ?? "";
return {
number: options.number,
author: comment.user?.login ?? "",
created: relativeTime(comment.created_at, options.now),
body: options.full ? body : truncateBody(body, COMMENT_TRUNCATE_LIMIT, options.host),
};
}

View File

@@ -87,6 +87,17 @@ function classify404(response: HttpResponseLike): AxiError {
return axiError(`Not found: ${path}`, "UNKNOWN"); return axiError(`Not found: ${path}`, "UNKNOWN");
} }
/**
* The HTTP status of a failed API call, or undefined if the failure was not an
* HTTP response at all. For the callers that give one status a meaning of their
* own before falling back to {@link classifyHttpError} — a 404 from Gitea's
* by-base-head pull lookup, for instance, means "no such pull request exists",
* which is an ordinary answer rather than an error.
*/
export function httpStatus(error: unknown): number | undefined {
return isHttpResponseLike(error) ? error.status : undefined;
}
export function classifyHttpError(error: unknown): AxiError { export function classifyHttpError(error: unknown): AxiError {
if (error instanceof AxiError) { if (error instanceof AxiError) {
return error; return error;

View File

@@ -70,6 +70,44 @@ export function consumeFlagValue(
return { value: next, lastIndex: index + 1 }; return { value: next, lastIndex: index + 1 };
} }
/** "issue" → "an issue"; "pull request" → "a pull request". */
function withArticle(noun: string): string {
return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;
}
/**
* Parse the single positional number of a `<command> <number>` invocation.
* `noun` names what the number identifies ("issue", "pull request") and appears
* in the errors; the parsing itself is identical for both.
*/
export function parsePositionalNumber(
positionals: string[],
command: string,
noun: string,
): number {
const helpSuggestion = [`Run \`gitea-axi ${command} --help\` to see available flags`];
if (positionals.length === 0) {
throw axiError(
`${command} requires ${withArticle(noun)} number`,
"VALIDATION_ERROR",
[`Run \`gitea-axi ${command} <number>\``],
);
}
if (positionals.length > 1) {
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion);
}
const raw = positionals[0]!;
const number = Number(raw);
if (!Number.isInteger(number) || number < 1) {
throw axiError(
`Invalid ${noun} number: ${raw} (expected a positive integer)`,
"VALIDATION_ERROR",
helpSuggestion,
);
}
return number;
}
export function parseFlags( export function parseFlags(
args: string[], args: string[],
spec: FlagSpec, spec: FlagSpec,

View File

@@ -43,6 +43,25 @@ export function parseRemoteUrl(url: string): RemoteRepo | null {
return null; return null;
} }
/**
* The branch currently checked out, or null when git cannot name one — it is
* absent, this is not a repository, or HEAD is detached. Every null case is
* repaired the same way, by the caller naming the branch explicitly, so they
* are not distinguished here.
*/
export async function currentBranch(deps: CliDeps): Promise<string | null> {
const result = await runSubprocess("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: deps.cwd,
env: deps.env,
});
if (result.enoent || result.code !== 0) {
return null;
}
const branch = result.stdout.trim();
// A detached HEAD abbreviates to the literal "HEAD", which is no branch.
return branch && branch !== "HEAD" ? branch : null;
}
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,

View File

@@ -3,16 +3,20 @@ import { runCliTest } from "../harness.js";
import { import {
fetchComments, fetchComments,
fetchIssue, fetchIssue,
fetchOpenPulls,
provisionInstance, provisionInstance,
seedBranch,
type E2EInstance, type E2EInstance,
} from "./provision.js"; } from "./provision.js";
/** /**
* The end-to-end tier for the issue mutations. These commands lean on behavior * The end-to-end tier for the issue and pull request mutations. These commands
* the fixture server cannot attest to — above all that Gitea's label and * lean on behavior the fixture server cannot attest to — that Gitea's label and
* milestone name lookups really are case-insensitive, and that `CreateIssueOption` * milestone name lookups really are case-insensitive, that `CreateIssueOption`
* really takes label *ids* rather than names. Both are asserted here against a * really takes label *ids* rather than names, and that the by-base-head pull
* live instance by passing names in a different case than they were seeded in. * lookup behind `pr create`'s idempotency check really answers a 404 when no
* pull request exists for the pair and the open one when it does. Each is
* asserted here against a live instance rather than a recorded shape.
*/ */
const E2E_URL = process.env.GITEA_AXI_E2E_URL; const E2E_URL = process.env.GITEA_AXI_E2E_URL;
@@ -23,19 +27,34 @@ function renderedNumber(stdout: string): number {
return Number(match![1]); return Number(match![1]);
} }
describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => { /**
let instance: E2EInstance; * One provisioned instance for every suite in this file: the suites run
* sequentially within the file, and sharing the instance keeps the bootstrap
* (which registers the site administrator) to a single run.
*/
let provisioned: Promise<E2EInstance> | undefined;
function instanceOnce(): Promise<E2EInstance> {
provisioned ??= provisionInstance(E2E_URL!);
return provisioned;
}
function env(): Record<string, string> { function envFor(instance: E2EInstance): Record<string, string> {
return { return {
GITEA_AXI_API_URL: instance.baseUrl, GITEA_AXI_API_URL: instance.baseUrl,
GITEA_AXI_TOKEN: instance.token, GITEA_AXI_TOKEN: instance.token,
GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`, GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`,
}; };
}
describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => {
let instance: E2EInstance;
function env(): Record<string, string> {
return envFor(instance);
} }
beforeAll(async () => { beforeAll(async () => {
instance = await provisionInstance(E2E_URL!); instance = await instanceOnce();
}, 150_000); }, 150_000);
it("creates an issue and reports the live number, state, and url", async () => { it("creates an issue and reports the live number, state, and url", async () => {
@@ -119,3 +138,81 @@ describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => {
expect(comments[0]!.body).toBe("A comment from the e2e tier."); expect(comments[0]!.body).toBe("A comment from the e2e tier.");
}); });
}); });
describe.skipIf(!E2E_URL)("end-to-end: pull request mutations", () => {
let instance: E2EInstance;
const branch = "e2e-pr-branch";
function env(): Record<string, string> {
return envFor(instance);
}
beforeAll(async () => {
instance = await instanceOnce();
// The head branch has to exist, with a diff to propose, before a pull
// request can be opened from it.
await seedBranch(instance, branch);
}, 150_000);
it("creates a pull request, defaulting the base to the live repo's default branch", async () => {
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "E2E created PR", "--head", branch, "--body", "From the e2e tier."],
{ env: env() },
);
expect(exitCode).toBe(0);
expect(stdout).toContain("created:");
expect(stdout).toContain(`${instance.owner}/${instance.repo}/pulls/`);
const pulls = await fetchOpenPulls(instance);
expect(pulls).toHaveLength(1);
const created = pulls[0]!;
expect(created.number).toBe(renderedNumber(stdout));
expect(created.title).toBe("E2E created PR");
// The base was never passed: it came from the repository's own default branch.
expect((created.base as { ref?: string }).ref).toBe("main");
expect((created.head as { ref?: string }).ref).toBe(branch);
});
it("short-circuits a second create for the same branch pair, creating no duplicate", async () => {
// Whether Gitea's by-base-head lookup really finds the pull request opened
// above is the assumption the whole idempotency check rests on; fixtures can
// only assert the shape of an answer they were told to give.
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "E2E duplicate PR", "--head", branch],
{ env: env() },
);
expect(exitCode).toBe(0);
expect(stdout).toContain("pull_request:");
expect(stdout).toContain("already: true");
expect(stdout).not.toContain("created:");
const pulls = await fetchOpenPulls(instance);
expect(pulls).toHaveLength(1);
// The existing pull request is reported untouched — not retitled, not replaced.
expect(pulls[0]!.title).toBe("E2E created PR");
});
it("posts a comment on a live pull request and echoes it back", async () => {
const pulls = await fetchOpenPulls(instance);
const number = pulls[0]!.number as number;
const { stdout, exitCode } = await runCliTest(
["pr", "comment", String(number), "--body", "A PR comment from the e2e tier."],
{ env: env() },
);
expect(exitCode).toBe(0);
expect(stdout).toContain("comment:");
expect(stdout).toContain(`number: ${number}`);
expect(stdout).toContain(`author: ${instance.owner}`);
expect(stdout).toContain("body: A PR comment from the e2e tier.");
// Pull requests really do share the issue comment endpoint, so the comment
// is readable back through it.
const comments = await fetchComments(instance, number);
expect(comments).toHaveLength(1);
expect(comments[0]!.body).toBe("A PR comment from the e2e tier.");
});
});

View File

@@ -219,6 +219,37 @@ export async function provisionInstance(baseUrl: string): Promise<E2EInstance> {
}; };
} }
/**
* Create `branch` off the default branch, carrying one new file, so that a pull
* request opened from it has a real diff to propose.
*/
export async function seedBranch(instance: E2EInstance, branch: string): Promise<void> {
await apiRequest(
instance.baseUrl,
"POST",
`/repos/${instance.owner}/${instance.repo}/contents/${branch}.txt`,
instance.token,
{
content: Buffer.from(`Seeded on ${branch}.\n`).toString("base64"),
message: `Seed ${branch}`,
new_branch: branch,
},
);
}
/** Fetch the repository's open pull requests as Gitea returns them. */
export async function fetchOpenPulls(
instance: E2EInstance,
): Promise<Record<string, unknown>[]> {
const res = await apiRequest(
instance.baseUrl,
"GET",
`/repos/${instance.owner}/${instance.repo}/pulls?state=open`,
instance.token,
);
return (await res.json()) as Record<string, unknown>[];
}
/** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */ /** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */
export async function fetchIssue( export async function fetchIssue(
instance: E2EInstance, instance: E2EInstance,

View File

@@ -29,6 +29,23 @@ describe("--help", () => {
expect(stdout).toContain("list"); expect(stdout).toContain("list");
}); });
it("prints the pr group help and exits 0", async () => {
const { stdout, exitCode } = await runCliTest(["pr", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("usage: gitea-axi pr");
expect(stdout).toContain("create");
expect(stdout).toContain("comment");
});
it("rejects an unknown pr subcommand with exit code 2", async () => {
const { stdout, exitCode } = await runCliTest(["pr", "frobnicate"]);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("frobnicate");
});
it("prints the version for --version", async () => { it("prints the version for --version", async () => {
const { stdout, exitCode } = await runCliTest(["--version"]); const { stdout, exitCode } = await runCliTest(["--version"]);

145
test/pr-comment.test.ts Normal file
View File

@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { postedBody, runCliTest, tempFiles, testModeEnv } from "./harness.js";
// Pull request comments go through the shared issue-comment endpoint.
const COMMENTS_PATH = "/api/v1/repos/testowner/testrepo/issues/12/comments";
let server: FixtureServer;
const files = tempFiles();
afterEach(async () => {
await server.close();
files.cleanup();
});
function createdComment(fields: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 900,
user: { login: "alexion" },
created_at: "2026-07-01T00:00:00Z",
body: "Looks good to me.",
pull_request_url: "http://127.0.0.1/testowner/testrepo/pulls/12",
...fields,
};
}
function postedComment(): Record<string, unknown> {
return postedBody(server, COMMENTS_PATH);
}
describe("pr comment", () => {
it("posts the comment and renders number, author, created, and body", async () => {
server = await startFixtureServer([
{ method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment() },
]);
const { stdout, exitCode } = await runCliTest(
["pr", "comment", "12", "--body", "Looks good to me."],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
// The same `comment` block name as `issue comment`, per ADR 0008.
expect(stdout).toContain("comment:");
// `number` is the PR commented on, not the comment's own id.
expect(stdout).toContain("number: 12");
expect(stdout).toContain("author: alexion");
expect(stdout).toContain("body: Looks good to me.");
expect(stdout).not.toContain("900");
expect(postedComment()).toEqual({ body: "Looks good to me." });
});
it("reads the body from --body-file", async () => {
const path = files.write("comment.md", "From a file.");
server = await startFixtureServer([
{ method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment() },
]);
const { exitCode } = await runCliTest(["pr", "comment", "12", "--body-file", path], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(postedComment()).toEqual({ body: "From a file." });
});
it("truncates a body over 800 chars in the output, with the inline hint", async () => {
const body = "z".repeat(1000);
server = await startFixtureServer([
{ method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment({ body }) },
]);
const { stdout, exitCode } = await runCliTest(["pr", "comment", "12", "--body", body], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain(
`... (truncated, ${body.length} chars total - use --full to see complete body)`,
);
// The posted body itself is never truncated — only its echo in the output.
expect(postedComment()).toEqual({ body });
});
it("echoes the untruncated body with --full", async () => {
const body = "z".repeat(1000);
server = await startFixtureServer([
{ method: "POST", path: COMMENTS_PATH, status: 201, body: createdComment({ body }) },
]);
const { stdout, exitCode } = await runCliTest(
["pr", "comment", "12", "--body", body, "--full"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout).toContain(body);
expect(stdout).not.toContain("truncated");
});
it("rejects a missing body before calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "comment", "12"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("--body");
expect(server.requests).toHaveLength(0);
});
it("rejects a missing pull request number before calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "comment", "--body", "hi"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("pull request number");
expect(server.requests).toHaveLength(0);
});
it("reports a nonexistent pull request as PR_NOT_FOUND, not ISSUE_NOT_FOUND", async () => {
server = await startFixtureServer([
{ method: "POST", path: COMMENTS_PATH, status: 404, body: { message: "Not Found" } },
]);
const { stdout, exitCode } = await runCliTest(["pr", "comment", "12", "--body", "hi"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(1);
// The shared endpoint lives under /issues/, but the caller asked about a PR.
expect(stdout).toContain("code: PR_NOT_FOUND");
expect(stdout).toContain("Pull request #12");
});
it("prints help with --help without calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "comment", "--help"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("usage: gitea-axi pr comment <number>");
expect(server.requests).toHaveLength(0);
});
});

370
test/pr-create.test.ts Normal file
View File

@@ -0,0 +1,370 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { postedBody, runCliTest, tempFiles, testModeEnv } from "./harness.js";
const REPO_PATH = "/api/v1/repos/testowner/testrepo";
const PULLS_PATH = `${REPO_PATH}/pulls`;
const LABELS_PATH = `${REPO_PATH}/labels`;
const MILESTONES_PATH = `${REPO_PATH}/milestones`;
/** The by-base-head lookup Gitea exposes for the idempotency check. */
const BASE_HEAD_PATH = `${PULLS_PATH}/main/feature-x`;
const root = mkdtempSync(join(tmpdir(), "gitea-axi-pr-"));
let repoCounter = 0;
let server: FixtureServer;
const files = tempFiles();
afterEach(async () => {
await server.close();
files.cleanup();
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
/**
* A real git repository checked out on `branch`, for the tests that exercise
* the `--head` default. It needs a commit: `git rev-parse --abbrev-ref HEAD`
* has no revision to resolve on an unborn branch.
*/
function repoOnBranch(branch: string): string {
const dir = mkdtempSync(join(root, `repo-${repoCounter++}-`));
execFileSync("git", ["init", "--quiet", "-b", branch], { cwd: dir });
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet",
"--allow-empty", "-m", "seed"], { cwd: dir });
return dir;
}
/** Test-mode env plus the PATH the git subprocess needs to be found on. */
function gitEnv(url: string): Record<string, string | undefined> {
return { ...testModeEnv(url), PATH: process.env.PATH };
}
function createdPull(fields: Record<string, unknown> = {}): Record<string, unknown> {
return {
number: 12,
title: "Add the thing",
state: "open",
html_url: "http://127.0.0.1/testowner/testrepo/pulls/12",
...fields,
};
}
/** No PR exists for the branch pair — Gitea answers the by-base-head lookup with a 404. */
const NO_EXISTING_PR = {
method: "GET",
path: BASE_HEAD_PATH,
status: 404,
body: { message: "Not Found" },
} as const;
const DEFAULT_BRANCH = {
method: "GET",
path: REPO_PATH,
body: { default_branch: "main" },
} as const;
function postedPull(): Record<string, unknown> {
return postedBody(server, PULLS_PATH);
}
function posted(): boolean {
return server.requests.some((request) => request.method === "POST");
}
describe("pr create", () => {
it("creates a pull request and renders the created action block", async () => {
server = await startFixtureServer([
NO_EXISTING_PR,
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "Add the thing", "--base", "main", "--head", "feature-x"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
// The mutation ran, so the block is named for the action, not the entity.
expect(stdout).toContain("created:");
expect(stdout).not.toContain("pull_request:");
expect(stdout).toContain("number: 12");
expect(stdout).toContain('url: "http://127.0.0.1/testowner/testrepo/pulls/12"');
expect(postedPull()).toEqual({
title: "Add the thing",
base: "main",
head: "feature-x",
});
});
it("defaults --head to the current branch and --base to the repo's default branch", async () => {
server = await startFixtureServer([
DEFAULT_BRANCH,
NO_EXISTING_PR,
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
]);
const { exitCode } = await runCliTest(["pr", "create", "--title", "T"], {
env: gitEnv(server.url),
cwd: repoOnBranch("feature-x"),
});
expect(exitCode).toBe(0);
expect(postedPull()).toEqual({ title: "T", base: "main", head: "feature-x" });
});
it("short-circuits to the existing open pull request without creating a duplicate", async () => {
server = await startFixtureServer([
{ method: "GET", path: BASE_HEAD_PATH, body: createdPull() },
]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "Add the thing", "--base", "main", "--head", "feature-x"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
// A no-op reports the entity, not the action.
expect(stdout).toContain("pull_request:");
expect(stdout).not.toContain("created:");
expect(stdout).toContain("number: 12");
expect(stdout).toContain('url: "http://127.0.0.1/testowner/testrepo/pulls/12"');
expect(stdout).toContain("already: true");
expect(posted()).toBe(false);
});
it("creates a fresh pull request when the only match for the branch pair is closed", async () => {
server = await startFixtureServer([
{
method: "GET",
path: BASE_HEAD_PATH,
body: createdPull({ number: 3, state: "closed" }),
},
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "Add the thing", "--base", "main", "--head", "feature-x"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout).toContain("created:");
expect(stdout).toContain("number: 12");
expect(posted()).toBe(true);
});
it("resolves --label and --milestone names and passes --assignee and --reviewer through", async () => {
server = await startFixtureServer([
{
method: "GET",
path: LABELS_PATH,
body: [
{ id: 11, name: "bug" },
{ id: 22, name: "Priority: High" },
],
},
{
method: "GET",
path: MILESTONES_PATH,
query: { name: "v1.0" },
body: [{ id: 5, title: "V1.0" }],
},
NO_EXISTING_PR,
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
]);
const { exitCode } = await runCliTest(
[
"pr", "create",
"--title", "T",
"--base", "main",
"--head", "feature-x",
"--label", "BUG",
"--label", "priority: high",
"--milestone", "v1.0",
"--assignee", "alexion",
"--reviewer", "reviewer-one",
],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(postedPull()).toEqual({
title: "T",
base: "main",
head: "feature-x",
labels: [11, 22],
milestone: 5,
assignees: ["alexion"],
reviewers: ["reviewer-one"],
});
});
it("rejects an unknown --label name with VALIDATION_ERROR and creates nothing", async () => {
server = await startFixtureServer([
{ method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] },
NO_EXISTING_PR,
]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x", "--label", "nope"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("nope");
expect(posted()).toBe(false);
});
it("rejects an unknown --milestone name with VALIDATION_ERROR and creates nothing", async () => {
server = await startFixtureServer([
{ method: "GET", path: MILESTONES_PATH, body: [] },
NO_EXISTING_PR,
]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x", "--milestone", "ghost"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("ghost");
expect(posted()).toBe(false);
});
it("reads the body from --body-file", async () => {
const path = files.write("body.md", "From a file.\n");
server = await startFixtureServer([
NO_EXISTING_PR,
{ method: "POST", path: PULLS_PATH, status: 201, body: createdPull() },
]);
const { exitCode } = await runCliTest(
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x", "--body-file", path],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(postedPull()).toEqual({
title: "T",
base: "main",
head: "feature-x",
body: "From a file.\n",
});
});
it("rejects a missing --title before calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "create", "--base", "main", "--head", "x"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("--title");
expect(server.requests).toHaveLength(0);
});
it("rejects an unresolvable current branch with VALIDATION_ERROR, before calling the API", async () => {
// Not a git repository, so the head branch cannot be read from git and the
// caller must name it themselves.
const cwd = mkdtempSync(join(root, "bare-"));
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "create", "--title", "T"], {
env: gitEnv(server.url),
cwd,
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("--head");
expect(server.requests).toHaveLength(0);
});
it("surfaces a failure to fetch the default branch", async () => {
server = await startFixtureServer([
{ method: "GET", path: REPO_PATH, status: 403, body: { message: "forbidden" } },
]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "T", "--head", "feature-x"],
{ env: gitEnv(server.url) },
);
expect(exitCode).toBe(1);
expect(stdout).toContain("code: FORBIDDEN");
expect(posted()).toBe(false);
});
it("surfaces a server-side rejection of the create", async () => {
server = await startFixtureServer([
NO_EXISTING_PR,
{
method: "POST",
path: PULLS_PATH,
status: 422,
body: { message: "head branch does not exist" },
},
]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("head branch does not exist");
});
it("rejects an unexpected positional argument before calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "create", "12", "--title", "T"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("12");
expect(server.requests).toHaveLength(0);
});
it("surfaces a failure of the existing-pull-request check that is not a 404", async () => {
// Only a 404 means "no such pull request"; anything else is a real failure
// and must not be mistaken for a clear runway to create one.
server = await startFixtureServer([
{ method: "GET", path: BASE_HEAD_PATH, status: 403, body: { message: "forbidden" } },
]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "T", "--base", "main", "--head", "feature-x"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(1);
expect(stdout).toContain("code: FORBIDDEN");
expect(posted()).toBe(false);
});
it("asks for --base when the repository reports no default branch", async () => {
server = await startFixtureServer([{ method: "GET", path: REPO_PATH, body: {} }]);
const { stdout, exitCode } = await runCliTest(
["pr", "create", "--title", "T", "--head", "feature-x"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("--base");
expect(posted()).toBe(false);
});
it("prints help with --help without calling the API", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "create", "--help"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("usage: gitea-axi pr create");
expect(server.requests).toHaveLength(0);
});
});