diff --git a/.claude/adr/0003-inline-truncation-with-full-flag.md b/.claude/adr/0003-inline-truncation-with-full-flag.md index c23e1e3..cf17dab 100644 --- a/.claude/adr/0003-inline-truncation-with-full-flag.md +++ b/.claude/adr/0003-inline-truncation-with-full-flag.md @@ -15,5 +15,7 @@ Simpler, portable, consistent with the stated reference. ## Consequences `issue view` and `pr view` both accept `--full` to return untruncated body. +`issue list`, `issue create`, `pr list`, and `search` also accept `--full`, since they offer `body` via `--fields` and that field truncates identically (task 0021). +The flag keeps the inline hint's "use --full" promise honest on those commands too. `pr diff` truncates at 4000 chars (matching gh-axi's `DIFF_TRUNCATE_LIMIT`); when truncated, a next-step suggestion to rerun with `--full` is prepended. The `full_content` field name and temp-file design from the spec draft are dropped entirely. diff --git a/.claude/spec/gitea-axi.md b/.claude/spec/gitea-axi.md index 6030f05..e17375d 100644 --- a/.claude/spec/gitea-axi.md +++ b/.claude/spec/gitea-axi.md @@ -98,7 +98,7 @@ This holds even when invoked by the SessionStart hook; error noise in non-Gitea Always passes `type=issues` — Gitea's issue endpoints also serve PRs, which must never appear in issue lists (see Issue/PR Type Guard). Client-side `--sort` reorders without changing membership, so the standard `count: N of T total` line is kept (see ADR 0005); full pagination still precedes sorting. Default output fields (matching gh-axi): `number`, `title`, `state` (lowercased), `author` (plucked from `user.login`), `created` (relative time). -Extra fields via `--fields`: `body` (raw), `closedAt` (relative time, as `closed_at`), `labels` (joined names), `milestone` (title), `updatedAt` (relative time, as `updated_at`), `url`. +Extra fields via `--fields`: `body` (truncated at 500, exactly as `issue view` presents it; `--full` shows it raw), `closedAt` (relative time, as `closed_at`), `labels` (joined names), `milestone` (title), `updatedAt` (relative time, as `updated_at`), `url`. No `type` field in output — Gitea has no issue types. **`issue view [flags]`** @@ -118,7 +118,7 @@ No sub-issue augmentation (Gitea does not model issue hierarchies; use `issue bl `--project` is excluded (Gitea has no projects REST API). `--type` is excluded (Gitea has no issue types). Output schema: `issue: { number, title, state, url }` (where `url` = `html_url`). -Extra fields available via `--fields`: `labels`, `assignees`, `milestone`, `body`. +Extra fields available via `--fields`: `labels`, `assignees`, `milestone`, `body` (truncated at 500, exactly as `issue view` presents it; `--full` shows it raw). **`issue edit [flags]`** `--title`; @@ -209,7 +209,7 @@ No gh-axi equivalent. `--fields `. `--search` is explicitly forbidden (VALIDATION_ERROR; help: `` Use `gitea-axi search prs ""` for full-text search ``). Default output fields (matching gh-axi): `number`, `title`, `state` (lowercased), `author` (plucked from `user.login`), `draft` (bool→yes/no), `review` (`reviewDecision` mapped: APPROVED→approved, CHANGES_REQUESTED→changes_requested, REVIEW_REQUIRED→required). -Extra fields via `--fields`: `body` (raw), `createdAt` (relative time, as `created`), `labels` (joined names), `milestone` (title), `mergedAt` (relative time, as `merged_at`), `url`. +Extra fields via `--fields`: `body` (truncated at 500, exactly as `pr view` presents it; `--full` shows it raw), `createdAt` (relative time, as `created`), `labels` (joined names), `milestone` (title), `mergedAt` (relative time, as `merged_at`), `url`. `reviewDecision` is computed client-side by fetching reviews for each PR in parallel (one extra HTTP call per PR; see ADR 0006). When any client-side filter is active, the count line shows `count: N of T total` with `T` computed from the in-memory filtered result set (see ADR 0005). @@ -361,7 +361,7 @@ Full-text search within the current repository, added because `--search` on the The positional `` is required (`VALIDATION_ERROR` if missing). Endpoint: `GET /repos/issues/search` with `q=`, `type=issues` or `type=pulls`, and `owner=`. The endpoint has no repo-name filter, so results are additionally filtered client-side to the current repository via each result's `repository` field — the standard client-side filtering policy applies, including its `count: N of T total` rule with `T` from the filtered set. -Flags: `--state ` (default open); `--label ` (API-supported — comma-separated names); `--limit ` (default 30); `--fields `. +Flags: `--state ` (default open); `--label ` (API-supported — comma-separated names); `--limit ` (default 30); `--fields ` (the same extras as `issue list` / `pr list`, so `body` is truncated at 500); `--full` (shows any `--fields body` raw, matching the list commands). Default output fields (both commands): `number`, `title`, `state`, `author`, `created` — a locator schema; search results are Issue-shaped for both types, and `draft`/`review` parity with `pr list` would require two extra fetches per result for a command whose job is finding the number to feed into `issue view` / `pr view`. Output blocks: `issues:` / `pull_requests:`, matching the list commands. Empty state: standard `[0]: (none)`. @@ -464,11 +464,15 @@ Field extraction uses a `FieldDef` type system with typed extractors: nested plu **Principle 3 — Content truncation.** Body text is truncated at **500 characters** in all contexts (list and detail alike), matching gh-axi. +"All contexts" is exhaustive: it includes the `body` field offered via `--fields` on `issue list`, `issue create`, `pr list`, and `search`, which truncate identically to `issue view` / `pr view` rather than emitting the body raw (task 0021). +The Command Surface once described that field as "`body` (raw)". +"Raw" is resolved here to mean *uncleaned markdown* (short bodies still pass through byte-for-byte, un-`cleanBody`-ed), never *unbounded* — an unbounded body on a list path would let `issue list --limit 30 --fields body` spill thirty full bodies into an agent's context, the exact cost this principle exists to prevent. Comment bodies truncate at 800 characters wherever they appear (comment-post output and `--comments` view blocks), with cleanBody applied. Diff content is truncated at 4000 characters. When body truncation occurs, a hint is appended inline: `"... (truncated, N chars total - use --full to see complete body)"`. When diff truncation occurs, `truncated: true` and `original_length: N` are added as separate fields, and a next-step suggestion to use `--full` is prepended. `--full` on `issue view` and `pr view` suppresses all truncation in the command's output (entity body and comment bodies alike); `--full` on `pr diff` suppresses diff truncation. +`--full` on `issue list`, `issue create`, `pr list`, and `search` likewise suppresses the `--fields body` truncation, so the hint's "use --full to see complete body" holds on every command that offers the field. Before truncation, a `cleanBody` step is applied **only when the raw body exceeds the truncation limit**. `cleanBody` normalizes Gitea issue/PR URLs using the detected hostname (`https://///issues/N` → `Issue#N`; `.../pulls/N` → `PR#N`), strips markdown image embeds, removes long URLs in markdown links and standalone text, and collapses email-style quoted blocks — matching gh-axi's transforms plus Gitea-specific URL normalization. If cleaning brings the body within the limit, the cleaned body is returned with an appended note; if it still exceeds the limit, the cleaned body is truncated. diff --git a/.claude/tasks/0021-body-field-truncation-ruling.md b/.claude/tasks/0021-body-field-truncation-ruling.md index 5dfaeda..bca4574 100644 --- a/.claude/tasks/0021-body-field-truncation-ruling.md +++ b/.claude/tasks/0021-body-field-truncation-ruling.md @@ -23,7 +23,26 @@ The ruling is verified at the fixture/unit tier only: body truncation is determi ## Acceptance criteria -- [ ] The spec no longer contradicts itself: Principle 3 and the Command Surface's `body` field agree, with the reasoning recorded -- [ ] `issue list --fields body` and `issue create --fields body` behave identically under the ruling -- [ ] If truncation wins, the truncation hint and `--full` affordance match how `issue view` already presents a truncated body (see ADR 0003) -- [ ] Tests cover the ruled behaviour on both commands +- [x] The spec no longer contradicts itself: Principle 3 and the Command Surface's `body` field agree, with the reasoning recorded +- [x] `issue list --fields body` and `issue create --fields body` behave identically under the ruling +- [x] If truncation wins, the truncation hint and `--full` affordance match how `issue view` already presents a truncated body (see ADR 0003) +- [x] Tests cover the ruled behaviour on both commands + +## Implementation Notes + +**The ruling: `--fields body` truncates.** +Principle 3 already declared truncation applies "in all contexts (list and detail alike)"; the four `--fields body` extractors were the drift, not the intent. +"Raw" in the Command Surface is resolved to mean *uncleaned markdown* (short bodies still pass through byte-for-byte), never *unbounded* — an unbounded body on a list path lets `issue list --limit 30 --fields body` spill thirty full bodies into an agent's context, the exact cost Principle 3 exists to prevent. + +**Code.** +Added a `truncatedBody()` `FieldDef` in `fields.ts` (alongside `pluck`/`joined`/`relativeTimeField`) that routes the value through the same `truncateBody(value, BODY_TRUNCATE_LIMIT, host)` that `issue view` / `pr view` use, so the hint text and `cleanBody`-on-overflow behaviour are byte-identical. +`ExtractContext` gained `host` and `full` (both required) — the render context now declares its hostname and truncation mode explicitly; every `extractRow` call site was updated (the body-less ones — dashboard, label list, relationships — pass `full: false`). +The four registries (`ISSUE_LIST`, `ISSUE_CREATE`, `PR_LIST`, `SEARCH` extra fields) now use `truncatedBody("body")`. + +**Scope beyond the two commands the ACs name.** +The task body mandated that "`issue list`, `issue create`, and any later command exposing `body` via `--fields` must behave the same", so `pr list` and `search` were included too — each got the ruling and a parity test. +A `--full` flag (suppresses the `--fields body` truncation, matching `issue view`) was added to `issue list`, `issue create`, `pr list`, and `search`, since the inline hint literally says "use --full to see complete body" and that promise must be keepable on every command that offers the field. +Help text for all four commands and ADR 0003's consequences were updated accordingly. + +**Verification tier.** +Fixture/unit tier only, as the task specified — no e2e case: truncation is deterministic string processing over a body the CLI already holds, with no live-Gitea response semantics to attest to. diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index a27158c..cd1d3b0 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -145,7 +145,7 @@ async function prRowsWithReview( ): Promise[]> { const decisions = await pullDecisions(api, context, pulls); return pulls.map((pull, index) => { - const row = extractRow(pull, fields, { now }); + const row = extractRow(pull, fields, { now, host: context.host, full: false }); row.review = decisions[index]; return row; }); @@ -187,7 +187,9 @@ async function shortDashboard(api: GiteaClient, context: RepoContext): Promise extractRow(issue, SHORT_ISSUE_FIELDS, { now })); + const issueRows = issues.map((issue) => + extractRow(issue, SHORT_ISSUE_FIELDS, { now, host: context.host, full: false }), + ); return [ `repo: ${context.owner}/${context.name}`, diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 2774fab..ebfe046 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -14,6 +14,7 @@ import { pluck, relativeTimeField, selectExtraFields, + truncatedBody, type FieldDef, } from "../fields.js"; import { @@ -190,6 +191,7 @@ flags: --label Apply a label by name (repeatable, case-insensitive) --milestone Assign a milestone by name (case-insensitive) --fields Append extra fields: labels, assignees, milestone, body + --full Show the body field raw, without 500-char truncation --help Show this help global flags: @@ -240,6 +242,7 @@ flags: --sort Sort descending (client-side) --limit Maximum number of issues to return (default: 30) --fields Append extra fields: body, closedAt, labels, milestone, updatedAt, url + --full Show the body field raw, without 500-char truncation --help Show this help global flags: @@ -257,7 +260,7 @@ const ISSUE_LIST_FIELDS: FieldDef[] = [ // Appended to the defaults on request via `--fields`, never replacing them. const ISSUE_LIST_EXTRA_FIELDS: Record> = { - body: pluck("body"), + body: truncatedBody("body"), closedAt: relativeTimeField("closedAt", "closed_at"), labels: joined("labels", "labels", "name"), milestone: pluck("milestone", "milestone.title"), @@ -394,6 +397,7 @@ async function issueList(deps: CliDeps, args: string[]): Promise { "--sort": { takesValue: true }, "--limit": { takesValue: true }, "--fields": { takesValue: true }, + "--full": { takesValue: false }, }, "issue list", ); @@ -407,6 +411,7 @@ async function issueList(deps: CliDeps, args: string[]): Promise { const state = parseState(flags["--state"]); const sort = parseSort(flags["--sort"]); const limit = parseLimit(flags["--limit"]); + const full = flags["--full"] === true; const extraFields = selectExtraFields( flagValue(flags, "--fields"), ISSUE_LIST_EXTRA_FIELDS, @@ -450,7 +455,7 @@ async function issueList(deps: CliDeps, args: string[]): Promise { const now = new Date(); const rows = issues.map((issue) => - extractRow(issue, [...ISSUE_LIST_FIELDS, ...extraFields], { now }), + extractRow(issue, [...ISSUE_LIST_FIELDS, ...extraFields], { now, host: context.host, full }), ); return renderList({ noun: "issues", @@ -478,7 +483,11 @@ interface IssueDetailOptions { } function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record { - const row = extractRow(issue, ISSUE_VIEW_FIELDS, { now: options.now }); + const row = extractRow(issue, ISSUE_VIEW_FIELDS, { + now: options.now, + host: options.host, + full: options.full, + }); const body = issue.body ?? ""; row.body = options.full ? body : truncateBody(body, BODY_TRUNCATE_LIMIT, options.host); if (!options.withComments) { @@ -584,7 +593,7 @@ const ISSUE_CREATE_EXTRA_FIELDS: Record> = { labels: joined("labels", "labels", "name"), assignees: joined("assignees", "assignees", "login"), milestone: pluck("milestone", "milestone.title"), - body: pluck("body"), + body: truncatedBody("body"), }; async function issueCreate(deps: CliDeps, args: string[]): Promise { @@ -601,6 +610,7 @@ async function issueCreate(deps: CliDeps, args: string[]): Promise { "--label": { takesValue: true, repeatable: true }, "--milestone": { takesValue: true }, "--fields": { takesValue: true }, + "--full": { takesValue: false }, }, "issue create", ); @@ -621,6 +631,7 @@ async function issueCreate(deps: CliDeps, args: string[]): Promise { ]); } const body = resolveBodySource(deps, flags, "issue create"); + const full = flags["--full"] === true; const extraFields = selectExtraFields( flagValue(flags, "--fields"), ISSUE_CREATE_EXTRA_FIELDS, @@ -656,7 +667,11 @@ async function issueCreate(deps: CliDeps, args: string[]): Promise { throw classifyHttpError(error); } - const item = extractRow(issue, [...ISSUE_CREATE_FIELDS, ...extraFields], { now: new Date() }); + const item = extractRow(issue, [...ISSUE_CREATE_FIELDS, ...extraFields], { + now: new Date(), + host: context.host, + full, + }); return renderDetail({ noun: "issue", item, @@ -1170,7 +1185,9 @@ async function listRelationships( const issues = await fetchRelationships(api, context, group, number); const now = new Date(); - const rows = issues.map((issue) => extractRow(issue, RELATIONSHIP_FIELDS, { now })); + const rows = issues.map((issue) => + extractRow(issue, RELATIONSHIP_FIELDS, { now, host: context.host, full: false }), + ); return renderList({ noun: group.listNoun, rows, diff --git a/src/commands/label.ts b/src/commands/label.ts index 6615254..08f91ba 100644 --- a/src/commands/label.ts +++ b/src/commands/label.ts @@ -124,7 +124,9 @@ async function labelList(deps: CliDeps, args: string[]): Promise { } const now = new Date(); - const rows = labels.map((label) => extractRow(label, LABEL_LIST_FIELDS, { now })); + const rows = labels.map((label) => + extractRow(label, LABEL_LIST_FIELDS, { now, host: context.host, full: false }), + ); return renderList({ noun: "labels", rows, diff --git a/src/commands/pr.ts b/src/commands/pr.ts index 2f1fc2b..4cf2da3 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -26,6 +26,7 @@ import { pluck, relativeTimeField, selectExtraFields, + truncatedBody, type FieldDef, } from "../fields.js"; import { @@ -228,6 +229,7 @@ flags: Sort order (passed to the API) --limit Maximum number of pull requests to return (default: 30) --fields Append extra fields: body, createdAt, labels, milestone, mergedAt, url + --full Show the body field raw, without 500-char truncation --help Show this help global flags: @@ -343,7 +345,7 @@ const PR_LIST_FIELDS: FieldDef[] = [ // Appended to the defaults on request via `--fields`, never replacing them. const PR_LIST_EXTRA_FIELDS: Record> = { - body: pluck("body"), + body: truncatedBody("body"), createdAt: relativeTimeField("created", "created_at"), labels: joined("labels", "labels", "name"), milestone: pluck("milestone", "milestone.title"), @@ -537,6 +539,7 @@ async function prList(deps: CliDeps, args: string[]): Promise { "--sort": { takesValue: true }, "--limit": { takesValue: true }, "--fields": { takesValue: true }, + "--full": { takesValue: false }, }, "pr list", ); @@ -550,6 +553,7 @@ async function prList(deps: CliDeps, args: string[]): Promise { const state = parsePrState(flags["--state"]); const sort = parsePrSort(flags["--sort"]); const limit = parsePrLimit(flags["--limit"]); + const full = flags["--full"] === true; const extraFields = selectExtraFields( flagValue(flags, "--fields"), PR_LIST_EXTRA_FIELDS, @@ -609,10 +613,11 @@ async function prList(deps: CliDeps, args: string[]): Promise { ); const now = new Date(); + const extractContext = { now, host: context.host, full }; const rows = pulls.map((pull, index) => { - const row = extractRow(pull, PR_LIST_FIELDS, { now }); + const row = extractRow(pull, PR_LIST_FIELDS, extractContext); row.review = decisions[index]; - Object.assign(row, extractRow(pull, extraFields, { now })); + Object.assign(row, extractRow(pull, extraFields, extractContext)); return row; }); @@ -745,7 +750,11 @@ interface PrDetailOptions { } function buildPrDetail(pull: PullRequest, options: PrDetailOptions): Record { - const row = extractRow(pull, PR_VIEW_FIELDS, { now: options.now }); + const row = extractRow(pull, PR_VIEW_FIELDS, { + now: options.now, + host: options.host, + full: options.full, + }); // gh-axi renders `merged` as `no` when open, or the merge time once merged. row.merged = pull.merged ? relativeTime(pull.merged_at, options.now) : "no"; row.checks = options.checksSummary; diff --git a/src/commands/search.ts b/src/commands/search.ts index a442c29..a7cf301 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -10,6 +10,7 @@ import { pluck, relativeTimeField, selectExtraFields, + truncatedBody, type FieldDef, } from "../fields.js"; import { flagValue, parseEnumFlag, parseFlags, parsePositiveInt } from "../flags.js"; @@ -41,6 +42,7 @@ flags: --label Filter by label name (comma-separated) --limit Maximum number of matches to return (default: 30) --fields Append extra fields: body, closedAt, labels, milestone, updatedAt, url + --full Show the body field raw, without 500-char truncation --help Show this help global flags: @@ -60,6 +62,7 @@ flags: --label Filter by label name (comma-separated) --limit Maximum number of matches to return (default: 30) --fields Append extra fields: body, closedAt, labels, milestone, updatedAt, url + --full Show the body field raw, without 500-char truncation --help Show this help global flags: @@ -82,7 +85,7 @@ const SEARCH_FIELDS: FieldDef[] = [ // Appended to the locator schema on request via `--fields`, never replacing it. // Results are Issue-shaped, so this mirrors `issue list`'s extra-field vocabulary. const SEARCH_EXTRA_FIELDS: Record> = { - body: pluck("body"), + body: truncatedBody("body"), closedAt: relativeTimeField("closedAt", "closed_at"), labels: joined("labels", "labels", "name"), milestone: pluck("milestone", "milestone.title"), @@ -155,6 +158,7 @@ async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promi "--label": { takesValue: true }, "--limit": { takesValue: true }, "--fields": { takesValue: true }, + "--full": { takesValue: false }, }, kind.command, ); @@ -175,6 +179,7 @@ async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promi const limitFlag = flags["--limit"]; const limit = limitFlag === undefined ? DEFAULT_LIMIT : parsePositiveInt(limitFlag, "--limit", helpSuggestion); + const full = flags["--full"] === true; const extraFields = selectExtraFields( flagValue(flags, "--fields"), SEARCH_EXTRA_FIELDS, @@ -208,7 +213,9 @@ async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promi const total = matches.length; const shown = matches.slice(0, limit); const now = new Date(); - const rows = shown.map((issue) => extractRow(issue, [...SEARCH_FIELDS, ...extraFields], { now })); + const rows = shown.map((issue) => + extractRow(issue, [...SEARCH_FIELDS, ...extraFields], { now, host: context.host, full }), + ); return renderList({ noun: kind.noun, diff --git a/src/fields.ts b/src/fields.ts index f004c58..d17f5b6 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -1,8 +1,13 @@ +import { BODY_TRUNCATE_LIMIT, truncateBody } from "./body.js"; import { axiError } from "./errors.js"; import { relativeTime } from "./time.js"; export interface ExtractContext { now: Date; + /** Hostname for `cleanBody`'s Gitea URL normalization when a body is truncated. */ + host: string; + /** When set, `truncatedBody` returns the raw body — the `--full` affordance. */ + full: boolean; } export interface FieldDef { @@ -66,6 +71,26 @@ export function joined(name: string, path: string, key: string): FieldDef }; } +/** + * Render a `body` field under Principle 3's content truncation — identical to + * how `issue view` / `pr view` present a body: over-limit bodies are cleaned and + * cut to 500 chars with the inline "... (truncated, N chars total ...)" hint, + * while short bodies pass through byte-for-byte. `context.full` (the `--full` + * flag) suppresses truncation and returns the raw body. This is the single + * ruling applied everywhere `body` is offered via `--fields` (see task 0021): + * `issue list --limit 30 --fields body` must not spill 30 full bodies into an + * agent's context, the exact cost the truncation principle exists to prevent. + */ +export function truncatedBody(name: string, path: string = name): FieldDef { + return { + name, + extract: (raw, context) => { + const value = String(pluckPath(raw, path) ?? ""); + return context.full ? value : truncateBody(value, BODY_TRUNCATE_LIMIT, context.host); + }, + }; +} + export function relativeTimeField(name: string, path: string): FieldDef { return { name, diff --git a/test/issue-create.test.ts b/test/issue-create.test.ts index 59023a7..e09c55e 100644 --- a/test/issue-create.test.ts +++ b/test/issue-create.test.ts @@ -309,6 +309,48 @@ describe("issue create", () => { expect(stdout).toContain("number: 7"); }); + it("applies the body-truncation ruling to a created issue, and --full suppresses it", async () => { + // Truncated inline at 500 chars with the hint. + server = await startFixtureServer([ + { + method: "POST", + path: ISSUES_PATH, + status: 201, + body: createdIssue({ body: "x".repeat(650) }), + }, + ]); + const truncated = await runCliTest( + ["issue", "create", "--title", "T", "--fields", "body"], + { env: testModeEnv(server.url) }, + ); + + expect(truncated.exitCode).toBe(0); + expect(truncated.stdout).toContain("x".repeat(500)); + expect(truncated.stdout).toContain( + "... (truncated, 650 chars total - use --full to see complete body)", + ); + expect(truncated.stdout).not.toContain("x".repeat(650)); + + // --full returns the body raw, with no truncation hint. + await server.close(); + server = await startFixtureServer([ + { + method: "POST", + path: ISSUES_PATH, + status: 201, + body: createdIssue({ body: "x".repeat(650) }), + }, + ]); + const full = await runCliTest( + ["issue", "create", "--title", "T", "--fields", "body", "--full"], + { env: testModeEnv(server.url) }, + ); + + expect(full.exitCode).toBe(0); + expect(full.stdout).toContain("x".repeat(650)); + expect(full.stdout).not.toContain("truncated"); + }); + it("rejects an unknown --fields name with VALIDATION_ERROR", async () => { server = await startFixtureServer([]); const { stdout, exitCode } = await runCliTest( diff --git a/test/issue-list.test.ts b/test/issue-list.test.ts index c2c115c..d8ef559 100644 --- a/test/issue-list.test.ts +++ b/test/issue-list.test.ts @@ -469,6 +469,49 @@ describe("issue list --fields", () => { expect(row).toMatch(/\d+(mo|[smhdy]) ago/); }); + it("truncates an over-limit body inline, appending the same hint as issue view", async () => { + const fullBody = "x".repeat(650); + server = await startFixtureServer([ + { + method: "GET", + path: ISSUES_PATH, + headers: { "X-Total-Count": "1" }, + body: [{ ...issueOf(50), body: fullBody }], + }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "list", "--fields", "body"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("x".repeat(500)); + expect(stdout).toContain( + "... (truncated, 650 chars total - use --full to see complete body)", + ); + expect(stdout).not.toContain("x".repeat(650)); + }); + + it("returns the complete body untruncated under --full, with no truncation hint", async () => { + const fullBody = "x".repeat(650); + server = await startFixtureServer([ + { + method: "GET", + path: ISSUES_PATH, + headers: { "X-Total-Count": "1" }, + body: [{ ...issueOf(50), body: fullBody }], + }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "list", "--fields", "body", "--full"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("x".repeat(650)); + expect(stdout).not.toContain("truncated"); + }); + it("rejects an unknown --fields name with exit code 2", async () => { server = await startFixtureServer([]); const { stdout, exitCode } = await runCliTest( diff --git a/test/pr-list.test.ts b/test/pr-list.test.ts index 656e9aa..34aee60 100644 --- a/test/pr-list.test.ts +++ b/test/pr-list.test.ts @@ -463,6 +463,48 @@ describe("pr list --fields", () => { expect(row).toMatch(/\d+(mo|[smhdy]) ago/); }); + it("applies the body-truncation ruling to a PR body, and --full suppresses it", async () => { + // Truncated inline at 500 chars with the hint. + server = await startFixtureServer([ + { + method: "GET", + path: PULLS_PATH, + headers: { "X-Total-Count": "1" }, + body: [pullOf(7, { body: "x".repeat(650) })], + }, + reviewsRoute(7, []), + ]); + const truncated = await runCliTest(["pr", "list", "--fields", "body"], { + env: testModeEnv(server.url), + }); + + expect(truncated.exitCode).toBe(0); + expect(truncated.stdout).toContain("x".repeat(500)); + expect(truncated.stdout).toContain( + "... (truncated, 650 chars total - use --full to see complete body)", + ); + expect(truncated.stdout).not.toContain("x".repeat(650)); + + // --full returns the body raw, with no truncation hint. + await server.close(); + server = await startFixtureServer([ + { + method: "GET", + path: PULLS_PATH, + headers: { "X-Total-Count": "1" }, + body: [pullOf(7, { body: "x".repeat(650) })], + }, + reviewsRoute(7, []), + ]); + const full = await runCliTest(["pr", "list", "--fields", "body", "--full"], { + env: testModeEnv(server.url), + }); + + expect(full.exitCode).toBe(0); + expect(full.stdout).toContain("x".repeat(650)); + expect(full.stdout).not.toContain("truncated"); + }); + it("rejects an unknown --fields name with exit code 2", async () => { server = await startFixtureServer([]); const { stdout, exitCode } = await runCliTest(["pr", "list", "--fields", "bogus"], { diff --git a/test/search.test.ts b/test/search.test.ts index b8131ee..7143900 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -323,6 +323,46 @@ describe("search issues --fields", () => { expect(row).toContain("http://gitea.example/testowner/testrepo/issues/42"); }); + it("applies the body-truncation ruling to a match body, and --full suppresses it", async () => { + // Truncated inline at 500 chars with the hint. + server = await startFixtureServer([ + { + method: "GET", + path: SEARCH_PATH, + body: [searchIssueOf(42, { body: "x".repeat(650) })], + }, + ]); + const truncated = await runCliTest( + ["search", "issues", "login", "--fields", "body"], + { env: testModeEnv(server.url) }, + ); + + expect(truncated.exitCode).toBe(0); + expect(truncated.stdout).toContain("x".repeat(500)); + expect(truncated.stdout).toContain( + "... (truncated, 650 chars total - use --full to see complete body)", + ); + expect(truncated.stdout).not.toContain("x".repeat(650)); + + // --full returns the body raw, with no truncation hint. + await server.close(); + server = await startFixtureServer([ + { + method: "GET", + path: SEARCH_PATH, + body: [searchIssueOf(42, { body: "x".repeat(650) })], + }, + ]); + const full = await runCliTest( + ["search", "issues", "login", "--fields", "body", "--full"], + { env: testModeEnv(server.url) }, + ); + + expect(full.exitCode).toBe(0); + expect(full.stdout).toContain("x".repeat(650)); + expect(full.stdout).not.toContain("truncated"); + }); + it("rejects an unknown --fields name with VALIDATION_ERROR", async () => { server = await startFixtureServer([]);