From 590691fc96ead9fb0e736d7c6d085a08b395e642 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 11 Jul 2026 21:03:40 -0400 Subject: [PATCH] feat: add pr create and comment (task 0010) `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 ` 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. --- .claude/tasks/0010-pr-create-and-comment.md | 57 ++- src/cli.ts | 4 + src/commands/issue.ts | 58 +-- src/commands/pr.ts | 311 ++++++++++++++++ src/comment.ts | 40 +++ src/errors.ts | 11 + src/flags.ts | 38 ++ src/git.ts | 19 + test/e2e/mutations.test.ts | 119 ++++++- test/e2e/provision.ts | 31 ++ test/help.test.ts | 17 + test/pr-comment.test.ts | 145 ++++++++ test/pr-create.test.ts | 370 ++++++++++++++++++++ 13 files changed, 1151 insertions(+), 69 deletions(-) create mode 100644 src/commands/pr.ts create mode 100644 src/comment.ts create mode 100644 test/pr-comment.test.ts create mode 100644 test/pr-create.test.ts diff --git a/.claude/tasks/0010-pr-create-and-comment.md b/.claude/tasks/0010-pr-create-and-comment.md index bcf0617..37763cd 100644 --- a/.claude/tasks/0010-pr-create-and-comment.md +++ b/.claude/tasks/0010-pr-create-and-comment.md @@ -14,10 +14,53 @@ Success output is the action block `created: { number, url }` — action block w ## Acceptance criteria -- [ ] `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 -- [ ] 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 -- [ ] `pr comment --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 -- [ ] Fixture-server tests cover creation with defaults, the idempotent short-circuit, name resolution failures, and the comment output shape +- [x] `pr create --title` creates a PR and outputs `created: { number, url }` +- [x] Omitted `--head` resolves to the current local branch; omitted `--base` resolves to the repo's default branch +- [x] An existing open PR for the same branch pair short-circuits to `pull_request: { number, url, already: true }` with no duplicate created +- [x] `--label` and `--milestone` resolve names case-insensitively with `VALIDATION_ERROR` on unknown names; `--assignee` and `--reviewer` pass through +- [x] `pr comment --body` outputs `comment: { number, author, created, body }` from the POST response, body truncated at 800 chars +- [x] Missing required inputs (`--title` on create, body on comment) fail with `VALIDATION_ERROR` (exit 2) before any API call +- [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 ` 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. diff --git a/src/cli.ts b/src/cli.ts index c42fa43..200912b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js"; import { issueCommand } from "./commands/issue.js"; +import { prCommand } from "./commands/pr.js"; import { resolveRepoContext } from "./context.js"; import type { CliDeps, GlobalFlags } from "./deps.js"; import { consumeFlagValue, splitFlag } from "./flags.js"; @@ -16,6 +17,8 @@ commands: issue view Show a single issue's details issue create Create an issue 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: -R, --repo Override the repository detected from the git origin remote @@ -111,6 +114,7 @@ export async function runCli(options: RunCliOptions): Promise { topLevelHelp: TOP_LEVEL_HELP, commands: { issue: issueCommand(deps), + pr: prCommand(deps), }, home: homeCommand(deps), stdout: options.stdout, diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 2571b58..0a51db9 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,11 +1,8 @@ import type { Comment, CreateIssueOption, Issue } from "gitea-js"; -import { - BODY_TRUNCATE_LIMIT, - COMMENT_TRUNCATE_LIMIT, - truncateBody, -} from "../body.js"; +import { BODY_TRUNCATE_LIMIT, COMMENT_TRUNCATE_LIMIT, truncateBody } from "../body.js"; import { requireBodySource, resolveBodySource } from "../body-source.js"; import { createClient } 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 } from "../errors.js"; @@ -18,7 +15,7 @@ import { selectExtraFields, type FieldDef, } from "../fields.js"; -import { flagValue, parseFlags } from "../flags.js"; +import { flagValue, parseFlags, parsePositionalNumber } from "../flags.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js"; import { relativeTime } from "../time.js"; @@ -214,32 +211,6 @@ async function issueList(deps: CliDeps, args: string[]): Promise { }); } -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} \``, - ]); - } - 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 // path; only `body` (truncation) and `comment_count` need bespoke handling. const ISSUE_VIEW_FIELDS: FieldDef[] = [ @@ -309,7 +280,7 @@ async function issueView(deps: CliDeps, args: string[]): Promise { { "--comments": { takesValue: false }, "--full": { takesValue: false } }, "issue view", ); - const number = parseIssueNumber(positionals, "issue view"); + const number = parsePositionalNumber(positionals, "issue view", "issue"); const full = flags["--full"] === true; const withComments = flags["--comments"] === true; @@ -460,16 +431,8 @@ async function issueComment(deps: CliDeps, args: string[]): Promise { if (args.includes("--help")) { return ISSUE_COMMENT_HELP; } - const { flags, positionals } = parseFlags( - args, - { - "--body": { takesValue: true }, - "--body-file": { takesValue: true }, - "--full": { takesValue: false }, - }, - "issue comment", - ); - const number = parseIssueNumber(positionals, "issue comment"); + const { flags, positionals } = parseFlags(args, COMMENT_FLAGS, "issue comment"); + const number = parsePositionalNumber(positionals, "issue comment", "issue"); const body = requireBodySource(deps, flags, "issue comment"); const full = flags["--full"] === true; @@ -487,14 +450,7 @@ async function issueComment(deps: CliDeps, args: string[]): Promise { throw classifyHttpError(error); } - const raw = comment.body ?? ""; - 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), - }; + const item = commentItem(comment, { number, full, host: context.host, now: new Date() }); // 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 diff --git a/src/commands/pr.ts b/src/commands/pr.ts new file mode 100644 index 0000000..9b8a4ce --- /dev/null +++ b/src/commands/pr.ts @@ -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 [flags] + +commands: + create Create a pull request + comment Post a comment on a pull request + +Run \`gitea-axi pr --help\` for the flags of a command. +`; + +export const PR_CREATE_HELP = `usage: gitea-axi pr create --title [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 Pull request title (required) + --body Pull request body + --body-file Read the body from a file (mutually exclusive with --body) + --base Branch to merge into (default: the repository's default branch) + --head Branch to merge from (default: the current local branch) + --assignee Assign the pull request to a user + --reviewer Request a review from a user + --label Apply a label by name (repeatable, case-insensitive) + --milestone Assign a milestone by name (case-insensitive) + --help Show this help + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + +export const PR_COMMENT_HELP = `usage: gitea-axi pr comment [flags] + +Post a comment on a pull request. + +flags: + --body Comment body (required unless --body-file is given) + --body-file 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 Override the repository detected from the git origin remote + --login 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 { + 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 ` 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 { + 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 ` 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 { + 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 { + 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 ", "VALIDATION_ERROR", [ + "Run `gitea-axi pr create --title `", + ]); + } + 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 `, + "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 `, "to comment on the pull request"), + ], + }); +} + +async function prComment(deps: CliDeps, args: string[]): Promise { + 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 => { + 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", + ]); + }; +} diff --git a/src/comment.ts b/src/comment.ts new file mode 100644 index 0000000..1522fbe --- /dev/null +++ b/src/comment.ts @@ -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 { + 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), + }; +} diff --git a/src/errors.ts b/src/errors.ts index 333924e..d8948e5 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -87,6 +87,17 @@ function classify404(response: HttpResponseLike): AxiError { 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 { if (error instanceof AxiError) { return error; diff --git a/src/flags.ts b/src/flags.ts index 42bba7b..e2b3295 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -70,6 +70,44 @@ export function consumeFlagValue( 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 ` ` 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} \``], + ); + } + 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( args: string[], spec: FlagSpec, diff --git a/src/git.ts b/src/git.ts index ee81649..797c4e2 100644 --- a/src/git.ts +++ b/src/git.ts @@ -43,6 +43,25 @@ export function parseRemoteUrl(url: string): RemoteRepo | 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 { + 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 { const result = await runSubprocess("git", ["remote", "get-url", "origin"], { cwd: deps.cwd, diff --git a/test/e2e/mutations.test.ts b/test/e2e/mutations.test.ts index 6430d7b..d839b5a 100644 --- a/test/e2e/mutations.test.ts +++ b/test/e2e/mutations.test.ts @@ -3,16 +3,20 @@ import { runCliTest } from "../harness.js"; import { fetchComments, fetchIssue, + fetchOpenPulls, provisionInstance, + seedBranch, type E2EInstance, } from "./provision.js"; /** - * The end-to-end tier for the issue mutations. These commands lean on behavior - * the fixture server cannot attest to — above all that Gitea's label and - * milestone name lookups really are case-insensitive, and that `CreateIssueOption` - * really takes label *ids* rather than names. Both are asserted here against a - * live instance by passing names in a different case than they were seeded in. + * The end-to-end tier for the issue and pull request mutations. These commands + * lean on behavior the fixture server cannot attest to — that Gitea's label and + * milestone name lookups really are case-insensitive, that `CreateIssueOption` + * really takes label *ids* rather than names, and that the by-base-head pull + * 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; @@ -23,19 +27,34 @@ function renderedNumber(stdout: string): number { return Number(match![1]); } +/** + * 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 | undefined; +function instanceOnce(): Promise { + provisioned ??= provisionInstance(E2E_URL!); + return provisioned; +} + +function envFor(instance: E2EInstance): Record { + return { + GITEA_AXI_API_URL: instance.baseUrl, + GITEA_AXI_TOKEN: instance.token, + GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`, + }; +} + describe.skipIf(!E2E_URL)("end-to-end: issue mutations", () => { let instance: E2EInstance; function env(): Record { - return { - GITEA_AXI_API_URL: instance.baseUrl, - GITEA_AXI_TOKEN: instance.token, - GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`, - }; + return envFor(instance); } beforeAll(async () => { - instance = await provisionInstance(E2E_URL!); + instance = await instanceOnce(); }, 150_000); 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."); }); }); + +describe.skipIf(!E2E_URL)("end-to-end: pull request mutations", () => { + let instance: E2EInstance; + const branch = "e2e-pr-branch"; + + function env(): Record { + 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."); + }); +}); diff --git a/test/e2e/provision.ts b/test/e2e/provision.ts index 56c0ba1..80fe70f 100644 --- a/test/e2e/provision.ts +++ b/test/e2e/provision.ts @@ -219,6 +219,37 @@ export async function provisionInstance(baseUrl: string): Promise { }; } +/** + * 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 { + 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[]> { + const res = await apiRequest( + instance.baseUrl, + "GET", + `/repos/${instance.owner}/${instance.repo}/pulls?state=open`, + instance.token, + ); + return (await res.json()) as Record[]; +} + /** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */ export async function fetchIssue( instance: E2EInstance, diff --git a/test/help.test.ts b/test/help.test.ts index 27d3227..f0618cf 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -29,6 +29,23 @@ describe("--help", () => { 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 () => { const { stdout, exitCode } = await runCliTest(["--version"]); diff --git a/test/pr-comment.test.ts b/test/pr-comment.test.ts new file mode 100644 index 0000000..01a901d --- /dev/null +++ b/test/pr-comment.test.ts @@ -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 = {}): Record { + 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 { + 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 "); + expect(server.requests).toHaveLength(0); + }); +}); diff --git a/test/pr-create.test.ts b/test/pr-create.test.ts new file mode 100644 index 0000000..e590bcb --- /dev/null +++ b/test/pr-create.test.ts @@ -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 { + return { ...testModeEnv(url), PATH: process.env.PATH }; +} + +function createdPull(fields: Record = {}): Record { + 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 { + 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); + }); +}); -- 2.47.3