Files
gitea-axi/src/commands/issue.ts
alexion 3446ce33a5
All checks were successful
CI / test (pull_request) Successful in 24s
feat: add issue view with body cleaning and truncation (task 0003)
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.
2026-07-11 19:33:00 -04:00

333 lines
10 KiB
TypeScript

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, 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 <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]
List issues in the current repository. Pull requests are never included.
flags:
--state <open|closed|all> Filter by state (default: open)
--limit <n> Maximum number of issues to return (default: 30)
--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
`;
const ISSUE_LIST_FIELDS: FieldDef<Issue>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
pluck("author", "user.login"),
relativeTimeField("created", "created_at"),
];
const ISSUE_STATES = ["open", "closed", "all"] as const;
type IssueState = (typeof ISSUE_STATES)[number];
const DEFAULT_LIMIT = 30;
const ISSUE_LIST_HELP_SUGGESTION = [
"Run `gitea-axi issue list --help` to see available flags",
];
function parseState(value: string | true | undefined): IssueState {
if (value === undefined) {
return "open";
}
if (value === true || !ISSUE_STATES.includes(value as IssueState)) {
throw axiError(
`Invalid --state value: ${String(value)} (expected open, closed, or all)`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
return value as IssueState;
}
function parseLimit(value: string | true | undefined): number {
if (value === undefined) {
return DEFAULT_LIMIT;
}
const limit = Number(value);
if (value === true || !Number.isInteger(limit) || limit < 1) {
throw axiError(
`Invalid --limit value: ${String(value)} (expected a positive integer)`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
return limit;
}
function issueListSuggestions(
context: RepoContext,
state: IssueState,
shown: number,
total: number | undefined,
): string[] {
const help: string[] = [];
if (state !== "all") {
help.push(suggestCommand(context, "issue list --state all", "to list issues in any state"));
}
if (total !== undefined && shown < total) {
help.push(
suggestCommand(context, "issue list --limit <n>", `to fetch more of the ${total} issues`),
);
}
if (help.length === 0) {
help.push(
suggestCommand(context, "issue list --help", "to see all issue list flags"),
);
}
return help;
}
async function issueList(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return ISSUE_LIST_HELP;
}
const { flags, positionals } = parseFlags(
args,
{ "--state": { takesValue: true }, "--limit": { takesValue: true } },
"issue list",
);
if (positionals.length > 0) {
throw axiError(
`Unexpected argument: ${positionals[0]}`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
const state = parseState(flags["--state"]);
const limit = parseLimit(flags["--limit"]);
const context = await resolveRepoContext(deps);
const api = createClient(context);
let response;
try {
response = await api.repos.issueListIssues(context.owner, context.name, {
state,
type: "issues",
limit,
page: 1,
});
} catch (error) {
throw classifyHttpError(error);
}
const issues = response.data ?? [];
const totalHeader = response.headers.get("x-total-count");
const total = totalHeader !== null ? Number(totalHeader) : undefined;
const resolvedTotal = total !== undefined && Number.isFinite(total) ? total : undefined;
const now = new Date();
const rows = issues.map((issue) => extractRow(issue, ISSUE_LIST_FIELDS, { now }));
return renderList({
noun: "issues",
rows,
countLine: formatCountLine(rows.length, resolvedTotal, rows.length >= limit),
help: issueListSuggestions(context, state, rows.length, resolvedTotal),
});
}
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;
if (!subcommand || subcommand === "--help") {
return ISSUE_HELP;
}
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",
]);
};
}