feat: add pr list (task 0008)
All checks were successful
CI / test (pull_request) Successful in 30s
CI / test (push) Successful in 31s

Implements `pr list` with the two policies later PR slices reuse:
client-side filtering with the filtered-set count line (ADR 0005) and
the official-first reviewDecision via parallel per-PR review fetches
(ADR 0006), extracted into src/review.ts.

API-supported flags map to their params (--state, --author→poster,
--label name→ID, --label-id, --sort, --limit, --fields); --assignee,
--base, --head, and --draft filter in-process after full pagination,
with the count line's total taken from the filtered set. --search is
refused with a redirect to `search prs`.

Adds a boolText field extractor and a shared parsePositiveInt helper,
the latter also adopted by issue list's --limit parsing.
This commit was merged in pull request #8.
This commit is contained in:
2026-07-12 19:23:29 -04:00
parent 7a41807a8a
commit 6333058b72
7 changed files with 964 additions and 20 deletions

View File

@@ -5,21 +5,65 @@ 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 {
boolText,
extractRow,
joined,
lowercased,
pluck,
relativeTimeField,
selectExtraFields,
type FieldDef,
} from "../fields.js";
import {
flagValue,
parseEnumFlag,
parseFlags,
parsePositionalNumber,
parsePositiveInt,
splitFlag,
} from "../flags.js";
import { currentBranch } from "../git.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { renderDetail } from "../render.js";
import { fetchAllPages, readTotalCount } from "../paginate.js";
import { formatCountLine, renderDetail, renderList } from "../render.js";
import { fetchReviewDecision } from "../review.js";
import { suggestCommand } from "../suggestions.js";
export const PR_HELP = `usage: gitea-axi pr <command> [flags]
commands:
list List pull requests in the current repository
create Create a pull request
comment Post a comment on a pull request
Run \`gitea-axi pr <command> --help\` for the flags of a command.
`;
export const PR_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
--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
@@ -61,6 +105,293 @@ 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",
];
// 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: pluck("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"),
};
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 },
},
"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 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 rows = pulls.map((pull, index) => {
const row = extractRow(pull, PR_LIST_FIELDS, { now });
row.review = decisions[index];
Object.assign(row, extractRow(pull, extraFields, { now }));
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) {
@@ -298,6 +629,9 @@ export function prCommand(deps: CliDeps) {
if (!subcommand || subcommand === "--help") {
return PR_HELP;
}
if (subcommand === "list") {
return prList(deps, rest);
}
if (subcommand === "create") {
return prCreate(deps, rest);
}