Files
gitea-axi/src/body.ts
alexion 5e66b4d746
All checks were successful
CI / test (pull_request) Successful in 26s
CI / test (push) Successful in 23s
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:36:24 -04:00

91 lines
3.4 KiB
TypeScript

/**
* 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)`;
}