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

@@ -20,6 +20,7 @@ import {
parseEnumFlag,
parseFlags,
parsePositionalNumber,
parsePositiveInt,
splitFlag,
} from "../flags.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
@@ -304,15 +305,7 @@ function parseLimit(value: string | true | undefined): number {
if (value === undefined) {
return DEFAULT_LIMIT;
}
const limit = Number(value);
if (value === true || !Number.isInteger(limit) || limit < 1) {
throw axiError(
`Invalid --limit value: ${String(value)} (expected a positive integer)`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
return limit;
return parsePositiveInt(value, "--limit", ISSUE_LIST_HELP_SUGGESTION);
}
function issueListSuggestions(

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);
}

View File

@@ -32,6 +32,20 @@ export function lowercased<T>(name: string, path: string = name): FieldDef<T> {
};
}
/**
* Render a boolean field as one of two words (default `yes`/`no`). A missing or
* falsy value reads as the `no` text, so an absent `draft` flag is reported as
* the ordinary non-draft it represents rather than blank.
*/
export function boolText<T>(
name: string,
path: string = name,
yes = "yes",
no = "no",
): FieldDef<T> {
return { name, extract: (raw) => (pluckPath(raw, path) ? yes : no) };
}
/**
* Join a field holding an array of objects (labels, assignees) into a single
* string of each element's `key` property.

View File

@@ -71,6 +71,27 @@ export function parseEnumFlag<T extends string>(
return value as T;
}
/**
* Parse a flag's value as a positive integer, rejecting a bare switch or a
* non-integer with a uniform `VALIDATION_ERROR`. `label` names the flag in the
* message (e.g. "--limit", "--label-id"). Shared by every flag that takes one.
*/
export function parsePositiveInt(
value: string | true,
label: string,
suggestions: string[] = [],
): number {
const parsed = Number(value);
if (value === true || !Number.isInteger(parsed) || parsed < 1) {
throw axiError(
`Invalid ${label} value: ${String(value)} (expected a positive integer)`,
"VALIDATION_ERROR",
suggestions,
);
}
return parsed;
}
/** Split "--flag=value" into name and inline value; "--flag" has none. */
export function splitFlag(arg: string): SplitFlag {
const equals = arg.indexOf("=");

67
src/review.ts Normal file
View File

@@ -0,0 +1,67 @@
import type { PullReview } from "gitea-js";
import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js";
import { classifyHttpError } from "./errors.js";
/**
* gitea-axi's reviewDecision, the three-value result of {@link reviewDecision}.
* Deliberately narrower than gh-axi's four values: Gitea offers no non-admin way
* to tell whether review is formally required, so there is no `none` (ADR 0006).
*/
export type ReviewDecision = "approved" | "changes_requested" | "required";
// Gitea's ReviewStateType is typed as a bare string; these are the two states
// the decision turns on. A comment-only or pending review is neither, so it
// falls through to `required`.
const APPROVED = "APPROVED";
const REQUEST_CHANGES = "REQUEST_CHANGES";
/**
* Derive a PR's reviewDecision from its reviews, using the official-first
* fallback (ADR 0006): when any review is `official`, only official reviews
* count — preserving branch-protection semantics; otherwise every review counts,
* since unprotected repos never mark a review official and `approved` would
* otherwise be unreachable there.
*
* Within the considered set: a non-dismissed `REQUEST_CHANGES` wins as
* `changes_requested`; else a non-dismissed, non-stale `APPROVED` is `approved`;
* everything else — zero reviews, comment-only, stale or dismissed approvals —
* is `required`.
*/
export function reviewDecision(reviews: PullReview[]): ReviewDecision {
const hasOfficial = reviews.some((review) => review.official === true);
const considered = hasOfficial
? reviews.filter((review) => review.official === true)
: reviews;
if (considered.some((review) => review.state === REQUEST_CHANGES && !review.dismissed)) {
return "changes_requested";
}
if (
considered.some(
(review) => review.state === APPROVED && !review.stale && !review.dismissed,
)
) {
return "approved";
}
return "required";
}
/**
* Fetch a PR's reviews and reduce them to its reviewDecision. One HTTP call per
* PR — `pr list` issues these in parallel across the rows it renders (ADR 0006).
*/
export async function fetchReviewDecision(
api: GiteaClient,
context: RepoContext,
number: number,
): Promise<ReviewDecision> {
let reviews: PullReview[];
try {
const response = await api.repos.repoListPullReviews(context.owner, context.name, number);
reviews = response.data ?? [];
} catch (error) {
throw classifyHttpError(error);
}
return reviewDecision(reviews);
}