feat: add pr list (task 0008) #8

Merged
alexion merged 1 commits from task-0008-pr-list into main 2026-07-12 19:50:28 -04:00
7 changed files with 964 additions and 20 deletions
Showing only changes of commit 6333058b72 - Show all commits

View File

@@ -14,12 +14,20 @@ reviewDecision uses the official-first fallback: only official reviews count whe
## Acceptance criteria ## Acceptance criteria
- [ ] `pr list` renders the default fields with `review` computed from one parallel review fetch per PR - [x] `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` - [x] 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 - [x] `--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 - [x] `--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 - [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
- [ ] `--fields` exposes `body`, `createdAt`, `labels`, `milestone`, `mergedAt`, `url` - [x] `--fields` exposes `body`, `createdAt`, `labels`, `milestone`, `mergedAt`, `url`
- [ ] `--search` fails with `VALIDATION_ERROR` (exit 2) pointing at `gitea-axi search prs "<query>"` - [x] `--search` fails with `VALIDATION_ERROR` (exit 2) pointing at `gitea-axi search prs "<query>"`
- [ ] Empty result emits `pull_requests[0]: (none)` plus a suggestion - [x] 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] 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.

View File

@@ -20,6 +20,7 @@ import {
parseEnumFlag, parseEnumFlag,
parseFlags, parseFlags,
parsePositionalNumber, parsePositionalNumber,
parsePositiveInt,
splitFlag, splitFlag,
} from "../flags.js"; } from "../flags.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
@@ -304,15 +305,7 @@ function parseLimit(value: string | true | undefined): number {
if (value === undefined) { if (value === undefined) {
return DEFAULT_LIMIT; return DEFAULT_LIMIT;
} }
const limit = Number(value); return parsePositiveInt(value, "--limit", ISSUE_LIST_HELP_SUGGESTION);
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;
} }
function issueListSuggestions( function issueListSuggestions(

View File

@@ -5,21 +5,65 @@ import { COMMENT_FLAGS, commentItem } from "../comment.js";
import { resolveRepoContext, type RepoContext } from "../context.js"; import { resolveRepoContext, type RepoContext } from "../context.js";
import type { CliDeps } from "../deps.js"; import type { CliDeps } from "../deps.js";
import { axiError, classifyHttpError, httpStatus } from "../errors.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 { currentBranch } from "../git.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.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"; import { suggestCommand } from "../suggestions.js";
export const PR_HELP = `usage: gitea-axi pr <command> [flags] export const PR_HELP = `usage: gitea-axi pr <command> [flags]
commands: commands:
list List pull requests in the current repository
create Create a pull request create Create a pull request
comment Post a comment on a pull request comment Post a comment on a pull request
Run \`gitea-axi pr <command> --help\` for the flags of a command. 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] 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 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", "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. */ /** The branch to merge from: the caller's `--head`, else the local checkout's. */
async function resolveHead(deps: CliDeps, head: string | undefined): Promise<string> { async function resolveHead(deps: CliDeps, head: string | undefined): Promise<string> {
if (head !== undefined) { if (head !== undefined) {
@@ -298,6 +629,9 @@ export function prCommand(deps: CliDeps) {
if (!subcommand || subcommand === "--help") { if (!subcommand || subcommand === "--help") {
return PR_HELP; return PR_HELP;
} }
if (subcommand === "list") {
return prList(deps, rest);
}
if (subcommand === "create") { if (subcommand === "create") {
return prCreate(deps, rest); 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 * Join a field holding an array of objects (labels, assignees) into a single
* string of each element's `key` property. * string of each element's `key` property.

View File

@@ -71,6 +71,27 @@ export function parseEnumFlag<T extends string>(
return value as T; 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. */ /** Split "--flag=value" into name and inline value; "--flag" has none. */
export function splitFlag(arg: string): SplitFlag { export function splitFlag(arg: string): SplitFlag {
const equals = arg.indexOf("="); 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);
}

507
test/pr-list.test.ts Normal file
View 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);
});
});