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:
@@ -15,12 +15,26 @@ No `type` field and no sub-issue augmentation.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `issue view <n>` renders the default detail fields plus `comment_count` via renderDetail
|
- [x] `issue view <n>` 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
|
- [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
|
||||||
- [ ] cleanBody normalizes issue/PR URLs using the detected hostname, strips image embeds and long URLs, and collapses quoted blocks
|
- [x] 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
|
- [x] `--comments` renders every comment (no cap), each body cleaned and truncated at 800 chars
|
||||||
- [ ] `--full` returns raw, untruncated body and comment bodies
|
- [x] `--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 <n>` help line, detected via the fetched object's `pull_request` field
|
- [x] A PR number yields `VALIDATION_ERROR` (exit 2) with the "is a pull request" message and a `pr view <n>` help line, detected via the fetched object's `pull_request` field
|
||||||
- [ ] A nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1)
|
- [x] A nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1)
|
||||||
- [ ] Single-entity next-step suggestions fill the actual issue number rather than a placeholder
|
- [x] 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] 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.
|
||||||
|
|||||||
@@ -3,3 +3,11 @@
|
|||||||
## Commits
|
## Commits
|
||||||
|
|
||||||
Any commit message you write must follow the Conventional Commits specification as documented in [CONVENTIONAL-COMMITS.md](CONVENTIONAL-COMMITS.md).
|
Any commit message you write must follow the Conventional Commits specification as documented in [CONVENTIONAL-COMMITS.md](CONVENTIONAL-COMMITS.md).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
The `origin` remote is a self-hosted **Gitea** instance (`git.alexion.dev`), not GitHub.
|
||||||
|
The `gh` CLI does not work here — open pull requests with `tea pr create --login axi --base main --head <branch>`, and list them with `tea pr`.
|
||||||
|
|
||||||
|
Task branches are merged into `main` on the remote, so the local `main` goes stale.
|
||||||
|
Always `git fetch origin` and cut a task branch from `origin/main`, not from whatever local `main` happens to point at.
|
||||||
|
|||||||
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)`;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const TOP_LEVEL_HELP = `usage: gitea-axi <command> [flags]
|
|||||||
|
|
||||||
commands:
|
commands:
|
||||||
issue list List issues in the current repository
|
issue list List issues in the current repository
|
||||||
|
issue view Show a single issue's details
|
||||||
|
|
||||||
global flags:
|
global flags:
|
||||||
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
||||||
|
|||||||
@@ -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 { createClient } from "../client.js";
|
||||||
import { resolveRepoContext, type RepoContext } from "../context.js";
|
import { resolveRepoContext, type RepoContext } from "../context.js";
|
||||||
import type { CliDeps } from "../deps.js";
|
import type { CliDeps } from "../deps.js";
|
||||||
import { axiError, classifyHttpError } from "../errors.js";
|
import { axiError, classifyHttpError } from "../errors.js";
|
||||||
import { extractRow, lowercased, pluck, relativeTimeField, type FieldDef } from "../fields.js";
|
import { extractRow, lowercased, pluck, relativeTimeField, type FieldDef } from "../fields.js";
|
||||||
import { parseFlags } from "../flags.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";
|
import { suggestCommand } from "../suggestions.js";
|
||||||
|
|
||||||
export const ISSUE_HELP = `usage: gitea-axi issue <command> [flags]
|
export const ISSUE_HELP = `usage: gitea-axi issue <command> [flags]
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
list List issues in the current repository
|
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 <command> --help\` for the flags of a command.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ISSUE_VIEW_HELP = `usage: gitea-axi issue view <number> [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 <OWNER/NAME> Override the repository detected from the git origin remote
|
||||||
|
--login <name> Select a tea login profile by name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ISSUE_LIST_HELP = `usage: gitea-axi issue list [flags]
|
export const ISSUE_LIST_HELP = `usage: gitea-axi issue list [flags]
|
||||||
@@ -146,6 +167,152 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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} <number>\``,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
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<Issue>[] = [
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>[] {
|
||||||
|
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<string> {
|
||||||
|
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) {
|
export function issueCommand(deps: CliDeps) {
|
||||||
return async (args: string[]): Promise<string> => {
|
return async (args: string[]): Promise<string> => {
|
||||||
const [subcommand, ...rest] = args;
|
const [subcommand, ...rest] = args;
|
||||||
@@ -155,6 +322,9 @@ export function issueCommand(deps: CliDeps) {
|
|||||||
if (subcommand === "list") {
|
if (subcommand === "list") {
|
||||||
return issueList(deps, rest);
|
return issueList(deps, rest);
|
||||||
}
|
}
|
||||||
|
if (subcommand === "view") {
|
||||||
|
return issueView(deps, rest);
|
||||||
|
}
|
||||||
throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [
|
throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [
|
||||||
"Run `gitea-axi issue --help` to see available issue commands",
|
"Run `gitea-axi issue --help` to see available issue commands",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export function formatCountLine(
|
|||||||
return `count: ${shown} of ${total} total`;
|
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, unknown>[]): string {
|
||||||
|
return rows.length > 0 ? encode({ [noun]: rows }) : `${noun}[0]: (none)`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderListOptions {
|
export interface RenderListOptions {
|
||||||
noun: string;
|
noun: string;
|
||||||
rows: Record<string, unknown>[];
|
rows: Record<string, unknown>[];
|
||||||
@@ -36,9 +41,28 @@ export interface RenderListOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderList(options: RenderListOptions): string {
|
export function renderList(options: RenderListOptions): string {
|
||||||
const body =
|
const body = encodeRows(options.noun, options.rows);
|
||||||
options.rows.length > 0
|
|
||||||
? encode({ [options.noun]: options.rows })
|
|
||||||
: `${options.noun}[0]: (none)`;
|
|
||||||
return [options.countLine, body, encode({ help: options.help })].join("\n");
|
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<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderDetailOptions {
|
||||||
|
noun: string;
|
||||||
|
item: Record<string, unknown>;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|||||||
97
test/body.test.ts
Normal file
97
test/body.test.ts
Normal 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("", HOST)).toContain("[image: a diagram]");
|
||||||
|
expect(cleanBody("", 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]");
|
||||||
|
});
|
||||||
|
});
|
||||||
260
test/issue-view.test.ts
Normal file
260
test/issue-view.test.ts
Normal file
@@ -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<string, unknown>): Record<string, unknown> {
|
||||||
|
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 <number>");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user