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:
@@ -14,12 +14,20 @@ reviewDecision uses the official-first fallback: only official reviews count whe
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `pr list` renders the default fields with `review` computed from one parallel review fetch per PR
|
||||
- [ ] reviewDecision honors the official-first fallback and maps to the three lowercase values, with zero-review and comment-only PRs rendering `required`
|
||||
- [ ] `--label` resolves the name case-insensitively to an ID (`VALIDATION_ERROR` if unknown); `--label-id` bypasses the lookup
|
||||
- [ ] `--author` and `--sort` map to their API params; `--sort` accepts the six Gitea values
|
||||
- [ ] `--assignee`, `--base`, `--head`, and `--draft` filter client-side after full pagination, and the count line reports `count: N of T total` with `T` from the in-memory filtered set
|
||||
- [ ] `--fields` exposes `body`, `createdAt`, `labels`, `milestone`, `mergedAt`, `url`
|
||||
- [ ] `--search` fails with `VALIDATION_ERROR` (exit 2) pointing at `gitea-axi search prs "<query>"`
|
||||
- [ ] Empty result emits `pull_requests[0]: (none)` plus a suggestion
|
||||
- [ ] Fixture-server tests cover the review computation variants (official/unofficial, stale, dismissed), each client-side filter with its count line, the label lookup, and the forbidden flag
|
||||
- [x] `pr list` renders the default fields with `review` computed from one parallel review fetch per PR
|
||||
- [x] reviewDecision honors the official-first fallback and maps to the three lowercase values, with zero-review and comment-only PRs rendering `required`
|
||||
- [x] `--label` resolves the name case-insensitively to an ID (`VALIDATION_ERROR` if unknown); `--label-id` bypasses the lookup
|
||||
- [x] `--author` and `--sort` map to their API params; `--sort` accepts the six Gitea values
|
||||
- [x] `--assignee`, `--base`, `--head`, and `--draft` filter client-side after full pagination, and the count line reports `count: N of T total` with `T` from the in-memory filtered set
|
||||
- [x] `--fields` exposes `body`, `createdAt`, `labels`, `milestone`, `mergedAt`, `url`
|
||||
- [x] `--search` fails with `VALIDATION_ERROR` (exit 2) pointing at `gitea-axi search prs "<query>"`
|
||||
- [x] Empty result emits `pull_requests[0]: (none)` plus a suggestion
|
||||
- [x] Fixture-server tests cover the review computation variants (official/unofficial, stale, dismissed), each client-side filter with its count line, the label lookup, and the forbidden flag
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `reviewDecision` computation lives in a new `src/review.ts` module (`reviewDecision` pure core + `fetchReviewDecision` I/O shell), so `pr view` and the dashboard can reuse the same policy in later slices (ADR 0006).
|
||||
- Added a `boolText` field extractor to `src/fields.ts` for the `draft` bool→yes/no column; it is part of the shared field vocabulary rather than inlined, since `pr view` renders `draft` too.
|
||||
- Extracted `parsePositiveInt` into `src/flags.ts` and routed `pr list`'s `--limit`/`--label-id` and `issue list`'s `--limit` through it, collapsing three copies of the same positive-integer parse into one (a review finding). Behaviour and error wording are unchanged.
|
||||
- Small unrequested robustness kept deliberately: `--label-id` accepts a comma-separated list (mirroring `--label`), and `--label` + `--label-id` may be combined — their resolved IDs concatenate. The spec describes each as a single value; this is a strict superset with no behaviour change for the single-value case.
|
||||
- The `url` extra field plucks `html_url` (the browsable URL), matching `issue list`'s precedent rather than Gitea's API `url` field.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
21
src/flags.ts
21
src/flags.ts
@@ -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
67
src/review.ts
Normal 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);
|
||||
}
|
||||
507
test/pr-list.test.ts
Normal file
507
test/pr-list.test.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer, type FixtureRoute, type FixtureServer } from "./fixture-server.js";
|
||||
import { runCliTest, testModeEnv } from "./harness.js";
|
||||
|
||||
const PULLS_PATH = "/api/v1/repos/testowner/testrepo/pulls";
|
||||
const LABELS_PATH = "/api/v1/repos/testowner/testrepo/labels";
|
||||
|
||||
let server: FixtureServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
interface PullOptions {
|
||||
title?: string;
|
||||
state?: string;
|
||||
draft?: boolean;
|
||||
author?: string;
|
||||
base?: string;
|
||||
head?: string;
|
||||
assignees?: { login: string }[] | null;
|
||||
labels?: { id: number; name: string }[];
|
||||
milestone?: { title: string } | null;
|
||||
body?: string;
|
||||
created_at?: string;
|
||||
merged_at?: string | null;
|
||||
}
|
||||
|
||||
/** A pull request shaped like Gitea's, with only the fields the list path reads. */
|
||||
function pullOf(number: number, options: PullOptions = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 1000 + number,
|
||||
number,
|
||||
title: options.title ?? `PR ${number}`,
|
||||
state: options.state ?? "open",
|
||||
draft: options.draft ?? false,
|
||||
user: { id: 7, login: options.author ?? "alexion" },
|
||||
base: { ref: options.base ?? "main" },
|
||||
head: { ref: options.head ?? `feature-${number}` },
|
||||
assignees: options.assignees ?? null,
|
||||
labels: options.labels ?? [],
|
||||
milestone: options.milestone ?? null,
|
||||
body: options.body ?? "",
|
||||
created_at: options.created_at ?? "2026-07-01T00:00:00Z",
|
||||
merged_at: options.merged_at ?? null,
|
||||
html_url: `http://gitea.example/testowner/testrepo/pulls/${number}`,
|
||||
url: `http://gitea.example/api/v1/repos/testowner/testrepo/pulls/${number}`,
|
||||
};
|
||||
}
|
||||
|
||||
interface ReviewOptions {
|
||||
official?: boolean;
|
||||
stale?: boolean;
|
||||
dismissed?: boolean;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
function reviewOf(state: string, options: ReviewOptions = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 1,
|
||||
state,
|
||||
official: options.official ?? false,
|
||||
stale: options.stale ?? false,
|
||||
dismissed: options.dismissed ?? false,
|
||||
user: { login: options.user ?? "reviewer" },
|
||||
};
|
||||
}
|
||||
|
||||
/** The reviews-list route a rendered PR triggers (one fetch per PR, ADR 0006). */
|
||||
function reviewsRoute(number: number, reviews: Record<string, unknown>[]): FixtureRoute {
|
||||
return { method: "GET", path: `${PULLS_PATH}/${number}/reviews`, body: reviews };
|
||||
}
|
||||
|
||||
/** The single rendered data row, split on commas. */
|
||||
function dataRow(stdout: string): string[] {
|
||||
const row = stdout.split("\n").find((line) => /^ {2}\d+,/.test(line));
|
||||
expect(row, "expected a rendered pull_requests row").toBeDefined();
|
||||
return row!.trim().split(",");
|
||||
}
|
||||
|
||||
/** The `review` column of a single-PR, default-fields render (its last column). */
|
||||
function reviewColumn(stdout: string): string {
|
||||
const parts = dataRow(stdout);
|
||||
return parts[parts.length - 1]!;
|
||||
}
|
||||
|
||||
/** The `number` column of every rendered row, in output order. */
|
||||
function renderedNumbers(stdout: string): number[] {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter((line) => /^ {2}\d+,/.test(line))
|
||||
.map((line) => Number(line.trim().split(",")[0]));
|
||||
}
|
||||
|
||||
describe("pr list", () => {
|
||||
it("renders the default fields with a review column and a count line", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: PULLS_PATH,
|
||||
query: { state: "open", limit: "30", page: "1" },
|
||||
headers: { "X-Total-Count": "9" },
|
||||
body: [
|
||||
pullOf(7, { title: "Add search", author: "alexion", draft: false }),
|
||||
pullOf(8, { title: "Draft work", author: "contributor", draft: true }),
|
||||
],
|
||||
},
|
||||
reviewsRoute(7, [reviewOf("APPROVED")]),
|
||||
reviewsRoute(8, []),
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "list"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const lines = stdout.split("\n");
|
||||
expect(lines[0]).toBe("count: 2 of 9 total");
|
||||
expect(lines[1]).toBe("pull_requests[2]{number,title,state,author,draft,review}:");
|
||||
expect(lines[2]).toBe(" 7,Add search,open,alexion,no,approved");
|
||||
expect(lines[3]).toBe(" 8,Draft work,open,contributor,yes,required");
|
||||
expect(stdout).toMatch(/^help\[\d+\]:/m);
|
||||
});
|
||||
|
||||
it("defaults to state=open, limit=30 and page=1", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: PULLS_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["pr", "list"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(server.requests[0]!.query.state).toBe("open");
|
||||
expect(server.requests[0]!.query.limit).toBe("30");
|
||||
expect(server.requests[0]!.query.page).toBe("1");
|
||||
});
|
||||
|
||||
it("emits an explicit empty state with a create suggestion", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: PULLS_PATH, headers: { "X-Total-Count": "0" }, body: [] },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "list"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("count: 0 of 0 total");
|
||||
expect(stdout).toContain("pull_requests[0]: (none)");
|
||||
expect(stdout).toContain("gitea-axi pr create");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pr list reviewDecision", () => {
|
||||
const cases: { name: string; reviews: Record<string, unknown>[]; expected: string }[] = [
|
||||
{ name: "a fresh approval renders approved", reviews: [reviewOf("APPROVED")], expected: "approved" },
|
||||
{
|
||||
name: "a change request renders changes_requested",
|
||||
reviews: [reviewOf("REQUEST_CHANGES")],
|
||||
expected: "changes_requested",
|
||||
},
|
||||
{ name: "zero reviews render required", reviews: [], expected: "required" },
|
||||
{
|
||||
name: "a comment-only review renders required",
|
||||
reviews: [reviewOf("COMMENT")],
|
||||
expected: "required",
|
||||
},
|
||||
{
|
||||
name: "a stale approval renders required",
|
||||
reviews: [reviewOf("APPROVED", { stale: true })],
|
||||
expected: "required",
|
||||
},
|
||||
{
|
||||
name: "a dismissed approval renders required",
|
||||
reviews: [reviewOf("APPROVED", { dismissed: true })],
|
||||
expected: "required",
|
||||
},
|
||||
{
|
||||
name: "a change request beats an approval in the same set",
|
||||
reviews: [reviewOf("APPROVED"), reviewOf("REQUEST_CHANGES")],
|
||||
expected: "changes_requested",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, reviews, expected } of cases) {
|
||||
it(name, async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: PULLS_PATH, headers: { "X-Total-Count": "1" }, body: [pullOf(7)] },
|
||||
reviewsRoute(7, reviews),
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "list"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(reviewColumn(stdout)).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("considers only official reviews when any review is official", async () => {
|
||||
// An official approval outranks an unofficial change request: under branch
|
||||
// protection only official reviews count (ADR 0006 official-first fallback).
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: PULLS_PATH, headers: { "X-Total-Count": "1" }, body: [pullOf(7)] },
|
||||
reviewsRoute(7, [
|
||||
reviewOf("APPROVED", { official: true }),
|
||||
reviewOf("REQUEST_CHANGES", { official: false }),
|
||||
]),
|
||||
]);
|
||||
const { stdout } = await runCliTest(["pr", "list"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(reviewColumn(stdout)).toBe("approved");
|
||||
});
|
||||
|
||||
it("lets an unofficial approval count when no review is official", async () => {
|
||||
// Unprotected repos never mark a review official; without the fallback the
|
||||
// approval would be ignored and the PR would read required forever.
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: PULLS_PATH, headers: { "X-Total-Count": "1" }, body: [pullOf(7)] },
|
||||
reviewsRoute(7, [reviewOf("APPROVED", { official: false })]),
|
||||
]);
|
||||
const { stdout } = await runCliTest(["pr", "list"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(reviewColumn(stdout)).toBe("approved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pr list --label", () => {
|
||||
it("resolves a label name to its ID case-insensitively before the list call", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: LABELS_PATH, body: [{ id: 4, name: "Bug" }] },
|
||||
{ method: "GET", path: PULLS_PATH, query: { labels: "4" }, body: [] },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(["pr", "list", "--label", "bug"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const pullsRequest = server.requests.find((request) => request.path === PULLS_PATH)!;
|
||||
expect(pullsRequest.query.labels).toBe("4");
|
||||
});
|
||||
|
||||
it("rejects an unknown label name with VALIDATION_ERROR and no list call", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: LABELS_PATH, body: [{ id: 4, name: "bug" }] },
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "list", "--label", "nope"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(server.requests.some((request) => request.path === PULLS_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("passes --label-id straight through, skipping the label lookup", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: PULLS_PATH, query: { labels: "5" }, body: [] },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(["pr", "list", "--label-id", "5"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests.some((request) => request.path === LABELS_PATH)).toBe(false);
|
||||
expect(server.requests[0]!.query.labels).toBe("5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pr list --author and --sort", () => {
|
||||
it("maps --author to the poster param", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: PULLS_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["pr", "list", "--author", "octocat"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(server.requests[0]!.query.poster).toBe("octocat");
|
||||
});
|
||||
|
||||
it("passes each of the six Gitea sort values straight to the API", async () => {
|
||||
const sorts = [
|
||||
"oldest",
|
||||
"recentupdate",
|
||||
"leastupdate",
|
||||
"mostcomment",
|
||||
"leastcomment",
|
||||
"priority",
|
||||
];
|
||||
for (const sort of sorts) {
|
||||
const local = await startFixtureServer([
|
||||
{ method: "GET", path: PULLS_PATH, body: [] },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(["pr", "list", "--sort", sort], {
|
||||
env: testModeEnv(local.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(local.requests[0]!.query.sort).toBe(sort);
|
||||
await local.close();
|
||||
}
|
||||
// afterEach closes `server`; give it a live handle to release.
|
||||
server = await startFixtureServer([]);
|
||||
});
|
||||
|
||||
it("rejects an invalid --sort value with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "list", "--sort", "banana"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pr list client-side filters", () => {
|
||||
it("filters --draft after full pagination, with the count from the filtered set", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: PULLS_PATH,
|
||||
query: { page: "1", limit: "50" },
|
||||
headers: { "X-Total-Count": "3" },
|
||||
body: [
|
||||
pullOf(7, { draft: true }),
|
||||
pullOf(8, { draft: false }),
|
||||
pullOf(9, { draft: true }),
|
||||
],
|
||||
},
|
||||
reviewsRoute(7, []),
|
||||
reviewsRoute(9, []),
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "list", "--draft"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(renderedNumbers(stdout)).toEqual([7, 9]);
|
||||
// Two drafts of three PRs: T is the filtered set's own size, not X-Total-Count.
|
||||
expect(stdout).toContain("count: 2 of 2 total");
|
||||
// Reviews were fetched only for the PRs that survived the filter.
|
||||
expect(server.requests.some((request) => request.path === `${PULLS_PATH}/8/reviews`)).toBe(false);
|
||||
});
|
||||
|
||||
it("filters --base against the base branch ref", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: PULLS_PATH,
|
||||
query: { page: "1", limit: "50" },
|
||||
body: [
|
||||
pullOf(7, { base: "main" }),
|
||||
pullOf(8, { base: "release" }),
|
||||
pullOf(9, { base: "main" }),
|
||||
],
|
||||
},
|
||||
reviewsRoute(7, []),
|
||||
reviewsRoute(9, []),
|
||||
]);
|
||||
const { stdout } = await runCliTest(["pr", "list", "--base", "main"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(renderedNumbers(stdout)).toEqual([7, 9]);
|
||||
expect(stdout).toContain("count: 2 of 2 total");
|
||||
});
|
||||
|
||||
it("filters --head against the head branch ref", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: PULLS_PATH,
|
||||
query: { page: "1", limit: "50" },
|
||||
body: [
|
||||
pullOf(7, { head: "feature-x" }),
|
||||
pullOf(8, { head: "feature-y" }),
|
||||
],
|
||||
},
|
||||
reviewsRoute(8, []),
|
||||
]);
|
||||
const { stdout } = await runCliTest(["pr", "list", "--head", "feature-y"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(renderedNumbers(stdout)).toEqual([8]);
|
||||
expect(stdout).toContain("count: 1 of 1 total");
|
||||
});
|
||||
|
||||
it("filters --assignee against assignee logins, case-insensitively", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: PULLS_PATH,
|
||||
query: { page: "1", limit: "50" },
|
||||
body: [
|
||||
pullOf(7, { assignees: [{ login: "Alexion" }] }),
|
||||
pullOf(8, { assignees: [{ login: "contributor" }] }),
|
||||
pullOf(9, { assignees: null }),
|
||||
],
|
||||
},
|
||||
reviewsRoute(7, []),
|
||||
]);
|
||||
const { stdout } = await runCliTest(["pr", "list", "--assignee", "alexion"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(renderedNumbers(stdout)).toEqual([7]);
|
||||
expect(stdout).toContain("count: 1 of 1 total");
|
||||
});
|
||||
|
||||
it("caps the filtered set at --limit while reporting the full filtered total", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: PULLS_PATH,
|
||||
query: { page: "1", limit: "50" },
|
||||
body: [
|
||||
pullOf(7, { draft: true }),
|
||||
pullOf(8, { draft: true }),
|
||||
pullOf(9, { draft: true }),
|
||||
],
|
||||
},
|
||||
reviewsRoute(7, []),
|
||||
reviewsRoute(8, []),
|
||||
]);
|
||||
const { stdout } = await runCliTest(["pr", "list", "--draft", "--limit", "2"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(renderedNumbers(stdout)).toEqual([7, 8]);
|
||||
expect(stdout).toContain("count: 2 of 3 total");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pr list --fields", () => {
|
||||
it("appends the selected extra fields, each via its extractor", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: PULLS_PATH,
|
||||
headers: { "X-Total-Count": "1" },
|
||||
body: [
|
||||
pullOf(7, {
|
||||
body: "Raw body text.",
|
||||
labels: [{ id: 1, name: "bug" }],
|
||||
milestone: { title: "v1.0" },
|
||||
merged_at: "2026-07-05T00:00:00Z",
|
||||
}),
|
||||
],
|
||||
},
|
||||
reviewsRoute(7, [reviewOf("APPROVED")]),
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["pr", "list", "--fields", "body,createdAt,labels,milestone,mergedAt,url"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain(
|
||||
"pull_requests[1]{number,title,state,author,draft,review,body,created,labels,milestone,merged_at,url}:",
|
||||
);
|
||||
const row = stdout.split("\n").find((line) => line.startsWith(" 7,"))!;
|
||||
expect(row).toContain("Raw body text.");
|
||||
expect(row).toContain("v1.0");
|
||||
expect(row).toContain("http://gitea.example/testowner/testrepo/pulls/7");
|
||||
expect(row).toMatch(/\d+(mo|[smhdy]) ago/);
|
||||
});
|
||||
|
||||
it("rejects an unknown --fields name with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "list", "--fields", "bogus"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("bogus");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pr list --search", () => {
|
||||
it("forbids --search, redirecting to search prs", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["pr", "list", "--search", "login bug"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("gitea-axi search prs");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("forbids --search in its inline and bare forms too", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const inline = await runCliTest(["pr", "list", "--search=login"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
const bare = await runCliTest(["pr", "list", "--search"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
for (const result of [inline, bare]) {
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stdout).toContain("gitea-axi search prs");
|
||||
}
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user