feat: add pr view and checks (task 0009) #9
@@ -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
102
src/checks.ts
Normal 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 ?? []);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
37
test/checks.test.ts
Normal 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
126
test/pr-checks.test.ts
Normal 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
336
test/pr-view.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user