feat: add pr view and checks (task 0009)
All checks were successful
CI / test (pull_request) Successful in 36s
CI / test (push) Successful in 37s

Add `pr view <n>` and `pr checks <n>`, built on the truncation machinery
and review fetches from earlier slices.

`pr view` uses the three-call fetch pattern (PR + reviews in parallel, then
the head commit's combined status) so `checks`, `comment_count`, and
`review_count` are in the default output; `--comments`, `--reviews`, and
`--full` behave as on `issue view`, with `--reviews` exposing Gitea's
`official`/`stale` fields plus per-review inline comments.

`pr checks <n>` renders the checks summary line and the `{ name, conclusion }`
rows, or the scalar no-CI message when no statuses exist.

The state→conclusion mapping and summary live in a new `src/checks.ts`;
`commentRows` is extracted into `src/comment.ts` and shared with `issue view`.
This commit was merged in pull request #9.
This commit is contained in:
2026-07-12 20:22:17 -04:00
parent 6333058b72
commit 16d2f29f19
10 changed files with 991 additions and 35 deletions

View File

@@ -13,11 +13,21 @@ The `checks` field renders as the summary string (`N passed, N failed[, N skippe
## Acceptance criteria ## Acceptance criteria
- [ ] `pr view <n>` renders the default fields including `checks`, `comment_count`, and `review_count` from the three-call fetch pattern - [x] `pr view <n>` renders the default fields including `checks`, `comment_count`, and `review_count` from the three-call fetch pattern
- [ ] Commit-status states map to the four conclusions per the spec, `warning` counting as failure - [x] Commit-status states map to the four conclusions per the spec, `warning` counting as failure
- [ ] A PR with no statuses renders the `"0 passed, 0 failed — this PR has no CI checks configured"` message in both commands - [x] A PR with no statuses renders the `"0 passed, 0 failed — this PR has no CI checks configured"` message in both commands
- [ ] `--reviews` lists reviews with `official` and `stale` fields plus their inline comments - [x] `--reviews` lists reviews with `official` and `stale` fields plus their inline comments
- [ ] `--comments` and `--full` behave as on `issue view` (800-char comment truncation with cleanBody; `--full` suppresses everything) - [x] `--comments` and `--full` behave as on `issue view` (800-char comment truncation with cleanBody; `--full` suppresses everything)
- [ ] `pr checks <n>` outputs the summary line followed by `{ name, conclusion }` rows - [x] `pr checks <n>` outputs the summary line followed by `{ name, conclusion }` rows
- [ ] A nonexistent PR yields `PR_NOT_FOUND` (exit 1) - [x] A nonexistent PR yields `PR_NOT_FOUND` (exit 1)
- [ ] Fixture-server tests cover the status mapping including `skipped` and `warning`, the no-CI case, `--reviews`, and truncation behavior - [x] Fixture-server tests cover the status mapping including `skipped` and `warning`, the no-CI case, `--reviews`, and truncation behavior
## Implementation Notes
- The checks machinery lives in a new `src/checks.ts`: `summarizeChecks` (pure state→conclusion mapping + summary line) and `fetchChecks` (I/O shell), so `pr view` renders only the summary line while `pr checks` renders the summary plus the `{ name, conclusion }` rows from the same core. Unknown/future commit-status states fall through to `pending` rather than being reported as a pass or fail they are not.
- `pr checks` output shape follows gh-axi: when checks exist, the summary is the lead line above a `checks` list block (rendered via `renderList`'s lead-line slot); when none exist, a scalar `checks: <message>` line. A new generic `renderScalar(noun, value, help)` in `src/render.ts` emits that literal line + help, keeping the value unquoted (as TOON reads a string scalar to end of line), matching the summary line's treatment.
- `pr view` uses the three-call pattern from ADR 0006: the PR and its reviews are fetched in parallel, then the combined status once the head SHA is known. `review.ts` grew `fetchReviews` (raw reviews, now the shared base of `fetchReviewDecision`) and `fetchReviewComments` (per-review inline comments for `--reviews`).
- `commentRows` was extracted from `issue.ts` into `src/comment.ts` and is now shared by `issue view --comments` and `pr view --comments`, removing the duplicate row builder.
- `merged` renders as `no` when open, or the merge time (relative) once merged, matching gh-axi's "no / mergedAt value" behavior.
- Review inline-comment rows (`{ author, path, body }`) are built inline in `buildReviewRows` rather than through the shared `commentRows` (`{ author, created, body }`): they are a different entity (`PullReviewComment`, with `path` and no displayed timestamp), so forcing reuse would have meant parameterizing the middle field — a shared helper here would obscure more than it saves. Flagged by the Standards review as a judgement call; kept separate deliberately.
- The no-CI summary is labeled `summary:` when checks exist but `checks:` when none do. This matches the spec's literal empty-state form (`checks: "0 passed, 0 failed — …"`, spec line 292) and gh-axi's shape, so the label difference is intentional rather than an inconsistency.

102
src/checks.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { CombinedStatus, CommitStatus } from "gitea-js";
import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js";
import { classifyHttpError } from "./errors.js";
/**
* A PR's CI checks, derived from its commit statuses. Shared by `pr view` (which
* renders only the {@link ChecksResult.summary} line) and `pr checks` (which
* renders the summary plus the per-check rows).
*/
/** gh-axi's four-value check classification (see the gitea-axi spec, "pr checks"). */
export type CheckConclusion = "pass" | "fail" | "skip" | "pending";
/** The summary shown when a PR has no commit statuses at all. */
export const NO_CHECKS_MESSAGE =
"0 passed, 0 failed — this PR has no CI checks configured";
export interface CheckRow {
name: string;
conclusion: CheckConclusion;
}
export interface ChecksResult {
/** The `N passed, N failed[, N skipped][, N pending], N total` line, or {@link NO_CHECKS_MESSAGE}. */
summary: string;
checks: CheckRow[];
}
/**
* Map a Gitea commit-status state to a check conclusion. `warning` counts as a
* failure, matching Gitea's own status-combine logic; `skipped` (only emitted by
* newer instances) is its own bucket. Anything else — `pending` and any state a
* future Gitea might add — is treated as still-in-progress rather than reported
* as a pass or a failure it is not.
*/
function classifyState(state: string | undefined): CheckConclusion {
switch (state) {
case "success":
return "pass";
case "failure":
case "error":
case "warning":
return "fail";
case "skipped":
return "skip";
default:
return "pending";
}
}
/**
* Reduce a PR's commit statuses to its checks summary and per-check rows. The
* `skipped` and `pending` counts are folded into the summary line only when
* non-zero, so an all-passing PR reads `N passed, 0 failed, N total`.
*/
export function summarizeChecks(statuses: CommitStatus[]): ChecksResult {
const checks: CheckRow[] = statuses.map((status) => ({
name: status.context ?? "",
conclusion: classifyState(status.status),
}));
return { summary: summaryLine(checks), checks };
}
function summaryLine(checks: CheckRow[]): string {
if (checks.length === 0) {
return NO_CHECKS_MESSAGE;
}
const count = (conclusion: CheckConclusion): number =>
checks.filter((check) => check.conclusion === conclusion).length;
const parts = [`${count("pass")} passed`, `${count("fail")} failed`];
const skipped = count("skip");
if (skipped > 0) {
parts.push(`${skipped} skipped`);
}
const pending = count("pending");
if (pending > 0) {
parts.push(`${pending} pending`);
}
parts.push(`${checks.length} total`);
return parts.join(", ");
}
/**
* Fetch a PR head SHA's combined commit status and reduce it to its checks
* summary and rows. One HTTP call — `pr view` issues it once the head SHA is
* known, after the PR and reviews fetches (ADR 0006's three-call pattern).
*/
export async function fetchChecks(
api: GiteaClient,
context: RepoContext,
sha: string,
): Promise<ChecksResult> {
let combined: CombinedStatus;
try {
const response = await api.repos.repoGetCombinedStatusByRef(context.owner, context.name, sha);
combined = response.data;
} catch (error) {
throw classifyHttpError(error);
}
return summarizeChecks(combined.statuses ?? []);
}

View File

@@ -1,8 +1,8 @@
import type { Comment, CreateIssueOption, EditIssueOption, Issue } from "gitea-js"; import type { Comment, CreateIssueOption, EditIssueOption, Issue } from "gitea-js";
import { BODY_TRUNCATE_LIMIT, COMMENT_TRUNCATE_LIMIT, truncateBody } from "../body.js"; import { BODY_TRUNCATE_LIMIT, truncateBody } from "../body.js";
import { requireBodySource, resolveBodySource } from "../body-source.js"; import { requireBodySource, resolveBodySource } from "../body-source.js";
import { createClient, type GiteaClient } from "../client.js"; import { createClient, type GiteaClient } from "../client.js";
import { COMMENT_FLAGS, commentItem } from "../comment.js"; import { COMMENT_FLAGS, commentItem, commentRows } 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";
@@ -26,7 +26,6 @@ import {
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { fetchAllPages, readTotalCount } from "../paginate.js"; import { fetchAllPages, readTotalCount } from "../paginate.js";
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js"; import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
import { relativeTime } from "../time.js";
import { suggestCommand } from "../suggestions.js"; import { suggestCommand } from "../suggestions.js";
export const ISSUE_HELP = `usage: gitea-axi issue <command> [flags] export const ISSUE_HELP = `usage: gitea-axi issue <command> [flags]
@@ -441,20 +440,6 @@ function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record<str
return row; return row;
} }
function buildCommentRows(
comments: Comment[],
options: { host: string; full: boolean; now: Date },
): Record<string, unknown>[] {
return comments.map((comment) => {
const body = comment.body ?? "";
return {
author: comment.user?.login ?? "",
created: relativeTime(comment.created_at, options.now),
body: options.full ? body : truncateBody(body, COMMENT_TRUNCATE_LIMIT, options.host),
};
});
}
/** Fetch a single issue, mapping any HTTP failure to an AxiError. */ /** Fetch a single issue, mapping any HTTP failure to an AxiError. */
async function getIssue(api: GiteaClient, context: RepoContext, number: number): Promise<Issue> { async function getIssue(api: GiteaClient, context: RepoContext, number: number): Promise<Issue> {
try { try {
@@ -518,7 +503,7 @@ async function issueView(deps: CliDeps, args: string[]): Promise<string> {
} catch (error) { } catch (error) {
throw classifyHttpError(error); throw classifyHttpError(error);
} }
blocks.push({ noun: "comments", rows: buildCommentRows(comments, { host: context.host, full, now }) }); blocks.push({ noun: "comments", rows: commentRows(comments, { host: context.host, full, now }) });
} }
const commentCount = issue.comments ?? 0; const commentCount = issue.comments ?? 0;

View File

@@ -1,7 +1,15 @@
import type { Comment, CreatePullRequestOption, PullRequest, Repository } from "gitea-js"; import type {
Comment,
CreatePullRequestOption,
PullRequest,
PullReview,
PullReviewComment,
Repository,
} from "gitea-js";
import { BODY_TRUNCATE_LIMIT, COMMENT_TRUNCATE_LIMIT, truncateBody } from "../body.js";
import { requireBodySource, resolveBodySource } from "../body-source.js"; import { requireBodySource, resolveBodySource } from "../body-source.js";
import { createClient, type GiteaClient } from "../client.js"; import { createClient, type GiteaClient } from "../client.js";
import { COMMENT_FLAGS, commentItem } from "../comment.js"; import { COMMENT_FLAGS, commentItem, commentRows } 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";
@@ -23,23 +31,55 @@ import {
parsePositiveInt, parsePositiveInt,
splitFlag, splitFlag,
} from "../flags.js"; } from "../flags.js";
import { fetchChecks } from "../checks.js";
import { currentBranch } from "../git.js"; import { currentBranch } from "../git.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { fetchAllPages, readTotalCount } from "../paginate.js"; import { fetchAllPages, readTotalCount } from "../paginate.js";
import { formatCountLine, renderDetail, renderList } from "../render.js"; import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js";
import { fetchReviewDecision } from "../review.js"; import { fetchReviewComments, fetchReviewDecision, fetchReviews } from "../review.js";
import { suggestCommand } from "../suggestions.js"; import { suggestCommand } from "../suggestions.js";
import { relativeTime } from "../time.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 list List pull requests in the current repository
view Show a single pull request's details
checks Show a pull request's CI check results
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_VIEW_HELP = `usage: gitea-axi pr view <number> [flags]
Show a single pull request, including its CI checks and review summary.
flags:
--comments Render every comment in full (bodies truncated at 800 chars)
--reviews Render every review with its inline comments (Gitea official/stale fields)
--full Suppress all truncation of the PR body and comment bodies
--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_CHECKS_HELP = `usage: gitea-axi pr checks <number>
Show the CI check results for a pull request, derived from its head commit's
combined status.
flags:
--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_LIST_HELP = `usage: gitea-axi pr list [flags] export const PR_LIST_HELP = `usage: gitea-axi pr list [flags]
List pull requests in the current repository. List pull requests in the current repository.
@@ -130,6 +170,17 @@ const PR_LIST_EXTRA_FIELDS: Record<string, FieldDef<PullRequest>> = {
url: pluck("url", "html_url"), url: pluck("url", "html_url"),
}; };
// The default `pr view` fields that reuse the shared declarative extraction;
// `merged`, `checks`, `body`, `comment_count`, and `review_count` are handled
// bespokely in buildPrDetail, since each needs a computed or fetched value.
const PR_VIEW_FIELDS: FieldDef<PullRequest>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
pluck("author", "user.login"),
boolText("draft"),
];
const PR_STATES = ["open", "closed", "all"] as const; const PR_STATES = ["open", "closed", "all"] as const;
type PrState = (typeof PR_STATES)[number]; type PrState = (typeof PR_STATES)[number];
@@ -479,6 +530,237 @@ function pullNumber(pull: PullRequest): number {
return pull.number; return pull.number;
} }
/** Fetch a single pull request, mapping any HTTP failure to an AxiError. */
async function getPull(api: GiteaClient, context: RepoContext, number: number): Promise<PullRequest> {
try {
const response = await api.repos.repoGetPullRequest(context.owner, context.name, number);
return response.data;
} catch (error) {
throw classifyHttpError(error);
}
}
/**
* The PR head commit SHA, the ref the combined-status fetch keys on. Every real
* pull request has one; a response without it is treated as the broken answer it
* is, rather than inventing a SHA to fetch a status for.
*/
function headSha(pull: PullRequest): string {
const sha = pull.head?.sha;
if (!sha) {
throw axiError("Gitea returned a pull request with no head SHA", "UNKNOWN");
}
return sha;
}
interface PrDetailOptions {
host: string;
full: boolean;
withComments: boolean;
withReviews: boolean;
checksSummary: string;
reviewCount: number;
now: Date;
}
function buildPrDetail(pull: PullRequest, options: PrDetailOptions): Record<string, unknown> {
const row = extractRow(pull, PR_VIEW_FIELDS, { now: options.now });
// gh-axi renders `merged` as `no` when open, or the merge time once merged.
row.merged = pull.merged ? relativeTime(pull.merged_at, options.now) : "no";
row.checks = options.checksSummary;
const body = pull.body ?? "";
row.body = options.full ? body : truncateBody(body, BODY_TRUNCATE_LIMIT, options.host);
// Each count scalar is replaced by its full block when the matching flag is
// passed, mirroring `issue view`'s comment_count (ADR: no redundant scalar).
if (!options.withComments) {
const count = pull.comments ?? 0;
row.comment_count = count > 0 ? `${count} — use --comments to see full comments` : 0;
}
if (!options.withReviews) {
row.review_count =
options.reviewCount > 0
? `${options.reviewCount} — use --reviews to see full reviews`
: 0;
}
return row;
}
function prViewSuggestions(
context: RepoContext,
number: number,
options: {
withComments: boolean;
commentCount: number;
withReviews: boolean;
reviewCount: number;
bodyAbbreviated: boolean;
},
): string[] {
const help: string[] = [];
if (!options.withComments && options.commentCount > 0) {
help.push(suggestCommand(context, `pr view ${number} --comments`, "to see full comments"));
}
if (!options.withReviews && options.reviewCount > 0) {
help.push(suggestCommand(context, `pr view ${number} --reviews`, "to see full reviews"));
}
if (options.bodyAbbreviated) {
help.push(suggestCommand(context, `pr view ${number} --full`, "to see the complete body"));
}
if (help.length === 0) {
help.push(suggestCommand(context, `pr view ${number} --help`, "to see all pr view flags"));
}
return help;
}
interface ReviewRowsOptions {
host: string;
full: boolean;
now: Date;
}
/**
* The `reviews` block rows for `--reviews`: each review with its Gitea-specific
* `official`/`stale` flags and its inline (diff) comments. One comments fetch per
* review, all in flight at once; review and comment bodies truncate at 800 chars
* unless `--full` is set.
*/
async function buildReviewRows(
api: GiteaClient,
context: RepoContext,
number: number,
reviews: PullReview[],
options: ReviewRowsOptions,
): Promise<Record<string, unknown>[]> {
const commentLists = await Promise.all(
reviews.map((review) =>
review.id !== undefined
? fetchReviewComments(api, context, number, review.id)
: Promise.resolve<PullReviewComment[]>([]),
),
);
const truncate = (text: string): string =>
options.full ? text : truncateBody(text, COMMENT_TRUNCATE_LIMIT, options.host);
return reviews.map((review, index) => ({
author: review.user?.login ?? "",
state: (review.state ?? "").toLowerCase(),
official: review.official ? "yes" : "no",
stale: review.stale ? "yes" : "no",
body: truncate(review.body ?? ""),
comments: commentLists[index]!.map((comment) => ({
author: comment.user?.login ?? "",
path: comment.path ?? "",
body: truncate(comment.body ?? ""),
})),
}));
}
async function prView(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_VIEW_HELP;
}
const { flags, positionals } = parseFlags(
args,
{
"--comments": { takesValue: false },
"--reviews": { takesValue: false },
"--full": { takesValue: false },
},
"pr view",
);
const number = parsePositionalNumber(positionals, "pr view", "pull request");
const full = flags["--full"] === true;
const withComments = flags["--comments"] === true;
const withReviews = flags["--reviews"] === true;
const context = await resolveRepoContext(deps);
const api = createClient(context);
// The PR and its reviews are fetched in parallel; the combined status then
// needs the head SHA, so it follows once the PR is in hand — three calls
// always, so `checks` and `review_count` are in the default output (ADR 0006).
const [pull, reviews] = await Promise.all([
getPull(api, context, number),
fetchReviews(api, context, number),
]);
const checksResult = await fetchChecks(api, context, headSha(pull));
const now = new Date();
const item = buildPrDetail(pull, {
host: context.host,
full,
withComments,
withReviews,
checksSummary: checksResult.summary,
reviewCount: reviews.length,
now,
});
const blocks: DetailBlock[] = [];
if (withComments) {
// PRs share the issue-comment endpoint, so their comments come from
// GET /issues/{n}/comments — the same fetch `issue view --comments` makes.
let comments: Comment[];
try {
const response = await api.repos.issueGetComments(context.owner, context.name, number);
comments = response.data ?? [];
} catch (error) {
throw classifyHttpError(error);
}
blocks.push({ noun: "comments", rows: commentRows(comments, { host: context.host, full, now }) });
}
if (withReviews) {
blocks.push({
noun: "reviews",
rows: await buildReviewRows(api, context, number, reviews, { host: context.host, full, now }),
});
}
const commentCount = pull.comments ?? 0;
const bodyAbbreviated = item.body !== (pull.body ?? "");
return renderDetail({
noun: "pull_request",
item,
blocks,
help: prViewSuggestions(context, number, {
withComments,
commentCount,
withReviews,
reviewCount: reviews.length,
bodyAbbreviated,
}),
});
}
async function prChecks(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return PR_CHECKS_HELP;
}
const { positionals } = parseFlags(args, {}, "pr checks");
const number = parsePositionalNumber(positionals, "pr checks", "pull request");
const context = await resolveRepoContext(deps);
const api = createClient(context);
// The combined status is keyed on the head SHA, so the PR is fetched first to
// learn it (GET /pulls/{n}), then its head commit's combined status.
const pull = await getPull(api, context, number);
const result = await fetchChecks(api, context, headSha(pull));
const help = [suggestCommand(context, `pr view ${number}`, "to see the pull request in full")];
// No statuses at all is a scalar `checks:` message, not an empty list block —
// there is nothing to tabulate, so the summary line stands on its own.
if (result.checks.length === 0) {
return renderScalar("checks", result.summary, help);
}
// Otherwise the summary occupies renderList's lead line, above the per-check rows.
return renderList({
noun: "checks",
rows: result.checks.map((check) => ({ name: check.name, conclusion: check.conclusion })),
countLine: `summary: ${result.summary}`,
help,
});
}
async function prCreate(deps: CliDeps, args: string[]): Promise<string> { async function prCreate(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) { if (args.includes("--help")) {
return PR_CREATE_HELP; return PR_CREATE_HELP;
@@ -632,6 +914,12 @@ export function prCommand(deps: CliDeps) {
if (subcommand === "list") { if (subcommand === "list") {
return prList(deps, rest); return prList(deps, rest);
} }
if (subcommand === "view") {
return prView(deps, rest);
}
if (subcommand === "checks") {
return prChecks(deps, rest);
}
if (subcommand === "create") { if (subcommand === "create") {
return prCreate(deps, rest); return prCreate(deps, rest);
} }

View File

@@ -38,3 +38,30 @@ export function commentItem(
body: options.full ? body : truncateBody(body, COMMENT_TRUNCATE_LIMIT, options.host), body: options.full ? body : truncateBody(body, COMMENT_TRUNCATE_LIMIT, options.host),
}; };
} }
export interface CommentRowsOptions {
host: string;
/** Echo bodies untruncated, as `--full` asks. */
full: boolean;
now: Date;
}
/**
* The `{ author, created, body }` rows for a `--comments` block, shared by
* `issue view` and `pr view`. Each body is cleaned and truncated at 800 chars
* unless `full` is set. Ordered author→created→body, matching the block header
* both commands render.
*/
export function commentRows(
comments: Comment[],
options: CommentRowsOptions,
): Record<string, unknown>[] {
return comments.map((comment) => {
const body = comment.body ?? "";
return {
author: comment.user?.login ?? "",
created: relativeTime(comment.created_at, options.now),
body: options.full ? body : truncateBody(body, COMMENT_TRUNCATE_LIMIT, options.host),
};
});
}

View File

@@ -45,6 +45,17 @@ export function renderList(options: RenderListOptions): string {
return [options.countLine, body, encode({ help: options.help })].join("\n"); return [options.countLine, body, encode({ help: options.help })].join("\n");
} }
/**
* A literal `noun: value` line followed by the help block — for output whose
* entity is a single line rather than a field map or a list (e.g. the no-CI
* `checks:` message). The value is emitted verbatim, not re-encoded: TOON reads
* a string scalar to end of line, so a comma-bearing message stays unquoted,
* matching the way a summary line stands above a list block.
*/
export function renderScalar(noun: string, value: string, help: string[]): string {
return [`${noun}: ${value}`, encode({ help })].join("\n");
}
/** A secondary list block appended below a detail entity (e.g. comments). */ /** A secondary list block appended below a detail entity (e.g. comments). */
export interface DetailBlock { export interface DetailBlock {
noun: string; noun: string;

View File

@@ -1,4 +1,4 @@
import type { PullReview } from "gitea-js"; import type { PullReview, PullReviewComment } from "gitea-js";
import type { GiteaClient } from "./client.js"; import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js"; import type { RepoContext } from "./context.js";
import { classifyHttpError } from "./errors.js"; import { classifyHttpError } from "./errors.js";
@@ -47,6 +47,24 @@ export function reviewDecision(reviews: PullReview[]): ReviewDecision {
return "required"; return "required";
} }
/**
* Fetch a PR's reviews. One HTTP call per PR — `pr list` issues these in
* parallel across the rows it renders, and `pr view` fetches them alongside the
* PR itself (ADR 0006).
*/
export async function fetchReviews(
api: GiteaClient,
context: RepoContext,
number: number,
): Promise<PullReview[]> {
try {
const response = await api.repos.repoListPullReviews(context.owner, context.name, number);
return response.data ?? [];
} catch (error) {
throw classifyHttpError(error);
}
}
/** /**
* Fetch a PR's reviews and reduce them to its reviewDecision. One HTTP call per * 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). * PR — `pr list` issues these in parallel across the rows it renders (ADR 0006).
@@ -56,12 +74,28 @@ export async function fetchReviewDecision(
context: RepoContext, context: RepoContext,
number: number, number: number,
): Promise<ReviewDecision> { ): Promise<ReviewDecision> {
let reviews: PullReview[]; return reviewDecision(await fetchReviews(api, context, number));
}
/**
* Fetch a single review's inline (diff) comments. `pr view --reviews` issues one
* of these per review, in parallel, to attach each review's comments to it.
*/
export async function fetchReviewComments(
api: GiteaClient,
context: RepoContext,
number: number,
reviewId: number,
): Promise<PullReviewComment[]> {
try { try {
const response = await api.repos.repoListPullReviews(context.owner, context.name, number); const response = await api.repos.repoGetPullReviewComments(
reviews = response.data ?? []; context.owner,
context.name,
number,
reviewId,
);
return response.data ?? [];
} catch (error) { } catch (error) {
throw classifyHttpError(error); throw classifyHttpError(error);
} }
return reviewDecision(reviews);
} }

37
test/checks.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { CommitStatus } from "gitea-js";
import { summarizeChecks } from "../src/checks.js";
describe("summarizeChecks", () => {
it("maps success commit statuses to pass checks and omits zero skipped/pending segments", () => {
const statuses = [
{ context: "build", status: "success" },
{ context: "test", status: "success" },
] as CommitStatus[];
const result = summarizeChecks(statuses);
expect(result.checks).toEqual([
{ name: "build", conclusion: "pass" },
{ name: "test", conclusion: "pass" },
]);
expect(result.summary).toBe("2 passed, 0 failed, 2 total");
});
it("maps failure, error, and warning states all to fail (warning counts as failure)", () => {
const statuses = [
{ context: "a", status: "failure" },
{ context: "b", status: "error" },
{ context: "c", status: "warning" },
] as CommitStatus[];
const result = summarizeChecks(statuses);
expect(result.checks).toEqual([
{ name: "a", conclusion: "fail" },
{ name: "b", conclusion: "fail" },
{ name: "c", conclusion: "fail" },
]);
expect(result.summary).toBe("0 passed, 3 failed, 3 total");
});
});

126
test/pr-checks.test.ts Normal file
View File

@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { runCliTest, testModeEnv } from "./harness.js";
const PULL_PATH = "/api/v1/repos/testowner/testrepo/pulls/5";
const STATUS_PATH = "/api/v1/repos/testowner/testrepo/commits/abc123/status";
let server: FixtureServer;
afterEach(async () => {
await server.close();
});
describe("pr checks", () => {
it("renders a summary line and a checks list mapping each commit-status state", async () => {
server = await startFixtureServer([
{
method: "GET",
path: PULL_PATH,
body: { number: 5, head: { sha: "abc123", ref: "feature" } },
},
{
method: "GET",
path: STATUS_PATH,
body: {
sha: "abc123",
total_count: 2,
statuses: [
{ context: "build", status: "success" },
{ context: "test", status: "failure" },
],
},
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "checks", "5"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("summary: 1 passed, 1 failed, 2 total");
expect(stdout).toContain("checks[2]{name,conclusion}:");
expect(stdout).toContain(" build,pass");
expect(stdout).toContain(" test,fail");
expect(stdout).toMatch(/^help\[\d+\]:/m);
});
it("renders a scalar checks line, not a list block, when no statuses are configured", async () => {
server = await startFixtureServer([
{
method: "GET",
path: PULL_PATH,
body: { number: 5, head: { sha: "abc123", ref: "feature" } },
},
{
method: "GET",
path: STATUS_PATH,
body: { sha: "abc123", total_count: 0, statuses: [] },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "checks", "5"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain(
"checks: 0 passed, 0 failed — this PR has no CI checks configured",
);
expect(stdout).not.toContain("summary:");
expect(stdout).not.toMatch(/checks\[\d+\]\{/);
expect(stdout).toMatch(/^help\[\d+\]:/m);
});
it("maps every commit-status state and folds skipped/pending into the summary when non-zero", async () => {
server = await startFixtureServer([
{
method: "GET",
path: PULL_PATH,
body: { number: 5, head: { sha: "abc123", ref: "feature" } },
},
{
method: "GET",
path: STATUS_PATH,
body: {
sha: "abc123",
total_count: 5,
statuses: [
{ context: "lint", status: "success" },
{ context: "build", status: "failure" },
{ context: "deploy", status: "warning" },
{ context: "e2e", status: "skipped" },
{ context: "docs", status: "pending" },
],
},
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "checks", "5"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("summary: 1 passed, 2 failed, 1 skipped, 1 pending, 5 total");
expect(stdout).toContain("checks[5]{name,conclusion}:");
expect(stdout).toContain(" lint,pass");
expect(stdout).toContain(" build,fail");
expect(stdout).toContain(" deploy,fail");
expect(stdout).toContain(" e2e,skip");
expect(stdout).toContain(" docs,pending");
});
it("reports a nonexistent PR as PR_NOT_FOUND with exit 1", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/999",
status: 404,
body: { message: "Not Found" },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "checks", "999"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: PR_NOT_FOUND");
});
});

336
test/pr-view.test.ts Normal file
View File

@@ -0,0 +1,336 @@
import { afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { runCliTest, testModeEnv } from "./harness.js";
const PULL_PATH = "/api/v1/repos/testowner/testrepo/pulls/5";
const REVIEWS_PATH = "/api/v1/repos/testowner/testrepo/pulls/5/reviews";
const STATUS_PATH = "/api/v1/repos/testowner/testrepo/commits/abc123/status";
let server: FixtureServer;
afterEach(async () => {
await server.close();
});
describe("pr view", () => {
it("renders the default fields from the PR, its reviews, and the head commit status", async () => {
server = await startFixtureServer([
{
method: "GET",
path: PULL_PATH,
body: {
number: 5,
title: "Add feature",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 2,
body: "Some body text.",
head: { sha: "abc123", ref: "feature" },
},
},
{
method: "GET",
path: REVIEWS_PATH,
body: [
{
id: 1,
state: "APPROVED",
official: false,
stale: false,
dismissed: false,
user: { login: "reviewer" },
},
],
},
{
method: "GET",
path: STATUS_PATH,
body: {
sha: "abc123",
total_count: 1,
statuses: [{ context: "build", status: "success" }],
},
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "5"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("pull_request:");
expect(stdout).toContain("number: 5");
expect(stdout).toContain("title: Add feature");
expect(stdout).toContain("state: open");
expect(stdout).toContain("author: alexion");
expect(stdout).toContain("draft: no");
expect(stdout).toContain("merged: no");
expect(stdout).toContain('checks: "1 passed, 0 failed, 1 total"');
expect(stdout).toContain("body: Some body text.");
expect(stdout).toContain("comment_count: 2 — use --comments to see full comments");
expect(stdout).toContain("review_count: 1 — use --reviews to see full reviews");
expect(stdout).toMatch(/^help\[\d+\]:/m);
});
it("renders every comment with --comments, dropping comment_count and truncating at 800 chars", async () => {
const longComment = "f".repeat(1000);
server = await startFixtureServer([
{
method: "GET",
path: PULL_PATH,
body: {
number: 5,
title: "Add feature",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 2,
body: "Body.",
head: { sha: "abc123", ref: "feature" },
},
},
{ method: "GET", path: REVIEWS_PATH, body: [] },
{
method: "GET",
path: STATUS_PATH,
body: {
sha: "abc123",
total_count: 1,
statuses: [{ context: "build", status: "success" }],
},
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/issues/5/comments",
body: [
{ user: { login: "bob" }, created_at: "2026-07-02T00:00:00Z", body: "short reply" },
{ user: { login: "sue" }, created_at: "2026-07-03T00:00:00Z", body: longComment },
],
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "5", "--comments"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("comments[2]{author,created,body}:");
expect(stdout).toContain("bob");
expect(stdout).toContain("short reply");
expect(stdout).toContain(
"... (truncated, 1000 chars total - use --full to see complete body)",
);
expect(stdout).not.toContain("comment_count");
expect(stdout).toContain("review_count: 0");
});
it("renders every review with official/stale and inline comments with --reviews, dropping review_count", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/7",
body: {
number: 7,
title: "Feature",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 0,
body: "Body.",
head: { sha: "sha7", ref: "feature" },
},
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/7/reviews",
body: [
{
id: 11,
state: "APPROVED",
official: true,
stale: false,
dismissed: false,
user: { login: "reviewer" },
body: "looks good",
},
{
id: 12,
state: "REQUEST_CHANGES",
official: false,
stale: true,
dismissed: false,
user: { login: "carol" },
body: "please fix",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/7/reviews/11/comments",
body: [{ user: { login: "alice" }, path: "src/x.ts", body: "nit here" }],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/7/reviews/12/comments",
body: [],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/commits/sha7/status",
body: { sha: "sha7", total_count: 0, statuses: [] },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "7", "--reviews"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("reviews[2]:");
expect(stdout).toContain(" state: approved");
expect(stdout).toContain(" state: request_changes");
expect(stdout).toContain(" official: yes");
expect(stdout).toContain(" official: no");
expect(stdout).toContain(" stale: no");
expect(stdout).toContain(" stale: yes");
expect(stdout).toContain(" comments[1]{author,path,body}:");
expect(stdout).toContain(" alice,src/x.ts,nit here");
expect(stdout).not.toContain("review_count");
expect(stdout).toContain("comment_count");
});
it("reports a nonexistent PR as PR_NOT_FOUND with exit 1", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/999",
status: 404,
body: { message: "Not Found" },
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/999/reviews",
status: 404,
body: { message: "Not Found" },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "999"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: PR_NOT_FOUND");
});
it("suppresses PR body and comment truncation with --full --comments", async () => {
const body = "e".repeat(900);
const longComment = "g".repeat(1000);
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/8",
body: {
number: 8,
title: "T",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 1,
body,
head: { sha: "sha8", ref: "f" },
},
},
{ method: "GET", path: "/api/v1/repos/testowner/testrepo/pulls/8/reviews", body: [] },
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/commits/sha8/status",
body: { sha: "sha8", total_count: 0, statuses: [] },
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/issues/8/comments",
body: [{ user: { login: "sue" }, created_at: "2026-07-03T00:00:00Z", body: longComment }],
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "8", "--comments", "--full"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain(body);
expect(stdout).toContain(longComment);
expect(stdout).not.toContain("truncated");
});
it("renders the no-CI-checks message in the checks field when no statuses exist", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/9",
body: {
number: 9,
title: "T",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 0,
body: "Body.",
head: { sha: "sha9", ref: "f" },
},
},
{ method: "GET", path: "/api/v1/repos/testowner/testrepo/pulls/9/reviews", body: [] },
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/commits/sha9/status",
body: { sha: "sha9", total_count: 0, statuses: [] },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "9"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain(
'checks: "0 passed, 0 failed — this PR has no CI checks configured"',
);
});
it("truncates a PR body over 500 chars with the inline hint and a --full suggestion", async () => {
const body = "z".repeat(600);
server = await startFixtureServer([
{
method: "GET",
path: PULL_PATH,
body: {
number: 5,
title: "T",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 0,
body,
head: { sha: "abc123", ref: "f" },
},
},
{ method: "GET", path: REVIEWS_PATH, body: [] },
{
method: "GET",
path: STATUS_PATH,
body: { sha: "abc123", total_count: 0, statuses: [] },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "5"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain(
"... (truncated, 600 chars total - use --full to see complete body)",
);
expect(stdout).toContain("pr view 5 --full");
});
});