feat: add pr list (task 0008)
All checks were successful
CI / test (pull_request) Successful in 30s
CI / test (push) Successful in 31s

Implements `pr list` with the two policies later PR slices reuse:
client-side filtering with the filtered-set count line (ADR 0005) and
the official-first reviewDecision via parallel per-PR review fetches
(ADR 0006), extracted into src/review.ts.

API-supported flags map to their params (--state, --author→poster,
--label name→ID, --label-id, --sort, --limit, --fields); --assignee,
--base, --head, and --draft filter in-process after full pagination,
with the count line's total taken from the filtered set. --search is
refused with a redirect to `search prs`.

Adds a boolText field extractor and a shared parsePositiveInt helper,
the latter also adopted by issue list's --limit parsing.
This commit was merged in pull request #8.
This commit is contained in:
2026-07-12 19:23:29 -04:00
parent 7a41807a8a
commit 6333058b72
7 changed files with 964 additions and 20 deletions

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);
}