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.
91 lines
3.4 KiB
TypeScript
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)`;
|
|
}
|