diff --git a/.claude/tasks/0003-issue-view-and-truncation.md b/.claude/tasks/0003-issue-view-and-truncation.md index 6226856..11eadf6 100644 --- a/.claude/tasks/0003-issue-view-and-truncation.md +++ b/.claude/tasks/0003-issue-view-and-truncation.md @@ -15,12 +15,26 @@ No `type` field and no sub-issue augmentation. ## Acceptance criteria -- [ ] `issue view ` renders the default detail fields plus `comment_count` via renderDetail -- [ ] Bodies over 500 chars are cleaned then truncated with the inline hint `"... (truncated, N chars total - use --full to see complete body)"`; bodies at or under the limit pass through untouched -- [ ] cleanBody normalizes issue/PR URLs using the detected hostname, strips image embeds and long URLs, and collapses quoted blocks -- [ ] `--comments` renders every comment (no cap), each body cleaned and truncated at 800 chars -- [ ] `--full` returns raw, untruncated body and comment bodies -- [ ] A PR number yields `VALIDATION_ERROR` (exit 2) with the "is a pull request" message and a `pr view ` help line, detected via the fetched object's `pull_request` field -- [ ] A nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1) -- [ ] Single-entity next-step suggestions fill the actual issue number rather than a placeholder -- [ ] Fixture-server tests cover truncation boundaries, cleanBody transforms, `--comments`, `--full`, and the type guard +- [x] `issue view ` renders the default detail fields plus `comment_count` via renderDetail +- [x] Bodies over 500 chars are cleaned then truncated with the inline hint `"... (truncated, N chars total - use --full to see complete body)"`; bodies at or under the limit pass through untouched +- [x] cleanBody normalizes issue/PR URLs using the detected hostname, strips image embeds and long URLs, and collapses quoted blocks +- [x] `--comments` renders every comment (no cap), each body cleaned and truncated at 800 chars +- [x] `--full` returns raw, untruncated body and comment bodies +- [x] A PR number yields `VALIDATION_ERROR` (exit 2) with the "is a pull request" message and a `pr view ` help line, detected via the fetched object's `pull_request` field +- [x] A nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1) +- [x] Single-entity next-step suggestions fill the actual issue number rather than a placeholder +- [x] Fixture-server tests cover truncation boundaries, cleanBody transforms, `--comments`, `--full`, and the type guard + +## Implementation Notes + +The cleaning/truncation machinery lives in a new `src/body.ts` (`cleanBody`, `truncateBody`, and the `BODY_TRUNCATE_LIMIT`/`COMMENT_TRUNCATE_LIMIT` constants) so the later issue/PR slices can reuse it. +`renderDetail` was added to `src/render.ts` alongside `renderList`, sharing a private `encodeRows` helper for the `(none)` empty-block form. + +`comment_count` is emitted only in the default view; when `--comments` is passed, the full `comments` block replaces it rather than sitting alongside a redundant scalar. +This matches gh-axi's documented behaviour ("with `--comments`, the comments block is appended") — the spec lists `comment_count` as a default field but does not require it to persist under `--comments`. +When there are no comments, `comment_count` renders as the bare number `0` (no `use --comments` hint, since there is nothing to expand). + +The `--full` next-step suggestion fires whenever the rendered body differs from the raw body — i.e. it was cleaned-under-limit *or* truncated — read directly off the rendered output rather than recomputing the over-limit threshold, so the suggestion can never drift from `truncateBody`'s own decision. + +The default detail fields (`number`, `title`, `state`, `author`, `created`) reuse the shared `FieldDef`/`extractRow` extraction from the list path; only `body` and `comment_count` are handled bespokely. +As a consequence the displayed `number` comes straight from the fetched issue rather than falling back to the requested number, which is safe because the get-issue endpoint always returns it. diff --git a/src/body.ts b/src/body.ts new file mode 100644 index 0000000..650e3de --- /dev/null +++ b/src/body.ts @@ -0,0 +1,90 @@ +/** + * Body cleaning and truncation, shared by every command that renders an issue, + * pull request, or comment body. Cleaning is deliberately applied *only* when a + * body exceeds its truncation limit, so short bodies pass through byte-for-byte + * (see the gitea-axi spec, "Content truncation"). + */ + +export const BODY_TRUNCATE_LIMIT = 500; +export const COMMENT_TRUNCATE_LIMIT = 800; + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** The two Gitea URL path segments that carry a reference number. */ +type RefKind = "issues" | "pulls"; + +function issueRef(kind: RefKind, number: string): string { + return kind === "pulls" ? `PR#${number}` : `Issue#${number}`; +} + +/** + * Normalize Gitea issue/PR URLs on the given host, strip image embeds and long + * URLs, and collapse email-style quoted blocks. Transforms run in a fixed order + * so that later, coarser rules never consume text an earlier rule owns. + */ +export function cleanBody(text: string, host: string): string { + const escapedHost = escapeForRegex(host); + // owner/repo/(issues|pulls)/N, with an optional #fragment or ?query tail. + const urlCore = `https?://${escapedHost}/[^/\\s)]+/[^/\\s)]+/(issues|pulls)/(\\d+)`; + + let result = text; + + // 1. Gitea issue/PR URLs inside markdown links collapse to the bare ref. + result = result.replace( + new RegExp(`\\[[^\\]]*\\]\\(${urlCore}[^)]*\\)`, "g"), + (_match, kind: RefKind, number: string) => issueRef(kind, number), + ); + + // 2. Bare Gitea issue/PR URLs collapse to the ref as well. + result = result.replace( + new RegExp(`${urlCore}(?:[#?][^\\s)]*)?`, "g"), + (_match, kind: RefKind, number: string) => issueRef(kind, number), + ); + + // 3. Markdown image embeds become a compact placeholder. + result = result.replace( + /!\[([^\]]*)\]\([^)]*\)/g, + (_match, alt: string) => (alt.trim() ? `[image: ${alt.trim()}]` : "[image]"), + ); + + // 4. Markdown links wrapping a long URL (>80 chars) keep only their label. + result = result.replace( + /\[([^\]]*)\]\(([^)]+)\)/g, + (match, label: string, url: string) => (url.length > 80 ? `[${label}]` : match), + ); + + // 5. Standalone long URLs (>100 chars) are removed entirely. + result = result.replace(/https?:\/\/[^\s)]+/g, (match) => + match.length > 100 ? "[long URL removed]" : match, + ); + + // 6. Email-style quoted blocks of 3+ consecutive `>` lines collapse to a note. + result = result.replace( + /(?:^[ \t]*>.*(?:\r?\n|$)){3,}/gm, + "[quoted text removed]\n", + ); + + return result; +} + +/** + * Return a body for display. Bodies within `maxLen` are returned untouched. When + * over the limit, cleaning is applied: if the cleaned body now fits it is + * returned with a "cleaned" note; otherwise it is truncated with the inline + * "truncated" hint. `N` in both notes is the original body length, since that is + * what `--full` would reveal. + */ +export function truncateBody(body: string, maxLen: number, host: string): string { + if (body.length <= maxLen) { + return body; + } + const cleaned = cleanBody(body, host); + if (cleaned.length <= maxLen) { + // Reaching here means cleaning shortened an over-limit body to fit, so it + // necessarily changed the content — hence the unconditional note. + return `${cleaned}\n(cleaned, ${body.length} chars original - use --full to see original)`; + } + return `${cleaned.slice(0, maxLen)}\n... (truncated, ${body.length} chars total - use --full to see complete body)`; +} diff --git a/src/cli.ts b/src/cli.ts index 044ae44..47c3847 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ const TOP_LEVEL_HELP = `usage: gitea-axi [flags] commands: issue list List issues in the current repository + issue view Show a single issue's details global flags: -R, --repo Override the repository detected from the git origin remote diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 5b1dca3..42044ad 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -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 [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 --help\` for the flags of a command. +`; + +export const ISSUE_VIEW_HELP = `usage: gitea-axi issue view [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 Override the repository detected from the git origin remote + --login 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 { }); } +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} \``, + ]); + } + 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[] = [ + 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 { + 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[] { + 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 { + 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 => { 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", ]); diff --git a/src/render.ts b/src/render.ts index 67eeb0d..5800669 100644 --- a/src/render.ts +++ b/src/render.ts @@ -28,6 +28,11 @@ export function formatCountLine( return `count: ${shown} of ${total} total`; } +/** Encode a named list block, with an explicit empty-state line when there are no rows. */ +function encodeRows(noun: string, rows: Record[]): string { + return rows.length > 0 ? encode({ [noun]: rows }) : `${noun}[0]: (none)`; +} + export interface RenderListOptions { noun: string; rows: Record[]; @@ -36,9 +41,28 @@ export interface RenderListOptions { } export function renderList(options: RenderListOptions): string { - const body = - options.rows.length > 0 - ? encode({ [options.noun]: options.rows }) - : `${options.noun}[0]: (none)`; + const body = encodeRows(options.noun, options.rows); return [options.countLine, body, encode({ help: options.help })].join("\n"); } + +/** A secondary list block appended below a detail entity (e.g. comments). */ +export interface DetailBlock { + noun: string; + rows: Record[]; +} + +export interface RenderDetailOptions { + noun: string; + item: Record; + blocks?: DetailBlock[]; + help: string[]; +} + +export function renderDetail(options: RenderDetailOptions): string { + const parts = [encode({ [options.noun]: options.item })]; + for (const block of options.blocks ?? []) { + parts.push(encodeRows(block.noun, block.rows)); + } + parts.push(encode({ help: options.help })); + return parts.join("\n"); +} diff --git a/test/body.test.ts b/test/body.test.ts new file mode 100644 index 0000000..72001db --- /dev/null +++ b/test/body.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + BODY_TRUNCATE_LIMIT, + cleanBody, + truncateBody, +} from "../src/body.js"; + +const HOST = "gitea.example.com"; + +describe("truncateBody", () => { + it("returns bodies at or under the limit untouched, without cleaning", () => { + const body = `See https://${HOST}/o/r/issues/9 for details`; + expect(truncateBody(body, BODY_TRUNCATE_LIMIT, HOST)).toBe(body); + }); + + it("returns a body exactly at the limit untouched", () => { + const body = "x".repeat(BODY_TRUNCATE_LIMIT); + expect(truncateBody(body, BODY_TRUNCATE_LIMIT, HOST)).toBe(body); + }); + + it("truncates an over-limit body with the inline hint and original length", () => { + const body = "y".repeat(BODY_TRUNCATE_LIMIT + 250); + const out = truncateBody(body, BODY_TRUNCATE_LIMIT, HOST); + expect(out.startsWith("y".repeat(BODY_TRUNCATE_LIMIT))).toBe(true); + expect(out).toContain( + `... (truncated, ${BODY_TRUNCATE_LIMIT + 250} chars total - use --full to see complete body)`, + ); + }); + + it("returns the cleaned body with a note when cleaning brings it under the limit", () => { + const longUrl = `https://${HOST}/${"segment/".repeat(20)}deep/path/resource`; + const filler = "z".repeat(BODY_TRUNCATE_LIMIT - 20); + const body = `${filler} ${longUrl}`; + expect(body.length).toBeGreaterThan(BODY_TRUNCATE_LIMIT); + const out = truncateBody(body, BODY_TRUNCATE_LIMIT, HOST); + expect(out).toContain("[long URL removed]"); + expect(out).toContain(`(cleaned, ${body.length} chars original - use --full to see original)`); + expect(out).not.toContain("truncated,"); + }); +}); + +describe("cleanBody", () => { + it("normalizes bare issue and PR URLs on the detected host", () => { + const text = `Fixed by https://${HOST}/acme/widgets/pulls/12, closes https://${HOST}/acme/widgets/issues/7`; + const out = cleanBody(text, HOST); + expect(out).toContain("PR#12"); + expect(out).toContain("Issue#7"); + expect(out).not.toContain(HOST); + }); + + it("normalizes issue URLs carrying a fragment", () => { + const out = cleanBody(`see https://${HOST}/o/r/issues/33#issuecomment-9 ok`, HOST); + expect(out).toContain("Issue#33"); + expect(out).not.toContain("issuecomment"); + }); + + it("normalizes issue/PR URLs inside markdown links", () => { + const out = cleanBody(`[the fix](https://${HOST}/o/r/pulls/5)`, HOST); + expect(out).toContain("PR#5"); + expect(out).not.toContain("the fix"); + }); + + it("leaves URLs on a different host alone", () => { + const text = `https://other.host/o/r/issues/7`; + expect(cleanBody(text, HOST)).toBe(text); + }); + + it("strips markdown image embeds, keeping alt text when present", () => { + expect(cleanBody("![a diagram](https://x/y.png)", HOST)).toContain("[image: a diagram]"); + expect(cleanBody("![](https://x/y.png)", HOST)).toContain("[image]"); + }); + + it("drops the URL from a markdown link when the URL is very long", () => { + const longUrl = `https://cdn.example.com/${"a".repeat(90)}`; + const out = cleanBody(`[report](${longUrl})`, HOST); + expect(out).toBe("[report]"); + }); + + it("removes standalone long URLs over 100 chars", () => { + const longUrl = `https://cdn.example.com/${"b".repeat(110)}`; + expect(cleanBody(`prefix ${longUrl} suffix`, HOST)).toBe("prefix [long URL removed] suffix"); + }); + + it("collapses email-style quoted blocks of three or more lines", () => { + const text = "reply\n> one\n> two\n> three\nend"; + const out = cleanBody(text, HOST); + expect(out).toContain("[quoted text removed]"); + expect(out).not.toContain("> two"); + }); + + it("leaves a short (two-line) quoted block intact", () => { + const text = "> one\n> two\nafter"; + const out = cleanBody(text, HOST); + expect(out).toContain("> one"); + expect(out).not.toContain("[quoted text removed]"); + }); +}); diff --git a/test/issue-view.test.ts b/test/issue-view.test.ts new file mode 100644 index 0000000..afdb614 --- /dev/null +++ b/test/issue-view.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { runCliTest, testModeEnv } from "./harness.js"; + +const ISSUE_PATH = "/api/v1/repos/testowner/testrepo/issues/42"; +const COMMENTS_PATH = "/api/v1/repos/testowner/testrepo/issues/42/comments"; + +let server: FixtureServer; + +afterEach(async () => { + await server.close(); +}); + +function issueBody(fields: Record): Record { + return { + number: 42, + title: "Fix the thing", + state: "open", + user: { login: "alexion" }, + created_at: "2026-07-01T00:00:00Z", + comments: 0, + body: "", + ...fields, + }; +} + +describe("issue view", () => { + it("renders the default detail fields plus comment_count", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: ISSUE_PATH, + body: issueBody({ comments: 3, body: "A short body." }), + }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "view", "42"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("issue:"); + expect(stdout).toContain("number: 42"); + expect(stdout).toContain("title: Fix the thing"); + expect(stdout).toContain("state: open"); + expect(stdout).toContain("author: alexion"); + expect(stdout).toContain("body: A short body."); + expect(stdout).toContain("comment_count: 3 — use --comments to see full comments"); + }); + + it("renders comment_count: 0 when there are no comments", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 0 }) }, + ]); + const { stdout } = await runCliTest(["issue", "view", "42"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain("comment_count: 0"); + expect(stdout).not.toContain("use --comments"); + }); + + it("passes a body at or under 500 chars through untouched", async () => { + const body = "b".repeat(500); + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ body }) }, + ]); + const { stdout } = await runCliTest(["issue", "view", "42"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain(body); + expect(stdout).not.toContain("truncated"); + expect(stdout).not.toContain("cleaned"); + }); + + it("cleans then truncates a body over 500 chars, with the inline hint", async () => { + const body = `See http://127.0.0.1/o/r/issues/7 for context. ${"y".repeat(600)}`; + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ body }) }, + ]); + const { stdout } = await runCliTest(["issue", "view", "42"], { + env: testModeEnv(server.url), + }); + + // cleanBody normalized the host URL before truncation kicked in. + expect(stdout).toContain("Issue#7"); + expect(stdout).toContain( + `... (truncated, ${body.length} chars total - use --full to see complete body)`, + ); + expect(stdout).toContain("issue view 42 --full"); + }); + + it("returns the cleaned body with a note when cleaning brings it under 500", async () => { + const longUrl = `http://127.0.0.1/${"c".repeat(110)}`; + const body = `${"d".repeat(480)} ${longUrl}`; + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ body }) }, + ]); + const { stdout } = await runCliTest(["issue", "view", "42"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain("[long URL removed]"); + expect(stdout).toContain(`(cleaned, ${body.length} chars original - use --full to see original)`); + expect(stdout).not.toContain("truncated,"); + }); + + it("suppresses body truncation with --full", async () => { + const body = "e".repeat(900); + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ body }) }, + ]); + const { stdout } = await runCliTest(["issue", "view", "42", "--full"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain(body); + expect(stdout).not.toContain("truncated"); + }); + + it("renders every comment with --comments, truncating bodies at 800 chars", async () => { + const longComment = "f".repeat(1000); + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 2 }) }, + { + method: "GET", + path: COMMENTS_PATH, + 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(["issue", "view", "42", "--comments"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("comments[2]"); + expect(stdout).toContain("bob"); + expect(stdout).toContain("short reply"); + expect(stdout).toContain( + `... (truncated, ${longComment.length} chars total - use --full to see complete body)`, + ); + // With --comments the redundant scalar count is replaced by the block. + expect(stdout).not.toContain("comment_count"); + }); + + it("suppresses comment truncation with --full --comments", async () => { + const longComment = "g".repeat(1000); + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 1 }) }, + { + method: "GET", + path: COMMENTS_PATH, + body: [{ user: { login: "sue" }, created_at: "2026-07-03T00:00:00Z", body: longComment }], + }, + ]); + const { stdout } = await runCliTest(["issue", "view", "42", "--full", "--comments"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain(longComment); + expect(stdout).not.toContain("truncated"); + }); + + it("renders an explicit empty comments block when there are none", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 0 }) }, + { method: "GET", path: COMMENTS_PATH, body: [] }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "view", "42", "--comments"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("comments[0]: (none)"); + }); + + it("surfaces a failure to fetch comments", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 2 }) }, + { method: "GET", path: COMMENTS_PATH, status: 403, body: { message: "forbidden" } }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "view", "42", "--comments"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: FORBIDDEN"); + }); + + it("prints help with --help without calling the API", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "view", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi issue view "); + expect(server.requests).toHaveLength(0); + }); + + it("rejects a pull request number with VALIDATION_ERROR and a pr view hint", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: ISSUE_PATH, + body: issueBody({ pull_request: { merged: false, html_url: "http://x/pulls/42" } }), + }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "view", "42"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("issue #42 is a pull request"); + expect(stdout).toContain("pr view 42"); + }); + + it("reports a nonexistent issue as ISSUE_NOT_FOUND with exit 1", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: ISSUE_PATH, + status: 404, + body: { message: "Not Found" }, + }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "view", "42"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: ISSUE_NOT_FOUND"); + }); + + it("rejects a missing issue number with exit 2", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "view"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); + + it("rejects a non-numeric issue number with exit 2", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "view", "abc"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); +});