diff --git a/.claude/tasks/0009-pr-view-and-checks.md b/.claude/tasks/0009-pr-view-and-checks.md index 1e081c3..42e0e87 100644 --- a/.claude/tasks/0009-pr-view-and-checks.md +++ b/.claude/tasks/0009-pr-view-and-checks.md @@ -13,11 +13,21 @@ The `checks` field renders as the summary string (`N passed, N failed[, N skippe ## Acceptance criteria -- [ ] `pr view ` 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 -- [ ] 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 -- [ ] `--comments` and `--full` behave as on `issue view` (800-char comment truncation with cleanBody; `--full` suppresses everything) -- [ ] `pr checks ` outputs the summary line followed by `{ name, conclusion }` rows -- [ ] 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] `pr view ` renders the default fields including `checks`, `comment_count`, and `review_count` from the three-call fetch pattern +- [x] Commit-status states map to the four conclusions per the spec, `warning` counting as failure +- [x] A PR with no statuses renders the `"0 passed, 0 failed — this PR has no CI checks configured"` message in both commands +- [x] `--reviews` lists reviews with `official` and `stale` fields plus their inline comments +- [x] `--comments` and `--full` behave as on `issue view` (800-char comment truncation with cleanBody; `--full` suppresses everything) +- [x] `pr checks ` outputs the summary line followed by `{ name, conclusion }` rows +- [x] A nonexistent PR yields `PR_NOT_FOUND` (exit 1) +- [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: ` 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. diff --git a/src/checks.ts b/src/checks.ts new file mode 100644 index 0000000..427e4ea --- /dev/null +++ b/src/checks.ts @@ -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 { + 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 ?? []); +} diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 259f968..3553ae8 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,8 +1,8 @@ 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 { 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 type { CliDeps } from "../deps.js"; import { axiError, classifyHttpError, httpStatus } from "../errors.js"; @@ -26,7 +26,6 @@ import { import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { fetchAllPages, readTotalCount } from "../paginate.js"; import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js"; -import { relativeTime } from "../time.js"; import { suggestCommand } from "../suggestions.js"; export const ISSUE_HELP = `usage: gitea-axi issue [flags] @@ -441,20 +440,6 @@ function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record[] { - 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. */ async function getIssue(api: GiteaClient, context: RepoContext, number: number): Promise { try { @@ -518,7 +503,7 @@ async function issueView(deps: CliDeps, args: string[]): Promise { } catch (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; diff --git a/src/commands/pr.ts b/src/commands/pr.ts index d50f2ee..8a8a359 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -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 { 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 type { CliDeps } from "../deps.js"; import { axiError, classifyHttpError, httpStatus } from "../errors.js"; @@ -23,23 +31,55 @@ import { parsePositiveInt, splitFlag, } from "../flags.js"; +import { fetchChecks } from "../checks.js"; import { currentBranch } from "../git.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { fetchAllPages, readTotalCount } from "../paginate.js"; -import { formatCountLine, renderDetail, renderList } from "../render.js"; -import { fetchReviewDecision } from "../review.js"; +import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js"; +import { fetchReviewComments, fetchReviewDecision, fetchReviews } from "../review.js"; import { suggestCommand } from "../suggestions.js"; +import { relativeTime } from "../time.js"; export const PR_HELP = `usage: gitea-axi pr [flags] commands: 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 comment Post a comment on a pull request Run \`gitea-axi pr --help\` for the flags of a command. `; +export const PR_VIEW_HELP = `usage: gitea-axi pr view [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 Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + +export const PR_CHECKS_HELP = `usage: gitea-axi pr checks + +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 Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + export const PR_LIST_HELP = `usage: gitea-axi pr list [flags] List pull requests in the current repository. @@ -130,6 +170,17 @@ const PR_LIST_EXTRA_FIELDS: Record> = { 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[] = [ + pluck("number"), + pluck("title"), + lowercased("state"), + pluck("author", "user.login"), + boolText("draft"), +]; + const PR_STATES = ["open", "closed", "all"] as const; type PrState = (typeof PR_STATES)[number]; @@ -479,6 +530,237 @@ function pullNumber(pull: PullRequest): 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 { + 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 { + 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[]> { + const commentLists = await Promise.all( + reviews.map((review) => + review.id !== undefined + ? fetchReviewComments(api, context, number, review.id) + : Promise.resolve([]), + ), + ); + 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 { + 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 { + 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 { if (args.includes("--help")) { return PR_CREATE_HELP; @@ -632,6 +914,12 @@ export function prCommand(deps: CliDeps) { if (subcommand === "list") { return prList(deps, rest); } + if (subcommand === "view") { + return prView(deps, rest); + } + if (subcommand === "checks") { + return prChecks(deps, rest); + } if (subcommand === "create") { return prCreate(deps, rest); } diff --git a/src/comment.ts b/src/comment.ts index 1522fbe..23054da 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -38,3 +38,30 @@ export function commentItem( 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[] { + 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), + }; + }); +} diff --git a/src/render.ts b/src/render.ts index 5800669..08d2f24 100644 --- a/src/render.ts +++ b/src/render.ts @@ -45,6 +45,17 @@ export function renderList(options: RenderListOptions): string { 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). */ export interface DetailBlock { noun: string; diff --git a/src/review.ts b/src/review.ts index fc6d73d..cae25cd 100644 --- a/src/review.ts +++ b/src/review.ts @@ -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 { RepoContext } from "./context.js"; import { classifyHttpError } from "./errors.js"; @@ -47,6 +47,24 @@ export function reviewDecision(reviews: PullReview[]): ReviewDecision { 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 { + 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 * PR — `pr list` issues these in parallel across the rows it renders (ADR 0006). @@ -56,12 +74,28 @@ export async function fetchReviewDecision( context: RepoContext, number: number, ): Promise { - 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 { try { - const response = await api.repos.repoListPullReviews(context.owner, context.name, number); - reviews = response.data ?? []; + const response = await api.repos.repoGetPullReviewComments( + context.owner, + context.name, + number, + reviewId, + ); + return response.data ?? []; } catch (error) { throw classifyHttpError(error); } - return reviewDecision(reviews); } diff --git a/test/checks.test.ts b/test/checks.test.ts new file mode 100644 index 0000000..1a8f093 --- /dev/null +++ b/test/checks.test.ts @@ -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"); + }); +}); diff --git a/test/pr-checks.test.ts b/test/pr-checks.test.ts new file mode 100644 index 0000000..971b072 --- /dev/null +++ b/test/pr-checks.test.ts @@ -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"); + }); +}); diff --git a/test/pr-view.test.ts b/test/pr-view.test.ts new file mode 100644 index 0000000..d3a9879 --- /dev/null +++ b/test/pr-view.test.ts @@ -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"); + }); +});