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.
This commit was merged in pull request #2.
This commit is contained in:
90
src/body.ts
Normal file
90
src/body.ts
Normal file
@@ -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)`;
|
||||
}
|
||||
Reference in New Issue
Block a user