feat: truncate --fields body uniformly (task 0021)
All checks were successful
CI / test (pull_request) Successful in 49s
CI / test (push) Successful in 49s

Rule that the `body` extra field truncates at 500 chars like `issue view`,
resolving the spec's Principle 3 / Command Surface contradiction. Route the
`body` extractor through a new `truncatedBody()` FieldDef so `issue list`,
`issue create`, `pr list`, and `search` all present it exactly as the detail
views do, and add a `--full` flag to each to suppress truncation, keeping the
inline hint's "use --full" promise honest.
This commit was merged in pull request #21.
This commit is contained in:
2026-07-14 12:23:24 -04:00
parent ed87f023cb
commit b5955cd7b8
13 changed files with 277 additions and 23 deletions

View File

@@ -145,7 +145,7 @@ async function prRowsWithReview(
): Promise<Record<string, unknown>[]> {
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<s
const now = new Date();
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 [
`repo: ${context.owner}/${context.name}`,

View File

@@ -14,6 +14,7 @@ import {
pluck,
relativeTimeField,
selectExtraFields,
truncatedBody,
type FieldDef,
} from "../fields.js";
import {
@@ -190,6 +191,7 @@ flags:
--label <name> Apply a label by name (repeatable, case-insensitive)
--milestone <name> Assign a milestone by name (case-insensitive)
--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
global flags:
@@ -240,6 +242,7 @@ flags:
--sort <created|updated|comments> Sort descending (client-side)
--limit <n> Maximum number of issues to return (default: 30)
--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
global flags:
@@ -257,7 +260,7 @@ const ISSUE_LIST_FIELDS: FieldDef<Issue>[] = [
// Appended to the defaults on request via `--fields`, never replacing them.
const ISSUE_LIST_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = {
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<string> {
"--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<string> {
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<string> {
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<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 ?? "";
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<string, FieldDef<Issue>> = {
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<string> {
@@ -601,6 +610,7 @@ async function issueCreate(deps: CliDeps, args: string[]): Promise<string> {
"--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<string> {
]);
}
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<string> {
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,

View File

@@ -124,7 +124,9 @@ async function labelList(deps: CliDeps, args: string[]): Promise<string> {
}
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,

View File

@@ -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 <n> Maximum number of pull requests to return (default: 30)
--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
global flags:
@@ -343,7 +345,7 @@ const PR_LIST_FIELDS: FieldDef<PullRequest>[] = [
// Appended to the defaults on request via `--fields`, never replacing them.
const PR_LIST_EXTRA_FIELDS: Record<string, FieldDef<PullRequest>> = {
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<string> {
"--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<string> {
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<string> {
);
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<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.
row.merged = pull.merged ? relativeTime(pull.merged_at, options.now) : "no";
row.checks = options.checksSummary;

View File

@@ -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 <a,b> Filter by label name (comma-separated)
--limit <n> Maximum number of matches to return (default: 30)
--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
global flags:
@@ -60,6 +62,7 @@ flags:
--label <a,b> Filter by label name (comma-separated)
--limit <n> Maximum number of matches to return (default: 30)
--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
global flags:
@@ -82,7 +85,7 @@ const SEARCH_FIELDS: FieldDef<Issue>[] = [
// 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<string, FieldDef<Issue>> = {
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,

View File

@@ -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<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> {
return {
name,