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

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 is contained in:
2026-07-11 19:32:31 -04:00
parent ae0f4c2675
commit 3446ce33a5
7 changed files with 672 additions and 16 deletions

97
test/body.test.ts Normal file
View File

@@ -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]");
});
});