feat: add issue view with body cleaning and truncation (task 0003)
All checks were successful
CI / test (pull_request) Successful in 26s
CI / test (push) Successful in 23s

Add `issue view <n>` as the first detail command, introducing the
content-cleaning and truncation machinery (src/body.ts) that later issue
and PR slices reuse.

- Default output: number, title, state, author, created, body (truncated
  at 500), plus comment_count; --comments expands every comment (bodies
  truncated at 800); --full suppresses all truncation.
- cleanBody runs only when a body exceeds its limit: normalizes Gitea
  issue/PR URLs on the detected host to Issue#N/PR#N, strips image embeds
  and long URLs, and collapses quoted blocks.
- Type guard: a PR number fails with VALIDATION_ERROR and a `pr view <n>`
  hint, detected via the fetched object's pull_request field.
- renderDetail joins the detail entity, optional sub-blocks, and help.
This commit was merged in pull request #2.
This commit is contained in:
2026-07-11 19:32:31 -04:00
parent ae0f4c2675
commit 5e66b4d746
8 changed files with 680 additions and 16 deletions

View File

@@ -1,19 +1,40 @@
import type { Issue } from "gitea-js";
import type { Comment, Issue } from "gitea-js";
import {
BODY_TRUNCATE_LIMIT,
COMMENT_TRUNCATE_LIMIT,
truncateBody,
} from "../body.js";
import { createClient } from "../client.js";
import { resolveRepoContext, type RepoContext } from "../context.js";
import type { CliDeps } from "../deps.js";
import { axiError, classifyHttpError } from "../errors.js";
import { extractRow, lowercased, pluck, relativeTimeField, type FieldDef } from "../fields.js";
import { parseFlags } from "../flags.js";
import { formatCountLine, renderList } from "../render.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 <command> [flags]
commands:
list List issues in the current repository
view Show a single issue's details
Run \`gitea-axi issue list --help\` for the flags of a command.
Run \`gitea-axi issue <command> --help\` for the flags of a command.
`;
export const ISSUE_VIEW_HELP = `usage: gitea-axi issue view <number> [flags]
Show a single issue. Pull request numbers are rejected — use \`pr view\` instead.
flags:
--comments Render every comment in full (bodies truncated at 800 chars)
--full Suppress all truncation of the issue 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 ISSUE_LIST_HELP = `usage: gitea-axi issue list [flags]
@@ -146,6 +167,152 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
});
}
const ISSUE_VIEW_HELP_SUGGESTION = [
"Run `gitea-axi issue view --help` to see available flags",
];
function parseIssueNumber(positionals: string[], command: string): number {
if (positionals.length === 0) {
throw axiError(`${command} requires an issue number`, "VALIDATION_ERROR", [
`Run \`gitea-axi ${command} <number>\``,
]);
}
if (positionals.length > 1) {
throw axiError(
`Unexpected argument: ${positionals[1]}`,
"VALIDATION_ERROR",
ISSUE_VIEW_HELP_SUGGESTION,
);
}
const raw = positionals[0]!;
const number = Number(raw);
if (!Number.isInteger(number) || number < 1) {
throw axiError(
`Invalid issue number: ${raw} (expected a positive integer)`,
"VALIDATION_ERROR",
ISSUE_VIEW_HELP_SUGGESTION,
);
}
return number;
}
// The default detail fields reuse the same declarative extraction as the list
// path; only `body` (truncation) and `comment_count` need bespoke handling.
const ISSUE_VIEW_FIELDS: FieldDef<Issue>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
pluck("author", "user.login"),
relativeTimeField("created", "created_at"),
];
interface IssueDetailOptions {
host: string;
full: boolean;
withComments: boolean;
now: Date;
}
function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record<string, unknown> {
const row = extractRow(issue, ISSUE_VIEW_FIELDS, { now: options.now });
const body = issue.body ?? "";
row.body = options.full ? body : truncateBody(body, BODY_TRUNCATE_LIMIT, options.host);
if (!options.withComments) {
const count = issue.comments ?? 0;
row.comment_count = count > 0 ? `${count} — use --comments to see full comments` : 0;
}
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),
};
});
}
function issueViewSuggestions(
context: RepoContext,
number: number,
options: { withComments: boolean; commentCount: number; bodyAbbreviated: boolean },
): string[] {
const help: string[] = [];
if (!options.withComments && options.commentCount > 0) {
help.push(suggestCommand(context, `issue view ${number} --comments`, "to see full comments"));
}
if (options.bodyAbbreviated) {
help.push(suggestCommand(context, `issue view ${number} --full`, "to see the complete body"));
}
if (help.length === 0) {
help.push(suggestCommand(context, `issue view ${number} --help`, "to see all issue view flags"));
}
return help;
}
async function issueView(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return ISSUE_VIEW_HELP;
}
const { flags, positionals } = parseFlags(
args,
{ "--comments": { takesValue: false }, "--full": { takesValue: false } },
"issue view",
);
const number = parseIssueNumber(positionals, "issue view");
const full = flags["--full"] === true;
const withComments = flags["--comments"] === true;
const context = await resolveRepoContext(deps);
const api = createClient(context);
let issue: Issue;
try {
const response = await api.repos.issueGetIssue(context.owner, context.name, number);
issue = response.data;
} catch (error) {
throw classifyHttpError(error);
}
if (issue.pull_request) {
throw axiError(`issue #${number} is a pull request`, "VALIDATION_ERROR", [
suggestCommand(context, `pr view ${number}`, "to view this pull request"),
]);
}
const now = new Date();
const item = buildIssueDetail(issue, { host: context.host, full, withComments, now });
const blocks: DetailBlock[] = [];
if (withComments) {
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: buildCommentRows(comments, { host: context.host, full, now }) });
}
const commentCount = issue.comments ?? 0;
// Suggest --full whenever the rendered body differs from the raw one — cleaned
// or truncated alike — read straight off the rendered output so the two never
// drift from truncateBody's own limit decision.
const bodyAbbreviated = item.body !== (issue.body ?? "");
return renderDetail({
noun: "issue",
item,
blocks,
help: issueViewSuggestions(context, number, { withComments, commentCount, bodyAbbreviated }),
});
}
export function issueCommand(deps: CliDeps) {
return async (args: string[]): Promise<string> => {
const [subcommand, ...rest] = args;
@@ -155,6 +322,9 @@ export function issueCommand(deps: CliDeps) {
if (subcommand === "list") {
return issueList(deps, rest);
}
if (subcommand === "view") {
return issueView(deps, rest);
}
throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [
"Run `gitea-axi issue --help` to see available issue commands",
]);