feat: add pr list (task 0008) #8
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
21
src/flags.ts
21
src/flags.ts
@@ -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
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