Files
gitea-axi/src/commands/pr.ts
alexion cb801522a9
All checks were successful
CI / test (pull_request) Successful in 57s
feat: add --comments-file to pr review for inline comments (task 0035)
`pr review <n>` gains `--comments-file <path>`, a JSON array of inline
comments submitted with the review. Each entry is one of two exclusive
shapes: a new comment `{ path, line, body }` (mapped to `new_position`,
always the new side) or a reply `{ reply_to, body }`. A reply carries no
line or side — gitea-axi finds the target via the reviews-plus-comments
fan-out (there is no get-comment-by-id endpoint), reconstructs its anchor
from the target's own `diff_hunk`, and infers old/new side from it, so a
same-line post threads with the existing conversation. All entries map
onto the review-submission payload's `comments[]`; no new HTTP layer is
added. An unknown `reply_to` is a VALIDATION_ERROR raised before the POST,
and the submitted inline-comment count rides the action block.

The shared path-resolve-and-read behind --body-file and --comments-file is
extracted into src/flag-file.ts.
2026-07-18 16:06:53 -04:00

1743 lines
61 KiB
TypeScript

import type {
Comment,
CreatePullRequestOption,
CreatePullReviewOptions,
EditPullRequestOption,
MergePullRequestOption,
PullRequest,
PullReview,
PullReviewComment,
PullReviewRequestOptions,
Repository,
} from "gitea-js";
import { assigneeLogins, mergeAssignees } from "../assignees.js";
import { BODY_TRUNCATE_LIMIT, COMMENT_TRUNCATE_LIMIT, truncateBody } from "../body.js";
import { requireBodySource, resolveBodySource } from "../body-source.js";
import { createClient, type GiteaClient } from "../client.js";
import { COMMENT_FLAGS, commentItem, commentRows } from "../comment.js";
import { resolveRepoContext, type RepoContext } from "../context.js";
import type { CliDeps } from "../deps.js";
import { axiError, classifyHttpError, httpStatus } from "../errors.js";
import {
boolText,
extractRow,
joined,
lowercased,
pluck,
relativeTimeField,
selectExtraFields,
truncatedBody,
type FieldDef,
} from "../fields.js";
import {
flagValue,
parseEnumFlag,
parseFlags,
parsePositionalNumber,
parsePositiveInt,
splitFlag,
} from "../flags.js";
import { fetchChecks } from "../checks.js";
import { fetchPullDiff, trimDiffHunk, truncateDiff } from "../diff.js";
import { checkoutPullHead, currentBranch } from "../git.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { fetchAllPages, readTotalCount } from "../paginate.js";
import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js";
import { fetchReviewComments, fetchReviewDecision, fetchReviews } from "../review.js";
import { loadInlineComments, resolveInlineComments } from "../review-comments.js";
import { suggestCommand } from "../suggestions.js";
import { relativeTime } from "../time.js";
export const PR_HELP = `usage: gitea-axi pr <command> [flags]
commands:
list List pull requests in the current repository
view Show a single pull request's details
diff Show a pull request's raw diff
checkout Check the pull request's head branch out locally
checks Show a pull request's CI check results
create Create a pull request
edit Edit a pull request's title, body, labels, assignees, reviewers, milestone, or base
merge Merge a pull request
update-branch Merge the base branch into a pull request's head branch
close Close a pull request
reopen Reopen a closed pull request
review Submit a review on 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_EDIT_HELP = `usage: gitea-axi pr edit <number> [flags]
Edit a pull request in the current repository. At least one change is required.
flags:
--title <text> New title
--body <text> New body
--body-file <path> Read the new body from a file (mutually exclusive with --body)
--base <branch> Change the base branch to merge into
--add-label <name> Add a label by name (repeatable)
--remove-label <name> Remove a label by name (repeatable, case-insensitive)
--add-assignee <login> Add an assignee (repeatable)
--remove-assignee <login> Remove an assignee (repeatable)
--add-reviewer <login> Request a review from a user (repeatable)
--remove-reviewer <login> Cancel a requested review (repeatable)
--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_CLOSE_HELP = `usage: gitea-axi pr close <number> [flags]
Close a pull request in the current repository. Closing an already-closed or
merged pull request is a no-op.
flags:
--comment <text> Post a comment when closing
--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_REOPEN_HELP = `usage: gitea-axi pr reopen <number>
Reopen a closed pull request in the current repository. Reopening an
already-open pull request is a no-op.
flags:
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const PR_MERGE_HELP = `usage: gitea-axi pr merge <number> [flags]
Merge a pull request in the current repository. An already-merged pull request
is reported as-is without re-merging.
flags:
--method <method> Merge method: merge, squash, rebase, rebase-merge,
fast-forward-only, or manually-merged (default: merge)
--merge Shorthand for --method merge
--squash Shorthand for --method squash
--rebase Shorthand for --method rebase
--auto Merge automatically once required checks succeed
--delete-branch Delete the head branch after a successful merge
--merge-commit-id <sha> The existing merge commit; required with, and only
valid for, --method manually-merged
--subject <text> Override the merge commit subject line
--body <text> Override the merge commit message body
--body-file <path> Read the merge commit message body from a file
--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_UPDATE_BRANCH_HELP = `usage: gitea-axi pr update-branch <number> [flags]
Merge the base branch into a pull request's head branch, bringing the head up
to date with the base.
flags:
--style <merge|rebase> How to update the head branch (default: merge)
--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_VIEW_HELP = `usage: gitea-axi pr view <number> [flags]
Show a single pull request, including its CI checks and review summary.
flags:
--comments Render every comment in full (bodies truncated at 800 chars)
--reviews Render every review with its inline comments (Gitea official/stale fields)
--full Suppress all truncation of the PR body and comment bodies
--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_CHECKS_HELP = `usage: gitea-axi pr checks <number>
Show the CI check results for a pull request, derived from its head commit's
combined status.
flags:
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const PR_DIFF_HELP = `usage: gitea-axi pr diff <number> [flags]
Show a pull request's raw unified diff. The diff is truncated at 4000 chars
unless --full is given.
flags:
--full Return the complete diff without truncating it
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const PR_CHECKOUT_HELP = `usage: gitea-axi pr checkout <number>
Check a pull request's head branch out into the current working tree, fetching
it from origin under refs/pull/<number>/head (works for fork PRs too). Re-running
is idempotent.
flags:
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const PR_LIST_HELP = `usage: gitea-axi pr list [flags]
List pull requests in the current repository.
flags:
--state <open|closed|all> Filter by state (default: open)
--label <name> Filter by label name (comma-separated, case-insensitive)
--label-id <id> Filter by label ID, bypassing the name lookup
--assignee <login> Filter by assignee (client-side)
--author <login> Filter by author
--base <branch> Filter by base branch (client-side)
--head <branch> Filter by head branch (client-side)
--draft Show only draft pull requests (client-side)
--sort <oldest|recentupdate|leastupdate|mostcomment|leastcomment|priority>
Sort order (passed to the API)
--limit <n> Maximum number of pull requests to return (default: 30)
--fields <a,b,c> Append extra fields: body, createdAt, labels, milestone, mergedAt, url
--full Show the body field raw, without 500-char truncation
--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_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
`;
export const PR_REVIEW_HELP = `usage: gitea-axi pr review <number> [flags]
Submit a review on a pull request. Exactly one action flag is required.
flags:
--approve Approve the pull request
--request-changes Request changes on the pull request
--comment Leave a review comment without approving or rejecting
--body <text> Review body
--body-file <path> Read the review body from a file (mutually exclusive with --body)
--comments-file <path> JSON array of inline comments to submit with the review;
each entry is {reply_to, body} or {path, line, body}
--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",
];
const PR_LIST_HELP_SUGGESTION = [
"Run `gitea-axi pr list --help` to see available flags",
];
const PR_MERGE_HELP_SUGGESTION = [
"Run `gitea-axi pr merge --help` to see available flags",
];
const PR_UPDATE_BRANCH_HELP_SUGGESTION = [
"Run `gitea-axi pr update-branch --help` to see available flags",
];
// The six methods Gitea's merge endpoint accepts as its `Do` field, in the
// order the help text lists them.
const MERGE_METHODS = [
"merge",
"squash",
"rebase",
"rebase-merge",
"fast-forward-only",
"manually-merged",
] as const;
type MergeMethod = (typeof MERGE_METHODS)[number];
// The bare-switch shorthands for the three common methods. `manually-merged` and
// the two rebase variants have no shorthand — they are reachable only via
// `--method`, which is why `--merge-commit-id` (a manually-merged-only flag)
// can never collide with a shorthand.
const MERGE_SHORTHANDS: Record<string, MergeMethod> = {
"--merge": "merge",
"--squash": "squash",
"--rebase": "rebase",
};
const UPDATE_STYLES = ["merge", "rebase"] as const;
type UpdateStyle = (typeof UPDATE_STYLES)[number];
// The `review` column is not one of these: it comes from a separate reviews
// fetch per PR (ADR 0006), so it is set on each row after the decision resolves,
// slotting in after `draft` and before any `--fields` extras.
const PR_LIST_FIELDS: FieldDef<PullRequest>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
pluck("author", "user.login"),
boolText("draft"),
];
// Appended to the defaults on request via `--fields`, never replacing them.
const PR_LIST_EXTRA_FIELDS: Record<string, FieldDef<PullRequest>> = {
body: truncatedBody("body"),
createdAt: relativeTimeField("created", "created_at"),
labels: joined("labels", "labels", "name"),
milestone: pluck("milestone", "milestone.title"),
mergedAt: relativeTimeField("merged_at", "merged_at"),
url: pluck("url", "html_url"),
};
// The default `pr view` fields that reuse the shared declarative extraction;
// `merged`, `checks`, `body`, `comment_count`, and `review_count` are handled
// bespokely in buildPrDetail, since each needs a computed or fetched value.
const PR_VIEW_FIELDS: FieldDef<PullRequest>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
pluck("author", "user.login"),
boolText("draft"),
];
const PR_STATES = ["open", "closed", "all"] as const;
type PrState = (typeof PR_STATES)[number];
const PR_SORTS = [
"oldest",
"recentupdate",
"leastupdate",
"mostcomment",
"leastcomment",
"priority",
] as const;
type PrSort = (typeof PR_SORTS)[number];
const PR_DEFAULT_LIMIT = 30;
/**
* The client-side filters — those Gitea's PR list has no query param for
* (ADR 0005). When any is set the whole result set is paginated and filtered
* in-process, and the count line's total is the filtered set's own size.
*/
interface ClientFilters {
assignee: string | undefined;
base: string | undefined;
head: string | undefined;
draftOnly: boolean;
}
function readClientFilters(flags: Record<string, string | true>): ClientFilters {
return {
assignee: flagValue(flags, "--assignee"),
base: flagValue(flags, "--base"),
head: flagValue(flags, "--head"),
draftOnly: flags["--draft"] === true,
};
}
function hasClientFilter(filters: ClientFilters): boolean {
return (
filters.assignee !== undefined ||
filters.base !== undefined ||
filters.head !== undefined ||
filters.draftOnly
);
}
function matchesClientFilters(pull: PullRequest, filters: ClientFilters): boolean {
if (filters.draftOnly && pull.draft !== true) {
return false;
}
// Branch names are case-sensitive in git, so base and head match exactly.
if (filters.base !== undefined && pull.base?.ref !== filters.base) {
return false;
}
if (filters.head !== undefined && pull.head?.ref !== filters.head) {
return false;
}
if (filters.assignee !== undefined) {
const target = filters.assignee.toLowerCase();
const assigned = (pull.assignees ?? []).some(
(user) => user.login?.toLowerCase() === target,
);
if (!assigned) {
return false;
}
}
return true;
}
function parsePrState(value: string | true | undefined): PrState {
return parseEnumFlag(value, "--state", PR_STATES, PR_LIST_HELP_SUGGESTION) ?? "open";
}
function parsePrSort(value: string | true | undefined): PrSort | undefined {
return parseEnumFlag(value, "--sort", PR_SORTS, PR_LIST_HELP_SUGGESTION);
}
function parsePrLimit(value: string | true | undefined): number {
if (value === undefined) {
return PR_DEFAULT_LIMIT;
}
return parsePositiveInt(value, "--limit", PR_LIST_HELP_SUGGESTION);
}
/**
* The label ids to send as the API `labels` filter. `--label-id` is passed
* through as an integer; `--label` is resolved name→id case-insensitively, since
* the PR list endpoint takes ids, not names. Both may be given at once.
*/
async function resolvePrLabelIds(
api: GiteaClient,
context: RepoContext,
flags: Record<string, string | true>,
): Promise<number[]> {
const ids: number[] = [];
const labelId = flagValue(flags, "--label-id");
if (labelId !== undefined) {
for (const raw of labelId.split(",")) {
const trimmed = raw.trim();
if (!trimmed) {
continue;
}
ids.push(parsePositiveInt(trimmed, "--label-id", PR_LIST_HELP_SUGGESTION));
}
}
const label = flagValue(flags, "--label");
if (label !== undefined) {
const names = label
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > 0);
ids.push(...(await resolveLabelIds(api, context, names)));
}
return ids;
}
/**
* `--search` is refused rather than quietly forwarded to the API's `q` param:
* full-text search is `search prs`. Checked ahead of `parseFlags` so every form
* of the flag — valued, inline, bare — lands on the redirect (mirrors the same
* guard on `issue list`).
*/
function refusePrSearchFlag(args: string[]): void {
if (!args.some((arg) => splitFlag(arg).name === "--search")) {
return;
}
throw axiError("pr list does not support --search", "VALIDATION_ERROR", [
'Use `gitea-axi search prs "<query>"` for full-text search',
]);
}
function prListSuggestions(
context: RepoContext,
state: PrState,
shown: number,
total: number | undefined,
): string[] {
if (shown === 0) {
const help = [
suggestCommand(context, "pr create --title <text>", "to create a pull request"),
];
if (state !== "closed" && state !== "all") {
help.push(
suggestCommand(context, "pr list --state closed", "to see closed pull requests"),
);
}
return help;
}
const help = [suggestCommand(context, "pr view <number>", "to see a pull request in full")];
if (total !== undefined && shown < total) {
help.push(
suggestCommand(context, "pr list --limit <n>", `to fetch more of the ${total} pull requests`),
);
}
return help;
}
async function prList(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_LIST_HELP;
}
refusePrSearchFlag(args);
const { flags, positionals } = parseFlags(
args,
{
"--state": { takesValue: true },
"--label": { takesValue: true },
"--label-id": { takesValue: true },
"--assignee": { takesValue: true },
"--author": { takesValue: true },
"--base": { takesValue: true },
"--head": { takesValue: true },
"--draft": { takesValue: false },
"--sort": { takesValue: true },
"--limit": { takesValue: true },
"--fields": { takesValue: true },
"--full": { takesValue: false },
},
"pr list",
);
if (positionals.length > 0) {
throw axiError(
`Unexpected argument: ${positionals[0]}`,
"VALIDATION_ERROR",
PR_LIST_HELP_SUGGESTION,
);
}
const state = parsePrState(flags["--state"]);
const sort = parsePrSort(flags["--sort"]);
const limit = parsePrLimit(flags["--limit"]);
const full = flags["--full"] === true;
const extraFields = selectExtraFields(
flagValue(flags, "--fields"),
PR_LIST_EXTRA_FIELDS,
"pr list",
);
const filters = readClientFilters(flags);
const context = await resolveRepoContext(deps);
const api = createClient(context);
// Label names resolve to ids before the list call — the PR list endpoint takes
// ids, and a typo must be reported the same way whether or not the filter would
// have matched anything.
const labelIds = await resolvePrLabelIds(api, context, flags);
const query = {
state,
...(sort !== undefined ? { sort } : {}),
...(flagValue(flags, "--author") !== undefined
? { poster: flagValue(flags, "--author") }
: {}),
...(labelIds.length > 0 ? { labels: labelIds } : {}),
};
let pulls: PullRequest[];
let total: number | undefined;
try {
if (hasClientFilter(filters)) {
// A client-side filter has no API param, so the whole set is paged in and
// filtered here; the filtered set's own size is the count-line total, since
// X-Total-Count describes the unfiltered result (ADR 0005).
const result = await fetchAllPages<PullRequest>((page, pageLimit) =>
api.repos.repoListPullRequests(context.owner, context.name, {
...query,
page,
limit: pageLimit,
}),
);
const filtered = result.items.filter((pull) => matchesClientFilters(pull, filters));
total = filtered.length;
pulls = filtered.slice(0, limit);
} else {
const response = await api.repos.repoListPullRequests(context.owner, context.name, {
...query,
limit,
page: 1,
});
pulls = response.data ?? [];
total = readTotalCount(response.headers);
}
} catch (error) {
throw classifyHttpError(error);
}
// One review fetch per rendered PR, all in flight at once (ADR 0006).
const decisions = await Promise.all(
pulls.map((pull) => fetchReviewDecision(api, context, pullNumber(pull))),
);
const now = new Date();
const extractContext = { now, host: context.host, full };
const rows = pulls.map((pull, index) => {
const row = extractRow(pull, PR_LIST_FIELDS, extractContext);
row.review = decisions[index];
Object.assign(row, extractRow(pull, extraFields, extractContext));
return row;
});
return renderList({
noun: "pull_requests",
rows,
countLine: formatCountLine(rows.length, total, rows.length >= limit),
help: prListSuggestions(context, state, rows.length, total),
});
}
/** 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;
}
/** Fetch a single pull request, mapping any HTTP failure to an AxiError. */
async function getPull(api: GiteaClient, context: RepoContext, number: number): Promise<PullRequest> {
try {
const response = await api.repos.repoGetPullRequest(context.owner, context.name, number);
return response.data;
} catch (error) {
throw classifyHttpError(error);
}
}
/**
* The PR head commit SHA, the ref the combined-status fetch keys on. Every real
* pull request has one; a response without it is treated as the broken answer it
* is, rather than inventing a SHA to fetch a status for.
*/
function headSha(pull: PullRequest): string {
const sha = pull.head?.sha;
if (!sha) {
throw axiError("Gitea returned a pull request with no head SHA", "UNKNOWN");
}
return sha;
}
/**
* The id Gitea gave a review comment — the handle a reply targets. The client
* types it optional, but every real comment has one, and an id invented to fill
* the gap would be reported as fact and copied into a `reply_to`, so a comment
* without one is treated as the broken answer it is (mirroring {@link pullNumber}).
*/
function reviewCommentId(comment: PullReviewComment): number {
if (comment.id === undefined) {
throw axiError("Gitea returned a review comment with no id", "UNKNOWN");
}
return comment.id;
}
interface PrDetailOptions {
host: string;
full: boolean;
withComments: boolean;
withReviews: boolean;
checksSummary: string;
reviewCount: number;
now: Date;
}
function buildPrDetail(pull: PullRequest, options: PrDetailOptions): Record<string, unknown> {
const row = extractRow(pull, PR_VIEW_FIELDS, {
now: options.now,
host: options.host,
full: options.full,
});
// gh-axi renders `merged` as `no` when open, or the merge time once merged.
row.merged = pull.merged ? relativeTime(pull.merged_at, options.now) : "no";
row.checks = options.checksSummary;
const body = pull.body ?? "";
row.body = options.full ? body : truncateBody(body, BODY_TRUNCATE_LIMIT, options.host);
// Each count scalar is replaced by its full block when the matching flag is
// passed, mirroring `issue view`'s comment_count (ADR: no redundant scalar).
if (!options.withComments) {
const count = pull.comments ?? 0;
row.comment_count = count > 0 ? `${count} — use --comments to see full comments` : 0;
}
if (!options.withReviews) {
row.review_count =
options.reviewCount > 0
? `${options.reviewCount} — use --reviews to see full reviews`
: 0;
}
return row;
}
function prViewSuggestions(
context: RepoContext,
number: number,
options: {
withComments: boolean;
commentCount: number;
withReviews: boolean;
reviewCount: number;
bodyAbbreviated: boolean;
},
): string[] {
const help: string[] = [];
if (!options.withComments && options.commentCount > 0) {
help.push(suggestCommand(context, `pr view ${number} --comments`, "to see full comments"));
}
if (!options.withReviews && options.reviewCount > 0) {
help.push(suggestCommand(context, `pr view ${number} --reviews`, "to see full reviews"));
}
if (options.bodyAbbreviated) {
help.push(suggestCommand(context, `pr view ${number} --full`, "to see the complete body"));
}
if (help.length === 0) {
help.push(suggestCommand(context, `pr view ${number} --help`, "to see all pr view flags"));
}
return help;
}
interface ReviewRowsOptions {
host: string;
full: boolean;
now: Date;
}
/**
* The `reviews` block rows for `--reviews`: each review with its Gitea-specific
* `official`/`stale` flags and its inline (diff) comments. One comments fetch per
* review, all in flight at once; review and comment bodies truncate at 800 chars
* unless `--full` is set.
*
* Each inline comment carries its anchor: `id` (the reply handle), `resolved`
* (`yes`/`no` from whether Gitea populated the comment's `resolver`), and
* `diff_hunk` — structurally trimmed to its `@@` header plus tail by default, or
* emitted verbatim under `--full`. The raw `position`/`original_position` diff
* offsets are deliberately not surfaced: they are unmappable to a file line
* without the patch, and the `@@` header already carries the line range.
*/
async function buildReviewRows(
api: GiteaClient,
context: RepoContext,
number: number,
reviews: PullReview[],
options: ReviewRowsOptions,
): Promise<Record<string, unknown>[]> {
const commentLists = await Promise.all(
reviews.map((review) =>
review.id !== undefined
? fetchReviewComments(api, context, number, review.id)
: Promise.resolve<PullReviewComment[]>([]),
),
);
const truncate = (text: string): string =>
options.full ? text : truncateBody(text, COMMENT_TRUNCATE_LIMIT, options.host);
return reviews.map((review, index) => ({
author: review.user?.login ?? "",
state: (review.state ?? "").toLowerCase(),
official: review.official ? "yes" : "no",
stale: review.stale ? "yes" : "no",
body: truncate(review.body ?? ""),
comments: commentLists[index]!.map((comment) => ({
id: reviewCommentId(comment),
author: comment.user?.login ?? "",
path: comment.path ?? "",
resolved: comment.resolver ? "yes" : "no",
diff_hunk: options.full ? (comment.diff_hunk ?? "") : trimDiffHunk(comment.diff_hunk ?? ""),
body: truncate(comment.body ?? ""),
})),
}));
}
async function prView(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_VIEW_HELP;
}
const { flags, positionals } = parseFlags(
args,
{
"--comments": { takesValue: false },
"--reviews": { takesValue: false },
"--full": { takesValue: false },
},
"pr view",
);
const number = parsePositionalNumber(positionals, "pr view", "pull request");
const full = flags["--full"] === true;
const withComments = flags["--comments"] === true;
const withReviews = flags["--reviews"] === true;
const context = await resolveRepoContext(deps);
const api = createClient(context);
// The PR and its reviews are fetched in parallel; the combined status then
// needs the head SHA, so it follows once the PR is in hand — three calls
// always, so `checks` and `review_count` are in the default output (ADR 0006).
const [pull, reviews] = await Promise.all([
getPull(api, context, number),
fetchReviews(api, context, number),
]);
const checksResult = await fetchChecks(api, context, headSha(pull));
const now = new Date();
const item = buildPrDetail(pull, {
host: context.host,
full,
withComments,
withReviews,
checksSummary: checksResult.summary,
reviewCount: reviews.length,
now,
});
const blocks: DetailBlock[] = [];
if (withComments) {
// PRs share the issue-comment endpoint, so their comments come from
// GET /issues/{n}/comments — the same fetch `issue view --comments` makes.
let comments: Comment[];
try {
const response = await api.repos.issueGetComments(context.owner, context.name, number);
comments = response.data ?? [];
} catch (error) {
throw classifyHttpError(error);
}
blocks.push({ noun: "comments", rows: commentRows(comments, { host: context.host, full, now }) });
}
if (withReviews) {
blocks.push({
noun: "reviews",
rows: await buildReviewRows(api, context, number, reviews, { host: context.host, full, now }),
});
}
const commentCount = pull.comments ?? 0;
const bodyAbbreviated = item.body !== (pull.body ?? "");
return renderDetail({
noun: "pull_request",
item,
blocks,
help: prViewSuggestions(context, number, {
withComments,
commentCount,
withReviews,
reviewCount: reviews.length,
bodyAbbreviated,
}),
});
}
async function prChecks(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CHECKS_HELP;
}
const { positionals } = parseFlags(args, {}, "pr checks");
const number = parsePositionalNumber(positionals, "pr checks", "pull request");
const context = await resolveRepoContext(deps);
const api = createClient(context);
// The combined status is keyed on the head SHA, so the PR is fetched first to
// learn it (GET /pulls/{n}), then its head commit's combined status.
const pull = await getPull(api, context, number);
const result = await fetchChecks(api, context, headSha(pull));
const help = [suggestCommand(context, `pr view ${number}`, "to see the pull request in full")];
// No statuses at all is a scalar `checks:` message, not an empty list block —
// there is nothing to tabulate, so the summary line stands on its own.
if (result.checks.length === 0) {
return renderScalar("checks", result.summary, help);
}
// Otherwise the summary occupies renderList's lead line, above the per-check rows.
return renderList({
noun: "checks",
rows: result.checks.map((check) => ({ name: check.name, conclusion: check.conclusion })),
countLine: `summary: ${result.summary}`,
help,
});
}
async function prDiff(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_DIFF_HELP;
}
const { flags, positionals } = parseFlags(args, { "--full": { takesValue: false } }, "pr diff");
const number = parsePositionalNumber(positionals, "pr diff", "pull request");
const full = flags["--full"] === true;
const context = await resolveRepoContext(deps);
const api = createClient(context);
const diff = await fetchPullDiff(api, context, number);
const result = truncateDiff(diff, full);
const item: Record<string, unknown> = { number, diff: result.diff };
// The `--full` next step is prepended above the standard `pr view` line only
// when the diff was actually cut short; the separate `truncated`/`original_length`
// fields signal the cut, keeping the diff text a verbatim prefix (spec).
const help = [suggestCommand(context, `pr view ${number}`, "to see the pull request in full")];
if (result.truncated) {
item.truncated = true;
item.original_length = result.original_length;
help.unshift(suggestCommand(context, `pr diff ${number} --full`, "to see the complete diff"));
}
return renderDetail({ noun: "pr_diff", item, help });
}
async function prCheckout(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CHECKOUT_HELP;
}
const { positionals } = parseFlags(args, {}, "pr checkout");
const number = parsePositionalNumber(positionals, "pr checkout", "pull request");
const context = await resolveRepoContext(deps);
const api = createClient(context);
// The head branch name comes from the PR fetch; the commit itself is fetched
// from refs/pull/<n>/head, so a fork head that is not a configured remote still
// resolves (ADR 0011). A PR without a head ref is the broken answer it is,
// rather than a branch name invented to fetch into.
const pull = await getPull(api, context, number);
const branch = pull.head?.ref;
if (!branch) {
throw axiError("Gitea returned a pull request with no head branch", "UNKNOWN");
}
await checkoutPullHead(deps, number, branch);
return renderDetail({
noun: "checkout",
item: { number, branch, status: "ok" },
help: [suggestCommand(context, `pr diff ${number}`, "to review the diff you checked out")],
});
}
async function prCreate(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CREATE_HELP;
}
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")],
});
}
// A review action: the event Gitea's review endpoint expects (`event`) paired
// with the value gitea-axi reports back (`action`).
interface ReviewAction {
event: string;
action: string;
}
// The three action switches, each mapped to its {@link ReviewAction}.
const REVIEW_ACTIONS: Record<string, ReviewAction> = {
"--approve": { event: "APPROVED", action: "approve" },
"--request-changes": { event: "REQUEST_CHANGES", action: "request-changes" },
"--comment": { event: "COMMENT", action: "comment" },
};
const PR_REVIEW_HELP_SUGGESTION = [
"Run `gitea-axi pr review --help` to see available flags",
];
/**
* The single review action to submit. Exactly one of the three action switches
* is required: zero or more than one is a `VALIDATION_ERROR` raised before any
* request goes out, mirroring `pr merge`'s conflicting-method rule. Body
* requirements are Gitea's to enforce, so they are not pre-checked here — a
* body-less event the server rejects surfaces as its own 422.
*/
function resolveReviewAction(flags: Record<string, string | true>): ReviewAction {
const selected = Object.entries(REVIEW_ACTIONS)
.filter(([flag]) => flags[flag] === true)
.map(([, value]) => value);
if (selected.length !== 1) {
throw axiError(
"Choose exactly one review action (--approve, --request-changes, or --comment)",
"VALIDATION_ERROR",
PR_REVIEW_HELP_SUGGESTION,
);
}
return selected[0]!;
}
async function prReview(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_REVIEW_HELP;
}
const { flags, positionals } = parseFlags(
args,
{
"--approve": { takesValue: false },
"--request-changes": { takesValue: false },
"--comment": { takesValue: false },
"--body": { takesValue: true },
"--body-file": { takesValue: true },
"--comments-file": { takesValue: true },
},
"pr review",
);
const number = parsePositionalNumber(positionals, "pr review", "pull request");
// Everything the caller's own input can settle is checked before any request
// goes out: the action flag count first, then the body source, then the
// inline-comment batch (parsed and shape-validated from the file).
const chosen = resolveReviewAction(flags);
const body = resolveBodySource(deps, flags, "pr review");
const inlineComments = loadInlineComments(deps, flags, "pr review");
const context = await resolveRepoContext(deps);
const api = createClient(context);
const payload: CreatePullReviewOptions = { event: chosen.event };
if (body !== undefined) {
payload.body = body;
}
// Replies are resolved against the PR's existing comments before the POST, so
// an unknown `reply_to` fails without a submission ever going out.
if (inlineComments !== undefined && inlineComments.length > 0) {
payload.comments = await resolveInlineComments(api, context, number, inlineComments);
}
try {
await api.repos.repoCreatePullReview(context.owner, context.name, number, payload);
} catch (error) {
throw classifyHttpError(error);
}
const item: Record<string, unknown> = { number, action: chosen.action };
if (payload.comments !== undefined) {
item.comments = payload.comments.length;
}
return renderDetail({
noun: "review",
item,
help: [suggestCommand(context, `pr view ${number} --reviews`, "to see the review in full")],
});
}
/**
* The assignee list to PATCH: the pull request's current assignees with the
* requested additions applied and removals dropped (fetch-then-patch, ADR 0007).
* The current logins are read off a fresh GET, then merged by the shared
* {@link mergeAssignees}.
*/
async function resolvePullAssignees(
api: GiteaClient,
context: RepoContext,
number: number,
add: string[],
remove: string[],
): Promise<string[]> {
const pull = await getPull(api, context, number);
return mergeAssignees(assigneeLogins(pull.assignees), add, remove);
}
/**
* The state to report for a pull request in the close no-op: `merged` marks a
* merged pull request (whose `state` Gitea reports as `closed`), otherwise the
* raw state stands.
*/
function pullState(pull: PullRequest): string {
return pull.merged ? "merged" : (pull.state ?? "closed");
}
async function prEdit(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_EDIT_HELP;
}
const { flags, lists, positionals } = parseFlags(
args,
{
"--title": { takesValue: true },
"--body": { takesValue: true },
"--body-file": { takesValue: true },
"--base": { takesValue: true },
"--add-label": { takesValue: true, repeatable: true },
"--remove-label": { takesValue: true, repeatable: true },
"--add-assignee": { takesValue: true, repeatable: true },
"--remove-assignee": { takesValue: true, repeatable: true },
"--add-reviewer": { takesValue: true, repeatable: true },
"--remove-reviewer": { takesValue: true, repeatable: true },
"--milestone": { takesValue: true },
},
"pr edit",
);
const number = parsePositionalNumber(positionals, "pr edit", "pull request");
const title = flagValue(flags, "--title");
const body = resolveBodySource(deps, flags, "pr edit");
const base = flagValue(flags, "--base");
const milestoneName = flagValue(flags, "--milestone");
const addLabels = lists["--add-label"] ?? [];
const removeLabels = lists["--remove-label"] ?? [];
const addAssignees = lists["--add-assignee"] ?? [];
const removeAssignees = lists["--remove-assignee"] ?? [];
const addReviewers = lists["--add-reviewer"] ?? [];
const removeReviewers = lists["--remove-reviewer"] ?? [];
const changesAssignees = addAssignees.length > 0 || removeAssignees.length > 0;
const nothingToDo =
title === undefined &&
body === undefined &&
base === undefined &&
milestoneName === undefined &&
addLabels.length === 0 &&
removeLabels.length === 0 &&
!changesAssignees &&
addReviewers.length === 0 &&
removeReviewers.length === 0;
if (nothingToDo) {
throw axiError("pr edit requires at least one change", "VALIDATION_ERROR", [
"Run `gitea-axi pr edit --help` to see the fields you can change",
]);
}
const context = await resolveRepoContext(deps);
const api = createClient(context);
// Name resolution runs before any mutation: whether a milestone or label name
// is real does not depend on the pull request's state, so a typo is reported
// before a single change lands, never leaving the pull request half-edited.
const milestoneId =
milestoneName !== undefined ? await resolveMilestoneId(api, context, milestoneName) : undefined;
const removeLabelIds = await resolveLabelIds(api, context, removeLabels);
// Title, body, base, milestone, and the recomputed assignee list travel in one
// PATCH — the reviewers are the exception, having no field on this body.
const payload: EditPullRequestOption = {};
if (title !== undefined) {
payload.title = title;
}
if (body !== undefined) {
payload.body = body;
}
if (base !== undefined) {
payload.base = base;
}
if (milestoneId !== undefined) {
payload.milestone = milestoneId;
}
if (changesAssignees) {
payload.assignees = await resolvePullAssignees(
api,
context,
number,
addAssignees,
removeAssignees,
);
}
if (Object.keys(payload).length > 0) {
try {
await api.repos.repoEditPullRequest(context.owner, context.name, number, payload);
} catch (error) {
throw classifyHttpError(error);
}
}
// Label mutations use Gitea's dedicated endpoints (idempotent). `--add-label`
// passes names straight through — Gitea accepts them there, no lookup needed —
// while `--remove-label` resolved to ids above (mirrors `issue edit`).
if (addLabels.length > 0) {
try {
await api.repos.issueAddLabel(context.owner, context.name, number, { labels: addLabels });
} catch (error) {
throw classifyHttpError(error);
}
}
for (const id of removeLabelIds) {
try {
await api.repos.issueRemoveLabel(context.owner, context.name, number, id);
} catch (error) {
// The label exists in the repo but is not applied to this pull request:
// Gitea answers 404, and the caller's intent (label absent) already holds,
// so it is silent success rather than an error.
if (httpStatus(error) === 404) {
continue;
}
throw classifyHttpError(error);
}
}
// Reviewer mutations go through Gitea's dedicated requested-reviewers endpoints
// — `EditPullRequestOption` has no reviewers field, so fetch-then-patch is
// structurally impossible here (ADR 0007 amendment). Each direction is one call
// carrying the whole list.
if (addReviewers.length > 0) {
const options: PullReviewRequestOptions = { reviewers: addReviewers };
try {
await api.repos.repoCreatePullReviewRequests(context.owner, context.name, number, options);
} catch (error) {
throw classifyHttpError(error);
}
}
if (removeReviewers.length > 0) {
const options: PullReviewRequestOptions = { reviewers: removeReviewers };
try {
await api.repos.repoDeletePullReviewRequests(context.owner, context.name, number, options);
} catch (error) {
throw classifyHttpError(error);
}
}
// The mutation ran, so the block is named for the action, not the entity.
return renderDetail({
noun: "edited",
item: { number, status: "ok" },
help: [suggestCommand(context, `pr view ${number}`, "to see the pull request in full")],
});
}
async function prClose(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CLOSE_HELP;
}
const { flags, positionals } = parseFlags(args, { "--comment": { takesValue: true } }, "pr close");
const number = parsePositionalNumber(positionals, "pr close", "pull request");
const comment = flagValue(flags, "--comment");
const context = await resolveRepoContext(deps);
const api = createClient(context);
// Read the current state first: an already-closed or merged pull request
// short-circuits to the idempotent no-op below rather than issuing a redundant
// PATCH. A merged pull request has state `closed`, so this catches both.
const pull = await getPull(api, context, number);
if (pull.state === "closed") {
return renderDetail({
noun: "pull_request",
item: { number, state: pullState(pull), already: true },
help: [suggestCommand(context, `pr reopen ${number}`, "to reopen this pull request")],
});
}
try {
await api.repos.repoEditPullRequest(context.owner, context.name, number, { state: "closed" });
} catch (error) {
throw classifyHttpError(error);
}
// The comment is a second call after the close lands. A failure here is
// surfaced, never swallowed: the pull request is closed, but the caller must
// learn that the comment they asked for did not post.
if (comment !== undefined) {
try {
await api.repos.issueCreateComment(context.owner, context.name, number, { body: comment });
} catch (error) {
throw classifyHttpError(error);
}
}
return renderDetail({
noun: "closed",
item: { number, status: "ok" },
help: [suggestCommand(context, `pr reopen ${number}`, "to reopen this pull request")],
});
}
async function prReopen(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_REOPEN_HELP;
}
const { positionals } = parseFlags(args, {}, "pr reopen");
const number = parsePositionalNumber(positionals, "pr reopen", "pull request");
const context = await resolveRepoContext(deps);
const api = createClient(context);
// Read the current state first: an already-open pull request short-circuits to
// the idempotent no-op below rather than issuing a redundant PATCH.
const pull = await getPull(api, context, number);
if (pull.state === "open") {
return renderDetail({
noun: "pull_request",
item: { number, state: "open", already: true },
help: [suggestCommand(context, `pr close ${number}`, "to close this pull request")],
});
}
try {
await api.repos.repoEditPullRequest(context.owner, context.name, number, { state: "open" });
} catch (error) {
throw classifyHttpError(error);
}
return renderDetail({
noun: "reopened",
item: { number, status: "ok" },
help: [suggestCommand(context, `pr view ${number}`, "to see the pull request in full")],
});
}
/**
* The merge method to send and the value to report. A single explicit selector
* — `--method` or one of the {@link MERGE_SHORTHANDS} — is resolved to its
* method; giving more than one, in any combination, is a `VALIDATION_ERROR`
* ("conflicting or duplicate action flags"). With no selector the method is
* `undefined`: the caller sends Gitea's baseline `merge` but reports `default`,
* signalling that no method was chosen.
*/
function resolveMergeMethod(flags: Record<string, string | true>): MergeMethod | undefined {
const selected: MergeMethod[] = [];
for (const [flag, method] of Object.entries(MERGE_SHORTHANDS)) {
if (flags[flag] === true) {
selected.push(method);
}
}
const hasMethodFlag = flags["--method"] !== undefined;
if (selected.length + (hasMethodFlag ? 1 : 0) > 1) {
throw axiError(
"Choose only one merge method (--method, --merge, --squash, or --rebase)",
"VALIDATION_ERROR",
PR_MERGE_HELP_SUGGESTION,
);
}
if (hasMethodFlag) {
// parseEnumFlag never returns undefined here — the flag is present — but its
// signature allows it, so the non-null assertion documents that.
return parseEnumFlag(flags["--method"], "--method", MERGE_METHODS, PR_MERGE_HELP_SUGGESTION)!;
}
return selected[0];
}
async function prMerge(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_MERGE_HELP;
}
const { flags, positionals } = parseFlags(
args,
{
"--method": { takesValue: true },
"--merge": { takesValue: false },
"--squash": { takesValue: false },
"--rebase": { takesValue: false },
"--auto": { takesValue: false },
"--delete-branch": { takesValue: false },
"--merge-commit-id": { takesValue: true },
"--subject": { takesValue: true },
"--body": { takesValue: true },
"--body-file": { takesValue: true },
},
"pr merge",
);
const number = parsePositionalNumber(positionals, "pr merge", "pull request");
// Everything the caller's own input can settle is checked before any request
// goes out, so a rejected invocation never merges. The method resolves first,
// then the manually-merged/`--merge-commit-id` pairing, then the body source.
const method = resolveMergeMethod(flags);
const effectiveMethod: MergeMethod = method ?? "merge";
const mergeCommitId = flagValue(flags, "--merge-commit-id");
if (effectiveMethod === "manually-merged" && mergeCommitId === undefined) {
throw axiError(
"--method manually-merged requires --merge-commit-id <sha>",
"VALIDATION_ERROR",
PR_MERGE_HELP_SUGGESTION,
);
}
if (effectiveMethod !== "manually-merged" && mergeCommitId !== undefined) {
throw axiError(
"--merge-commit-id is only valid with --method manually-merged",
"VALIDATION_ERROR",
PR_MERGE_HELP_SUGGESTION,
);
}
const body = resolveBodySource(deps, flags, "pr merge");
const subject = flagValue(flags, "--subject");
const context = await resolveRepoContext(deps);
const api = createClient(context);
// Read the current state first: an already-merged pull request short-circuits
// to the idempotent entity block below rather than issuing a merge that Gitea
// would reject as redundant.
const pull = await getPull(api, context, number);
if (pull.merged) {
return renderDetail({
noun: "pull_request",
item: {
number,
state: "merged",
merged_by: pull.merged_by?.login ?? null,
merged_at: relativeTime(pull.merged_at, new Date()),
},
help: [suggestCommand(context, `pr view ${number}`, "to see the merged pull request")],
});
}
const payload: MergePullRequestOption = { Do: effectiveMethod };
if (mergeCommitId !== undefined) {
payload.MergeCommitID = mergeCommitId;
}
if (subject !== undefined) {
payload.MergeTitleField = subject;
}
if (body !== undefined) {
payload.MergeMessageField = body;
}
if (flags["--auto"] === true) {
payload.merge_when_checks_succeed = true;
}
if (flags["--delete-branch"] === true) {
payload.delete_branch_after_merge = true;
}
try {
await api.repos.repoMergePullRequest(context.owner, context.name, number, payload);
} catch (error) {
// A merge-blocked pull request (stale head, failing checks, conflicts) comes
// back 405/409, which classifyHttpError already maps to VALIDATION_ERROR. Its
// classified message (the server's own detail) is kept, and remediation lines
// are added pointing at the two commands that unblock it: update-branch for a
// stale head, checkout to resolve conflicts locally.
const classified = classifyHttpError(error);
const status = httpStatus(error);
if (status === 405 || status === 409) {
throw axiError(classified.message, "VALIDATION_ERROR", [
suggestCommand(context, `pr update-branch ${number}`, "to merge the base branch into a stale head"),
suggestCommand(context, `pr checkout ${number}`, "to check the branch out and resolve conflicts locally"),
]);
}
throw classified;
}
// The mutation ran, so the block is named for the action. `method` reports the
// caller's choice: the resolved method, or `default` when none was given.
return renderDetail({
noun: "merged",
item: { number, status: "ok", method: method ?? "default" },
help: [suggestCommand(context, `pr view ${number}`, "to see the merged pull request")],
});
}
async function prUpdateBranch(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_UPDATE_BRANCH_HELP;
}
const { flags, positionals } = parseFlags(
args,
{ "--style": { takesValue: true } },
"pr update-branch",
);
const number = parsePositionalNumber(positionals, "pr update-branch", "pull request");
const style: UpdateStyle =
parseEnumFlag(flags["--style"], "--style", UPDATE_STYLES, PR_UPDATE_BRANCH_HELP_SUGGESTION) ??
"merge";
const context = await resolveRepoContext(deps);
const api = createClient(context);
try {
await api.repos.repoUpdatePullRequest(context.owner, context.name, number, { style });
} catch (error) {
throw classifyHttpError(error);
}
return renderDetail({
noun: "updated",
item: { number, status: "ok" },
help: [suggestCommand(context, `pr checks ${number}`, "to monitor CI after the update")],
});
}
export function prCommand(deps: CliDeps) {
return async (args: string[]): Promise<string> => {
const [subcommand, ...rest] = args;
if (!subcommand || subcommand === "--help") {
return PR_HELP;
}
if (subcommand === "list") {
return prList(deps, rest);
}
if (subcommand === "view") {
return prView(deps, rest);
}
if (subcommand === "diff") {
return prDiff(deps, rest);
}
if (subcommand === "checkout") {
return prCheckout(deps, rest);
}
if (subcommand === "checks") {
return prChecks(deps, rest);
}
if (subcommand === "create") {
return prCreate(deps, rest);
}
if (subcommand === "edit") {
return prEdit(deps, rest);
}
if (subcommand === "merge") {
return prMerge(deps, rest);
}
if (subcommand === "update-branch") {
return prUpdateBranch(deps, rest);
}
if (subcommand === "close") {
return prClose(deps, rest);
}
if (subcommand === "reopen") {
return prReopen(deps, rest);
}
if (subcommand === "review") {
return prReview(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",
]);
};
}