feat: truncate --fields body uniformly (task 0021) #21
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"], {
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user