From 6333058b724d7a046105b53c9e098a594ba0434f Mon Sep 17 00:00:00 2001 From: alexion Date: Sun, 12 Jul 2026 19:23:29 -0400 Subject: [PATCH] feat: add pr list (task 0008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/tasks/0008-pr-list.md | 26 +- src/commands/issue.ts | 11 +- src/commands/pr.ts | 338 ++++++++++++++++++++++- src/fields.ts | 14 + src/flags.ts | 21 ++ src/review.ts | 67 +++++ test/pr-list.test.ts | 507 ++++++++++++++++++++++++++++++++++ 7 files changed, 964 insertions(+), 20 deletions(-) create mode 100644 src/review.ts create mode 100644 test/pr-list.test.ts diff --git a/.claude/tasks/0008-pr-list.md b/.claude/tasks/0008-pr-list.md index 9b26a16..9b2381a 100644 --- a/.claude/tasks/0008-pr-list.md +++ b/.claude/tasks/0008-pr-list.md @@ -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 ""` -- [ ] 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 ""` +- [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. diff --git a/src/commands/issue.ts b/src/commands/issue.ts index a2ee39d..259f968 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -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( diff --git a/src/commands/pr.ts b/src/commands/pr.ts index 9b8a4ce..d50f2ee 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -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 [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 --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 Filter by state (default: open) + --label Filter by label name (comma-separated, case-insensitive) + --label-id Filter by label ID, bypassing the name lookup + --assignee Filter by assignee (client-side) + --author Filter by author + --base Filter by base branch (client-side) + --head Filter by head branch (client-side) + --draft Show only draft pull requests (client-side) + --sort + Sort order (passed to the API) + --limit Maximum number of pull requests to return (default: 30) + --fields Append extra fields: body, createdAt, labels, milestone, mergedAt, url + --help Show this help + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + export const PR_CREATE_HELP = `usage: gitea-axi pr create --title [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[] = [ + 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> = { + 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): 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, +): Promise { + 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 ""` 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 ", "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 ", "to see a pull request in full")]; + if (total !== undefined && shown < total) { + help.push( + suggestCommand(context, "pr list --limit ", `to fetch more of the ${total} pull requests`), + ); + } + return help; +} + +async function prList(deps: CliDeps, args: string[]): Promise { + 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((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 { 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); } diff --git a/src/fields.ts b/src/fields.ts index 2c1cc44..f004c58 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -32,6 +32,20 @@ export function lowercased(name: string, path: string = name): FieldDef { }; } +/** + * 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( + name: string, + path: string = name, + yes = "yes", + no = "no", +): FieldDef { + 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. diff --git a/src/flags.ts b/src/flags.ts index a12abd8..471e168 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -71,6 +71,27 @@ export function parseEnumFlag( 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("="); diff --git a/src/review.ts b/src/review.ts new file mode 100644 index 0000000..fc6d73d --- /dev/null +++ b/src/review.ts @@ -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 { + 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); +} diff --git a/test/pr-list.test.ts b/test/pr-list.test.ts new file mode 100644 index 0000000..656e9aa --- /dev/null +++ b/test/pr-list.test.ts @@ -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 { + 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 { + 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[]): 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[]; 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); + }); +}); -- 2.47.3