feat: truncate --fields body uniformly (task 0021) #21

Merged
alexion merged 1 commits from task-0021-body-field-truncation-ruling into main 2026-07-14 14:27:24 -04:00
13 changed files with 277 additions and 23 deletions

View File

@@ -15,5 +15,7 @@ Simpler, portable, consistent with the stated reference.
## Consequences ## Consequences
`issue view` and `pr view` both accept `--full` to return untruncated body. `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. `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. The `full_content` field name and temp-file design from the spec draft are dropped entirely.

View File

@@ -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). 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. 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). 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. No `type` field in output — Gitea has no issue types.
**`issue view <n> [flags]`** **`issue view <n> [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). `--project` is excluded (Gitea has no projects REST API).
`--type` is excluded (Gitea has no issue types). `--type` is excluded (Gitea has no issue types).
Output schema: `issue: { number, title, state, url }` (where `url` = `html_url`). 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 <n> [flags]`** **`issue edit <n> [flags]`**
`--title`; `--title`;
@@ -209,7 +209,7 @@ No gh-axi equivalent.
`--fields <a,b,c>`. `--fields <a,b,c>`.
`--search` is explicitly forbidden (VALIDATION_ERROR; help: `` Use `gitea-axi search prs "<query>"` for full-text search ``). `--search` is explicitly forbidden (VALIDATION_ERROR; help: `` Use `gitea-axi search prs "<query>"` 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). 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). `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). 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 `<query>` is required (`VALIDATION_ERROR` if missing). The positional `<query>` is required (`VALIDATION_ERROR` if missing).
Endpoint: `GET /repos/issues/search` with `q=<query>`, `type=issues` or `type=pulls`, and `owner=<owner>`. Endpoint: `GET /repos/issues/search` with `q=<query>`, `type=issues` or `type=pulls`, and `owner=<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. 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 <open|closed|all>` (default open); `--label <name>` (API-supported — comma-separated names); `--limit <n>` (default 30); `--fields <a,b,c>`. Flags: `--state <open|closed|all>` (default open); `--label <name>` (API-supported — comma-separated names); `--limit <n>` (default 30); `--fields <a,b,c>` (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`. 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. Output blocks: `issues:` / `pull_requests:`, matching the list commands.
Empty state: standard `<noun>[0]: (none)`. Empty state: standard `<noun>[0]: (none)`.
@@ -464,11 +464,15 @@ Field extraction uses a `FieldDef` type system with typed extractors: nested plu
**Principle 3 — Content truncation.** **Principle 3 — Content truncation.**
Body text is truncated at **500 characters** in all contexts (list and detail alike), matching gh-axi. 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. 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. 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 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. 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 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**. 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://<host>/<owner>/<repo>/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. `cleanBody` normalizes Gitea issue/PR URLs using the detected hostname (`https://<host>/<owner>/<repo>/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. 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.

View File

@@ -23,7 +23,26 @@ The ruling is verified at the fixture/unit tier only: body truncation is determi
## Acceptance criteria ## Acceptance criteria
- [ ] The spec no longer contradicts itself: Principle 3 and the Command Surface's `body` field agree, with the reasoning recorded - [x] 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 - [x] `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) - [x] 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] 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.

View File

@@ -145,7 +145,7 @@ async function prRowsWithReview(
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const decisions = await pullDecisions(api, context, pulls); const decisions = await pullDecisions(api, context, pulls);
return pulls.map((pull, index) => { 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]; row.review = decisions[index];
return row; return row;
}); });
@@ -187,7 +187,9 @@ async function shortDashboard(api: GiteaClient, context: RepoContext): Promise<s
const now = new Date(); const now = new Date();
const prRows = await prRowsWithReview(api, context, openPulls.pulls, SHORT_PR_FIELDS, now); const prRows = await prRowsWithReview(api, context, openPulls.pulls, SHORT_PR_FIELDS, now);
const issueRows = issues.map((issue) => extractRow(issue, SHORT_ISSUE_FIELDS, { now })); const issueRows = issues.map((issue) =>
extractRow(issue, SHORT_ISSUE_FIELDS, { now, host: context.host, full: false }),
);
return [ return [
`repo: ${context.owner}/${context.name}`, `repo: ${context.owner}/${context.name}`,

View File

@@ -14,6 +14,7 @@ import {
pluck, pluck,
relativeTimeField, relativeTimeField,
selectExtraFields, selectExtraFields,
truncatedBody,
type FieldDef, type FieldDef,
} from "../fields.js"; } from "../fields.js";
import { import {
@@ -190,6 +191,7 @@ flags:
--label <name> Apply a label by name (repeatable, case-insensitive) --label <name> Apply a label by name (repeatable, case-insensitive)
--milestone <name> Assign a milestone by name (case-insensitive) --milestone <name> Assign a milestone by name (case-insensitive)
--fields <a,b,c> Append extra fields: labels, assignees, milestone, body --fields <a,b,c> Append extra fields: labels, assignees, milestone, body
--full Show the body field raw, without 500-char truncation
--help Show this help --help Show this help
global flags: global flags:
@@ -240,6 +242,7 @@ flags:
--sort <created|updated|comments> Sort descending (client-side) --sort <created|updated|comments> Sort descending (client-side)
--limit <n> Maximum number of issues to return (default: 30) --limit <n> Maximum number of issues to return (default: 30)
--fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url --fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url
--full Show the body field raw, without 500-char truncation
--help Show this help --help Show this help
global flags: global flags:
@@ -257,7 +260,7 @@ const ISSUE_LIST_FIELDS: FieldDef<Issue>[] = [
// Appended to the defaults on request via `--fields`, never replacing them. // Appended to the defaults on request via `--fields`, never replacing them.
const ISSUE_LIST_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = { const ISSUE_LIST_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = {
body: pluck("body"), body: truncatedBody("body"),
closedAt: relativeTimeField("closedAt", "closed_at"), closedAt: relativeTimeField("closedAt", "closed_at"),
labels: joined("labels", "labels", "name"), labels: joined("labels", "labels", "name"),
milestone: pluck("milestone", "milestone.title"), milestone: pluck("milestone", "milestone.title"),
@@ -394,6 +397,7 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
"--sort": { takesValue: true }, "--sort": { takesValue: true },
"--limit": { takesValue: true }, "--limit": { takesValue: true },
"--fields": { takesValue: true }, "--fields": { takesValue: true },
"--full": { takesValue: false },
}, },
"issue list", "issue list",
); );
@@ -407,6 +411,7 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
const state = parseState(flags["--state"]); const state = parseState(flags["--state"]);
const sort = parseSort(flags["--sort"]); const sort = parseSort(flags["--sort"]);
const limit = parseLimit(flags["--limit"]); const limit = parseLimit(flags["--limit"]);
const full = flags["--full"] === true;
const extraFields = selectExtraFields( const extraFields = selectExtraFields(
flagValue(flags, "--fields"), flagValue(flags, "--fields"),
ISSUE_LIST_EXTRA_FIELDS, ISSUE_LIST_EXTRA_FIELDS,
@@ -450,7 +455,7 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
const now = new Date(); const now = new Date();
const rows = issues.map((issue) => 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({ return renderList({
noun: "issues", noun: "issues",
@@ -478,7 +483,11 @@ interface IssueDetailOptions {
} }
function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record<string, unknown> { function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record<string, unknown> {
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 ?? ""; const body = issue.body ?? "";
row.body = options.full ? body : truncateBody(body, BODY_TRUNCATE_LIMIT, options.host); row.body = options.full ? body : truncateBody(body, BODY_TRUNCATE_LIMIT, options.host);
if (!options.withComments) { if (!options.withComments) {
@@ -584,7 +593,7 @@ const ISSUE_CREATE_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = {
labels: joined("labels", "labels", "name"), labels: joined("labels", "labels", "name"),
assignees: joined("assignees", "assignees", "login"), assignees: joined("assignees", "assignees", "login"),
milestone: pluck("milestone", "milestone.title"), milestone: pluck("milestone", "milestone.title"),
body: pluck("body"), body: truncatedBody("body"),
}; };
async function issueCreate(deps: CliDeps, args: string[]): Promise<string> { async function issueCreate(deps: CliDeps, args: string[]): Promise<string> {
@@ -601,6 +610,7 @@ async function issueCreate(deps: CliDeps, args: string[]): Promise<string> {
"--label": { takesValue: true, repeatable: true }, "--label": { takesValue: true, repeatable: true },
"--milestone": { takesValue: true }, "--milestone": { takesValue: true },
"--fields": { takesValue: true }, "--fields": { takesValue: true },
"--full": { takesValue: false },
}, },
"issue create", "issue create",
); );
@@ -621,6 +631,7 @@ async function issueCreate(deps: CliDeps, args: string[]): Promise<string> {
]); ]);
} }
const body = resolveBodySource(deps, flags, "issue create"); const body = resolveBodySource(deps, flags, "issue create");
const full = flags["--full"] === true;
const extraFields = selectExtraFields( const extraFields = selectExtraFields(
flagValue(flags, "--fields"), flagValue(flags, "--fields"),
ISSUE_CREATE_EXTRA_FIELDS, ISSUE_CREATE_EXTRA_FIELDS,
@@ -656,7 +667,11 @@ async function issueCreate(deps: CliDeps, args: string[]): Promise<string> {
throw classifyHttpError(error); 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({ return renderDetail({
noun: "issue", noun: "issue",
item, item,
@@ -1170,7 +1185,9 @@ async function listRelationships(
const issues = await fetchRelationships(api, context, group, number); const issues = await fetchRelationships(api, context, group, number);
const now = new Date(); 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({ return renderList({
noun: group.listNoun, noun: group.listNoun,
rows, rows,

View File

@@ -124,7 +124,9 @@ async function labelList(deps: CliDeps, args: string[]): Promise<string> {
} }
const now = new Date(); 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({ return renderList({
noun: "labels", noun: "labels",
rows, rows,

View File

@@ -26,6 +26,7 @@ import {
pluck, pluck,
relativeTimeField, relativeTimeField,
selectExtraFields, selectExtraFields,
truncatedBody,
type FieldDef, type FieldDef,
} from "../fields.js"; } from "../fields.js";
import { import {
@@ -228,6 +229,7 @@ flags:
Sort order (passed to the API) Sort order (passed to the API)
--limit <n> Maximum number of pull requests to return (default: 30) --limit <n> Maximum number of pull requests to return (default: 30)
--fields <a,b,c> Append extra fields: body, createdAt, labels, milestone, mergedAt, url --fields <a,b,c> Append extra fields: body, createdAt, labels, milestone, mergedAt, url
--full Show the body field raw, without 500-char truncation
--help Show this help --help Show this help
global flags: global flags:
@@ -343,7 +345,7 @@ const PR_LIST_FIELDS: FieldDef<PullRequest>[] = [
// Appended to the defaults on request via `--fields`, never replacing them. // Appended to the defaults on request via `--fields`, never replacing them.
const PR_LIST_EXTRA_FIELDS: Record<string, FieldDef<PullRequest>> = { const PR_LIST_EXTRA_FIELDS: Record<string, FieldDef<PullRequest>> = {
body: pluck("body"), body: truncatedBody("body"),
createdAt: relativeTimeField("created", "created_at"), createdAt: relativeTimeField("created", "created_at"),
labels: joined("labels", "labels", "name"), labels: joined("labels", "labels", "name"),
milestone: pluck("milestone", "milestone.title"), milestone: pluck("milestone", "milestone.title"),
@@ -537,6 +539,7 @@ async function prList(deps: CliDeps, args: string[]): Promise<string> {
"--sort": { takesValue: true }, "--sort": { takesValue: true },
"--limit": { takesValue: true }, "--limit": { takesValue: true },
"--fields": { takesValue: true }, "--fields": { takesValue: true },
"--full": { takesValue: false },
}, },
"pr list", "pr list",
); );
@@ -550,6 +553,7 @@ async function prList(deps: CliDeps, args: string[]): Promise<string> {
const state = parsePrState(flags["--state"]); const state = parsePrState(flags["--state"]);
const sort = parsePrSort(flags["--sort"]); const sort = parsePrSort(flags["--sort"]);
const limit = parsePrLimit(flags["--limit"]); const limit = parsePrLimit(flags["--limit"]);
const full = flags["--full"] === true;
const extraFields = selectExtraFields( const extraFields = selectExtraFields(
flagValue(flags, "--fields"), flagValue(flags, "--fields"),
PR_LIST_EXTRA_FIELDS, PR_LIST_EXTRA_FIELDS,
@@ -609,10 +613,11 @@ async function prList(deps: CliDeps, args: string[]): Promise<string> {
); );
const now = new Date(); const now = new Date();
const extractContext = { now, host: context.host, full };
const rows = pulls.map((pull, index) => { 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]; row.review = decisions[index];
Object.assign(row, extractRow(pull, extraFields, { now })); Object.assign(row, extractRow(pull, extraFields, extractContext));
return row; return row;
}); });
@@ -745,7 +750,11 @@ interface PrDetailOptions {
} }
function buildPrDetail(pull: PullRequest, options: PrDetailOptions): Record<string, unknown> { function buildPrDetail(pull: PullRequest, options: PrDetailOptions): Record<string, unknown> {
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. // 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.merged = pull.merged ? relativeTime(pull.merged_at, options.now) : "no";
row.checks = options.checksSummary; row.checks = options.checksSummary;

View File

@@ -10,6 +10,7 @@ import {
pluck, pluck,
relativeTimeField, relativeTimeField,
selectExtraFields, selectExtraFields,
truncatedBody,
type FieldDef, type FieldDef,
} from "../fields.js"; } from "../fields.js";
import { flagValue, parseEnumFlag, parseFlags, parsePositiveInt } from "../flags.js"; import { flagValue, parseEnumFlag, parseFlags, parsePositiveInt } from "../flags.js";
@@ -41,6 +42,7 @@ flags:
--label <a,b> Filter by label name (comma-separated) --label <a,b> Filter by label name (comma-separated)
--limit <n> Maximum number of matches to return (default: 30) --limit <n> Maximum number of matches to return (default: 30)
--fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url --fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url
--full Show the body field raw, without 500-char truncation
--help Show this help --help Show this help
global flags: global flags:
@@ -60,6 +62,7 @@ flags:
--label <a,b> Filter by label name (comma-separated) --label <a,b> Filter by label name (comma-separated)
--limit <n> Maximum number of matches to return (default: 30) --limit <n> Maximum number of matches to return (default: 30)
--fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url --fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url
--full Show the body field raw, without 500-char truncation
--help Show this help --help Show this help
global flags: global flags:
@@ -82,7 +85,7 @@ const SEARCH_FIELDS: FieldDef<Issue>[] = [
// Appended to the locator schema on request via `--fields`, never replacing it. // 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. // Results are Issue-shaped, so this mirrors `issue list`'s extra-field vocabulary.
const SEARCH_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = { const SEARCH_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = {
body: pluck("body"), body: truncatedBody("body"),
closedAt: relativeTimeField("closedAt", "closed_at"), closedAt: relativeTimeField("closedAt", "closed_at"),
labels: joined("labels", "labels", "name"), labels: joined("labels", "labels", "name"),
milestone: pluck("milestone", "milestone.title"), milestone: pluck("milestone", "milestone.title"),
@@ -155,6 +158,7 @@ async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promi
"--label": { takesValue: true }, "--label": { takesValue: true },
"--limit": { takesValue: true }, "--limit": { takesValue: true },
"--fields": { takesValue: true }, "--fields": { takesValue: true },
"--full": { takesValue: false },
}, },
kind.command, kind.command,
); );
@@ -175,6 +179,7 @@ async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promi
const limitFlag = flags["--limit"]; const limitFlag = flags["--limit"];
const limit = const limit =
limitFlag === undefined ? DEFAULT_LIMIT : parsePositiveInt(limitFlag, "--limit", helpSuggestion); limitFlag === undefined ? DEFAULT_LIMIT : parsePositiveInt(limitFlag, "--limit", helpSuggestion);
const full = flags["--full"] === true;
const extraFields = selectExtraFields( const extraFields = selectExtraFields(
flagValue(flags, "--fields"), flagValue(flags, "--fields"),
SEARCH_EXTRA_FIELDS, SEARCH_EXTRA_FIELDS,
@@ -208,7 +213,9 @@ async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promi
const total = matches.length; const total = matches.length;
const shown = matches.slice(0, limit); const shown = matches.slice(0, limit);
const now = new Date(); 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({ return renderList({
noun: kind.noun, noun: kind.noun,

View File

@@ -1,8 +1,13 @@
import { BODY_TRUNCATE_LIMIT, truncateBody } from "./body.js";
import { axiError } from "./errors.js"; import { axiError } from "./errors.js";
import { relativeTime } from "./time.js"; import { relativeTime } from "./time.js";
export interface ExtractContext { export interface ExtractContext {
now: Date; 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<T> { export interface FieldDef<T> {
@@ -66,6 +71,26 @@ export function joined<T>(name: string, path: string, key: string): FieldDef<T>
}; };
} }
/**
* 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<T>(name: string, path: string = name): FieldDef<T> {
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<T>(name: string, path: string): FieldDef<T> { export function relativeTimeField<T>(name: string, path: string): FieldDef<T> {
return { return {
name, name,

View File

@@ -309,6 +309,48 @@ describe("issue create", () => {
expect(stdout).toContain("number: 7"); 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 () => { it("rejects an unknown --fields name with VALIDATION_ERROR", async () => {
server = await startFixtureServer([]); server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest( const { stdout, exitCode } = await runCliTest(

View File

@@ -469,6 +469,49 @@ describe("issue list --fields", () => {
expect(row).toMatch(/\d+(mo|[smhdy]) ago/); 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 () => { it("rejects an unknown --fields name with exit code 2", async () => {
server = await startFixtureServer([]); server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest( const { stdout, exitCode } = await runCliTest(

View File

@@ -463,6 +463,48 @@ describe("pr list --fields", () => {
expect(row).toMatch(/\d+(mo|[smhdy]) ago/); 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 () => { it("rejects an unknown --fields name with exit code 2", async () => {
server = await startFixtureServer([]); server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "list", "--fields", "bogus"], { const { stdout, exitCode } = await runCliTest(["pr", "list", "--fields", "bogus"], {

View File

@@ -323,6 +323,46 @@ describe("search issues --fields", () => {
expect(row).toContain("http://gitea.example/testowner/testrepo/issues/42"); 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 () => { it("rejects an unknown --fields name with VALIDATION_ERROR", async () => {
server = await startFixtureServer([]); server = await startFixtureServer([]);