feat: add pr list (task 0008)
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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user