Compare commits

...

5 Commits

Author SHA1 Message Date
040daed39d docs: rewrite bench results for the clean 4-arm snapshot
Some checks failed
CI / test (pull_request) Failing after 54s
The prior results table and narrative claimed gitea-axi posts the lowest
cost-equivalent tokens. That snapshot predated the neutral-working-dir
isolation fix, when the checkout-defaulting arms (gitea-axi, tea) drew
repo and login for free from the harness's own checkout — so gitea-axi
was implicitly pre-authenticated and looked like the winner.

On a clean run with every arm fairly credentialed and executed together,
raw REST is the cheapest on cost-equivalent tokens and leads every tier —
terse HTTP is the token floor no wrapper undercuts. gitea-axi is a clear
second overall and the lowest-cost structured interface, beating gitea-mcp
and tea on every tier at 100% success. Keep the raw-REST arm and state
this plainly rather than crown the wrapper by omitting the floor.

Numbers regenerated from bench:report over the 240-sample clean snapshot.
2026-07-17 22:35:40 -04:00
6e65032944 docs: note the bench runs built dist, not src, in CLAUDE.md gotchas 2026-07-17 18:59:48 -04:00
8653b89612 feat: show issue labels in issue view and add its --fields flag
`issue view` rendered state but never labels, and offered no way to add
them — so reading one issue's labels forced a detour through
`issue list --fields labels` and hunting the matching row. The benchmark
transcripts showed agents paying this round-trip on every labels/state
read.

Show labels by default in the detail view (a detail view should be
complete), and add a `--fields` flag mirroring `issue list` / `search`
to append assignees, closedAt, milestone, updatedAt, url on request.

Also strengthen SKILL.md against the two command-discovery round-trips
the transcripts exposed: name the required `search issues` / `search prs`
subcommand form (a bare `search "<query>"` is invalid), and point agents
straight at `issue view <n>` for a single issue's fields.

Verified live: read-issue-labels-and-state dropped from 10 turns to 4
(cache-read ~3.3x lower), the transcript reduced to three clean commands
with the search-help and issue-list round-trips gone.
2026-07-17 18:59:16 -04:00
ab59e699c1 fix: pre-authenticate the gitea-axi bench arm via its env interface
The gitea-axi arm was the only shell arm handed no credentials: the
runner set only PATH, so the agent had to reverse-engineer the tea-login
system — guessing a profile name and hunting for a config file — before
any real work, burning ~4 turns per task. Since turns drive cache-read,
the benchmark's dominant cost metric, this scaffolding gap alone inflated
gitea-axi's cost-equivalent tokens above every other arm.

Hand the arm its host and token through gitea-axi's own env interface
(GITEA_AXI_API_URL / GITEA_AXI_TOKEN), the symmetric counterpart to the
gitea-mcp server's GITEA_HOST / GITEA_ACCESS_TOKEN env: both name the
same two facts, and both still leave the agent to name the repository per
call. A shell arm now carries a credential env (empty for tea and
raw-api, which need none), merged under PATH in the driver.

Also strengthen SKILL.md so a cold agent targets and authenticates on the
first call: an explicit "Targeting and authentication" section replaces
the buried, optional-looking one-liner, spelling out that outside a
checkout `-R OWNER/NAME` plus the environment's token is all that is
needed — do not go hunting for a config file or login profile.

Verified live: create-memory-leak-issue dropped from 10 turns to 3 and
its cache-read fell ~3.8x, with the auth flailing gone from the transcript.
2026-07-17 15:10:48 -04:00
14d494afa2 feat: persist the tool transcript on every result record
Records stored only a run's token/turn totals, so an arm's turn cost —
the dominant driver of cache-read tokens — could not be diagnosed from
the store. Retain the ordered transcript of tool invocations (the exact
shell commands, MCP calls, and built-in tools the run made) on every
scored record, absent only for a hung run that produced no transcript.

The canonical TranscriptEntry shape lives on the record (result.ts); the
isolation audit's ToolUse now aliases it so the persisted and audited
shapes cannot drift.
2026-07-17 14:43:43 -04:00
12 changed files with 212 additions and 29 deletions

View File

@@ -9,6 +9,9 @@ Any commit message you write must follow the Conventional Commits specification
The `origin` remote is a self-hosted **Gitea** instance (`git.alexion.dev`), not GitHub.
The `gh` CLI does not work here.
The benchmark arms invoke the **built `dist/main.js`** (the `gitea-axi` binary on `PATH`), not the TypeScript source.
Run `npm run build` before any live `bench:run` if you want `src/` changes reflected; the bench does not run from source.
Prefer this project's own CLI for pull requests — it is the tool being built, so opening its PRs with it is the dogfood path:
`npm run build && node dist/main.js pr create --login alexion --base main --head <branch> --title <text> --body-file <path>`.
It reuses the `tea` login profiles, so it needs no separate credentials.

View File

@@ -1,28 +1,37 @@
# Benchmark harness
This directory holds the benchmark that tests gitea-axi's central claim — that it is an agent-ergonomic, low-token interface to Gitea — against the `tea` CLI, the official `gitea-mcp` server, and raw Gitea REST calls.
This run bears that out on cost: gitea-axi posts the lowest cost-equivalent tokens and the lowest imputed cost of the four tools, though `gitea-mcp` edges it slightly on accuracy.
The result is honest rather than flattering: gitea-axi is the lowest-cost of the *structured* interfaces — it beats both `tea` and `gitea-mcp` on every tier at 100% task success — but hand-rolled raw REST is cheaper still, because terse HTTP is the token floor no wrapper undercuts.
Keeping the raw-REST arm in the comparison is deliberate: a benchmark of agent-CLIs that omits it will always crown the wrapper, and this one refuses to.
## How it works
Each arm is an agent given exactly one of the four tools and nothing else, run on the same fixed model at temperature zero, so the comparison measures the tool rather than the model.
The suite is 20 tasks across four tiers — read, single-mutation, find-then-act, and multi-step — each run against a freshly seeded throwaway repository and scored deterministically by diffing the resulting repository state (or matching required facts in the agent's answer) against the seeded ground truth.
The headline metric is cost-equivalent tokens: the four token components (fresh input, cache write, cache read, output) weighted by Anthropic's published API pricing ratios, which is why an arm can spend more raw tokens yet cost less.
Every arm is credentialed the way its product is really configured — the token in its environment (`gitea-axi`, `gitea-mcp`) or in its prompt (`raw-api`), and a `tea` login for `tea` — so no arm pays a turn tax rediscovering how to authenticate.
## Results
| arm | cost-equivalent tokens | raw tokens | success | imputed cost |
| --- | ---: | ---: | ---: | ---: |
| gitea-axi | 16,921 | 68,093 | 95% | $6.20 |
| raw-api | 17,773 | 55,631 | 95% | $6.64 |
| gitea-mcp | 17,898 | 60,028 | 97% | $6.82 |
| tea | 20,505 | 80,702 | 90% | $7.25 |
| arm | cost-equivalent tokens | raw tokens | turns | success | imputed cost |
| --- | ---: | ---: | ---: | ---: | ---: |
| raw-api | 16,971 | 52,586 | 4.3 | 100% | ~$0.11 |
| gitea-axi | 19,240 | 81,067 | 6.0 | 100% | ~$0.12 |
| tea | 20,568 | 82,188 | 6.2 | 97% | ~$0.12 |
| gitea-mcp | 21,803 | 79,961 | 5.7 | 100% | ~$0.14 |
All four arms completed the full matrix — 20 of 20 tasks each, at the reporting floor.
gitea-axi wins on cost-equivalent tokens and on real imputed cost even though it does not use the fewest raw tokens: its interactions are output-light, and output is the most expensive component (weighted 5×), so its compact answers beat arms that emit more.
gitea-mcp is the most accurate at 97% against gitea-axi's 95%, so the two leaders trade a small accuracy edge for a clear cost lead.
All four arms completed the full matrix — 20 of 20 tasks each, at the reporting floor — and success is near-perfect: only `tea` slips, to 89% on find-then-act, while the other three pass every run.
By tier, the read tasks are the hardest for every arm (7583% success) — exact-answer reads, not mutations, are where correctness slips.
tea is the outlier on find-then-act, dropping to 78% success at about 1.7× the cost-equivalent tokens of the other three arms.
Raw REST posts the lowest cost-equivalent tokens and leads every tier.
It is direct HTTP with the token in the request header, so it takes the fewest turns (4.3) and reads the least cached context, and no higher-level tool beats that on tokens alone.
This is the honest ceiling, and the reason gitea-axi does not claim the cost crown outright.
_Snapshot: 2026-07-17 — 4 arms × 20 tasks × 3 trials each (240 samples), a single run against one live Gitea host; imputed cost is Anthropic API-priced._
gitea-axi is a clear second overall and the cheapest of the structured tools: it undercuts the official `gitea-mcp` server and the `tea` CLI on every tier, at 100% success, with the lowest output-token count of any arm.
Note the split between raw and cost-equivalent tokens — gitea-axi spends more raw tokens than `gitea-mcp` yet costs less, because output is weighted 5× and gitea-axi's answers are compact.
By tier, raw REST's edge is widest on reads (10,921 vs gitea-axi's 14,415) — a read is one HTTP request for curl, where a CLI still spends a turn or two — and narrows on multi-step (24,348 vs 26,963), where the work itself dominates and interface overhead matters less.
Cost parity on the scored suite also understates gitea-axi, because the suite is the subset every arm can do at all.
The bonus table records capability-asymmetric operations — full-text issue search, rendering a PR's diff and checks, issue dependencies — that gitea-axi handles directly and raw REST has no first-class equivalent for.
_Snapshot: 2026-07-17 — 4 arms × 20 tasks × 3 trials each (240 samples), a single clean run with all four arms executed together against one live Gitea host; imputed cost is the mean per-task Anthropic-API-priced dollar cost._

View File

@@ -112,6 +112,32 @@ describe("buildArm", () => {
expect(envValues).toContain("s3cr3t-token");
});
// Behavior: the gitea-axi arm's shell is handed a credential environment
// carrying the host and token from the shared access, so its tool is
// pre-authenticated without the agent having to discover credentials
// (benchmark-harness spec, "Scaffolding"). The access here is built from
// independent literals; shell.env must deep-equal exactly the two facts echoed
// back under their env-var names (host→GITEA_AXI_API_URL, token→GITEA_AXI_TOKEN),
// and nothing else. The keys and mapping are fixed by gitea-axi's own env
// contract, not recomputed from arm.ts.
it("gives the gitea-axi arm's shell a credential env with the shared host URL and token", () => {
const preAuthed: SharedContext = {
coords: { owner: "acme", repo: "bench-xyz" },
access: { apiUrl: "https://git.example.test", token: "tok-abc123" },
};
const definition = buildArm("gitea-axi", preAuthed, { binRoot, locate });
const shell = definition.shell;
expect(shell).not.toBeNull();
if (shell === null) return;
expect(shell.env).toEqual({
GITEA_AXI_API_URL: "https://git.example.test",
GITEA_AXI_TOKEN: "tok-abc123",
});
});
// Behavior: each non-MCP arm's tool/PATH configuration comes from the guard and
// exposes only that arm's allowed binary (benchmark-harness spec, "Tool
// isolation" / ADR 0016). The (arm, binary) pairs are independent literals —

View File

@@ -40,6 +40,17 @@ export interface ArmShell {
path: string;
/** The authoritative tool-isolation guard, bound to this arm. */
guard: (command: string) => GuardDecision;
/**
* Credential environment the arm's tool is pre-configured with, merged into
* the agent's shell environment on top of {@link path}. This keeps the arms
* symmetric on authentication: every arm is handed its host and token the way
* its product is really configured, so none pays a turn tax rediscovering how
* to authenticate. The gitea-mcp arm gets the equivalent through its MCP
* server's env; raw-api uses the token stated in its prompt directly; the
* gitea-axi arm is configured through its own env interface here. Empty for an
* arm that needs no ambient credentials.
*/
env: Record<string, string>;
}
/**
@@ -172,7 +183,7 @@ function mcpAttachment(context: SharedContext): ArmMcp {
* gitea-mcp arm has no shell binary (`provisionArmBin` exposes nothing for it),
* so this returns null there and the arm reaches Gitea through its MCP tools.
*/
function buildShell(arm: Arm, options: BuildArmOptions): ArmShell | null {
function buildShell(arm: Arm, context: SharedContext, options: BuildArmOptions): ArmShell | null {
if (arm === "gitea-mcp") {
return null;
}
@@ -183,16 +194,37 @@ function buildShell(arm: Arm, options: BuildArmOptions): ArmShell | null {
binDir,
path: ambient === "" ? binDir : `${binDir}${delimiter}${ambient}`,
guard: (command) => guardCommand(arm, command),
env: shellEnv(arm, context),
};
}
/**
* The credential environment a shell arm's tool is pre-configured with. The
* gitea-axi arm is handed its host and token through its own env interface
* (`GITEA_AXI_API_URL` / `GITEA_AXI_TOKEN`), the symmetric counterpart to the
* gitea-mcp arm's server env: both name the same host and token, and both leave
* the agent to name the repository per call (gitea-axi via `-R`, gitea-mcp via
* each tool's arguments). The tea and raw-api arms need no ambient credentials —
* raw-api uses the token stated in its prompt directly in each request, and tea
* resolves its own login store — so their env is empty.
*/
function shellEnv(arm: Arm, context: SharedContext): Record<string, string> {
if (arm === "gitea-axi") {
return {
GITEA_AXI_API_URL: context.access.apiUrl,
GITEA_AXI_TOKEN: context.access.token,
};
}
return {};
}
/** Assemble the single arm definition the runner consumes for the given arm. */
export function buildArm(arm: Arm, context: SharedContext, options: BuildArmOptions): ArmDefinition {
const systemPrompt = `${basePrompt(context)}\n\n${armBootstrap(arm, context, options)}`;
return {
arm,
systemPrompt,
shell: buildShell(arm, options),
shell: buildShell(arm, context, options),
mcp: arm === "gitea-mcp" ? mcpAttachment(context) : null,
};
}

View File

@@ -12,17 +12,17 @@
// runner (runner.ts) drives the run and feeds the transcript here.
import type { ArmDefinition } from "./arm.js";
import type { TranscriptEntry } from "./result.js";
/**
* One tool invocation recorded in the agent's transcript, reduced to what the
* isolation audit needs. `shell` is a proposed shell command; `mcp` is a call to
* an attached MCP server's tool; `other` is a built-in, non-Gitea-reaching tool
* (file read/edit and the like) that carries no isolation risk.
* (file read/edit and the like) that carries no isolation risk. This is the same
* shape the record persists ({@link TranscriptEntry}); the audit and the record
* share one type so they cannot drift.
*/
export type ToolUse =
| { kind: "shell"; command: string }
| { kind: "mcp"; server: string; tool: string }
| { kind: "other"; name: string };
export type ToolUse = TranscriptEntry;
/**
* The audit's verdict. On a leak it carries a human-readable reason per foreign

View File

@@ -45,6 +45,19 @@ export type FailureTag = "incorrect" | "confused" | "hung";
/** The pass/fail outcome of a run, tagged with the failure mode when it fails. */
export type Outcome = { pass: true } | { pass: false; failure: FailureTag };
/**
* One tool invocation as it is recorded in a run's transcript, in the order it
* executed. This is the canonical shape the harness both audits for isolation
* (see audit.ts, whose `ToolUse` aliases this) and persists on the record for
* diagnosis. A `shell` entry keeps the exact command line the agent ran; an `mcp`
* entry names the server and tool it called; `other` names a built-in tool that
* reaches no Gitea channel.
*/
export type TranscriptEntry =
| { kind: "shell"; command: string }
| { kind: "mcp"; server: string; tool: string }
| { kind: "other"; name: string };
/**
* One completed `(arm, task, trial)` run. Carries the metrics the headline and
* supporting views are computed from, plus the tags those views group by.
@@ -81,6 +94,15 @@ export interface ResultRecord {
* scored by diffing repository state and have no agent report to record.
*/
report?: string;
/**
* The ordered transcript of tool invocations the run made, retained on every
* scored run so its turn cost is diagnosable directly from the record — the
* exact command sequence, which is how an arm's turn count (the dominant driver
* of cache-read tokens) is explained. Absent only for a hung run, which
* produced no completed transcript to record.
*/
transcript?: TranscriptEntry[];
}
/**

View File

@@ -241,6 +241,12 @@ describe("runCell", () => {
expect(sample.imputedCostUsd).toBe(DRIVER_COST);
expect(sample.outcome).toEqual({ pass: true });
// The recorded sample carries the run's tool transcript — the exact ordered
// sequence of tool invocations the driver reported — so the turn's cost is
// diagnosable directly from the record. The expected value is the literal the
// fake driver planted, deep-equal and in order, not recomputed from runner.ts.
expect(sample.transcript).toEqual([{ kind: "mcp", server: "gitea-mcp", tool: "edit_issue" }]);
// The sample carries the run's wall-clock duration; a completed run takes
// non-negative time.
expect(typeof sample.durationMs).toBe("number");

View File

@@ -14,7 +14,7 @@
import { buildArm, type ArmDefinition, type BuildArmOptions, type SharedContext } from "./arm.js";
import { auditTranscript, type ToolUse } from "./audit.js";
import { score } from "./checker.js";
import type { Arm, Outcome, ResultRecord, TokenComponents } from "./result.js";
import type { Arm, Outcome, ResultRecord, TokenComponents, TranscriptEntry } from "./result.js";
import type { RepoState, ScoringSpec } from "./scoring-spec.js";
import type { BenchAccess, RepoCoords } from "./seed.js";
import type { SampleStore } from "./store.js";
@@ -151,7 +151,7 @@ export async function runCell(input: RunCellInput): Promise<CellOutcome> {
if (result.kind === "hung") {
return recorded(
store,
makeRecord(input, NO_TOKENS, 0, 0, durationMs, { pass: false, failure: "hung" }, undefined, clock),
makeRecord(input, NO_TOKENS, 0, 0, durationMs, { pass: false, failure: "hung" }, undefined, undefined, clock),
);
}
@@ -176,7 +176,7 @@ export async function runCell(input: RunCellInput): Promise<CellOutcome> {
return recorded(
store,
makeRecord(input, run.tokens, run.turns, run.imputedCostUsd, durationMs, outcome, report, clock),
makeRecord(input, run.tokens, run.turns, run.imputedCostUsd, durationMs, outcome, report, run.transcript, clock),
);
} finally {
await host.delete(coords);
@@ -241,6 +241,7 @@ function makeRecord(
durationMs: number,
outcome: Outcome,
report: string | undefined,
transcript: TranscriptEntry[] | undefined,
clock: RunnerClock,
): ResultRecord {
return {
@@ -257,6 +258,9 @@ function makeRecord(
// Absent for mutation runs and runs with no completed report (hung); JSON
// serialization drops the key when undefined.
...(report !== undefined ? { report } : {}),
// Absent only for a hung run, which produced no transcript; JSON
// serialization drops the key when undefined.
...(transcript !== undefined ? { transcript } : {}),
};
}

View File

@@ -271,7 +271,10 @@ function buildOptions(
if (arm.shell !== null) {
// Lead the agent's PATH with the arm's curated bin directory so only its one
// allowed binary resolves by name; the guard on canUseTool is the authority.
options.env = { ...process.env, PATH: arm.shell.path };
// Layer the arm's credential env underneath so its tool is pre-authenticated
// the way its product is really configured, symmetric to the gitea-mcp
// server's env (see ArmShell.env); PATH stays last so it is never overridden.
options.env = { ...process.env, ...arm.shell.env, PATH: arm.shell.path };
}
if (arm.mcp !== null) {
options.mcpServers = { [arm.arm]: { type: "stdio", ...arm.mcp.server } };

View File

@@ -16,16 +16,29 @@ Reach for `gitea-axi` whenever a task touches a Gitea repository's issues, pull
- **Over raw Gitea API calls:** it handles auth, pagination, name-to-ID resolution, and review-decision aggregation for you, so you do not hand-roll HTTP.
- **Over improvised `git`:** for anything about issues or pull requests as entities (state, reviews, labels, comments) rather than local commits and branches.
Run it inside a Gitea checkout, or pass `-R OWNER/NAME` (and `--login <name>`) to target a repository explicitly.
## Targeting and authentication
Every command resolves two things: which repository to act on, and which credentials to authenticate with.
Get both right on the first call — they are the usual reason a command fails and has to be retried.
- **Repository.** Inside a Gitea checkout it is taken from the `origin` remote automatically.
Outside a checkout you must name it: pass `-R OWNER/NAME` on every command (or set `GITEA_AXI_REPO=OWNER/NAME` once for the session).
- **Credentials.** When the environment is pre-configured — `GITEA_AXI_TOKEN` together with `GITEA_AXI_API_URL` — authentication is automatic and you need nothing more.
Otherwise credentials come from a `tea` login: pass `--login <name>` (or set `GITEA_AXI_LOGIN=<name>`) unless the checkout's remote already selects one.
So outside a checkout with the token in the environment, `gitea-axi <command> -R OWNER/NAME …` is all you need; do not go hunting for a config file or a login profile.
## Command groups
- `issue` — list, view, create, comment on, edit, close/reopen, pin, and link issues.
- `pr` — create, view, comment on, edit, review, merge, check out, diff, and inspect the checks of pull requests.
- `label` — list, create, edit, and delete labels.
- `search` — full-text search across issues and pull requests.
- `search` — full-text search; it takes a subcommand, so search issues with `search issues "<query>"` and pull requests with `search prs "<query>"` (a bare `search "<query>"` is not valid).
- `setup` — install this skill (`setup`) and, opt-in, the SessionStart dashboard hook (`setup hooks`).
To read one issue's fields, reach straight for `issue view <number>`: it shows labels and state by default, and takes `--fields assignees,milestone,…` for the rest.
You rarely need `issue list` to answer a question about a single issue.
## Discovery
This skill is a pointer, not a command reference — the CLI is the single source of truth for its own interface.

View File

@@ -222,8 +222,11 @@ Show a single issue. Pull request numbers are rejected — use \`pr view\` inste
flags:
--comments Render every comment in full (bodies truncated at 800 chars)
--full Suppress all truncation of the issue body and comment bodies
--fields <a,b,c> Append extra fields: assignees, closedAt, milestone, updatedAt, url
--help Show this help
Labels are shown by default; use --fields to add assignees, milestone, and more.
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
@@ -479,19 +482,32 @@ const ISSUE_VIEW_FIELDS: FieldDef<Issue>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
joined("labels", "labels", "name"),
pluck("author", "user.login"),
relativeTimeField("created", "created_at"),
];
// Appended to the default view fields on request via `--fields`, never replacing
// them. Labels and body are shown by default, so they are not offered here.
const ISSUE_VIEW_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = {
assignees: joined("assignees", "assignees", "login"),
closedAt: relativeTimeField("closedAt", "closed_at"),
milestone: pluck("milestone", "milestone.title"),
updatedAt: relativeTimeField("updatedAt", "updated_at"),
url: pluck("url", "html_url"),
};
interface IssueDetailOptions {
host: string;
full: boolean;
withComments: boolean;
now: Date;
/** Extra fields selected via `--fields`, appended after the defaults. */
extraFields: FieldDef<Issue>[];
}
function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record<string, unknown> {
const row = extractRow(issue, ISSUE_VIEW_FIELDS, {
const row = extractRow(issue, [...ISSUE_VIEW_FIELDS, ...options.extraFields], {
now: options.now,
host: options.host,
full: options.full,
@@ -539,12 +555,21 @@ async function issueView(deps: CliDeps, args: string[]): Promise<string> {
}
const { flags, positionals } = parseFlags(
args,
{ "--comments": { takesValue: false }, "--full": { takesValue: false } },
{
"--comments": { takesValue: false },
"--full": { takesValue: false },
"--fields": { takesValue: true },
},
"issue view",
);
const number = parsePositionalNumber(positionals, "issue view", "issue");
const full = flags["--full"] === true;
const withComments = flags["--comments"] === true;
const extraFields = selectExtraFields(
flagValue(flags, "--fields"),
ISSUE_VIEW_EXTRA_FIELDS,
"issue view",
);
const context = await resolveRepoContext(deps);
const api = createClient(context);
@@ -557,7 +582,7 @@ async function issueView(deps: CliDeps, args: string[]): Promise<string> {
}
const now = new Date();
const item = buildIssueDetail(issue, { host: context.host, full, withComments, now });
const item = buildIssueDetail(issue, { host: context.host, full, withComments, now, extraFields });
const blocks: DetailBlock[] = [];
if (withComments) {

View File

@@ -47,6 +47,46 @@ describe("issue view", () => {
expect(stdout).toContain("comment_count: 3 — use --comments to see full comments");
});
it("renders the issue's labels comma-joined by default, with no flag", async () => {
server = await startFixtureServer([
{
method: "GET",
path: ISSUE_PATH,
body: issueBody({ labels: [{ name: "bug" }, { name: "regression" }] }),
},
]);
const { stdout, exitCode } = await runCliTest(["issue", "view", "42"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
// TOON-quoted because the joined value contains a comma.
expect(stdout).toContain('labels: "bug, regression"');
});
it("appends named extra fields with --fields on top of the default fields", async () => {
server = await startFixtureServer([
{
method: "GET",
path: ISSUE_PATH,
body: issueBody({
assignees: [{ login: "alexion" }],
milestone: { title: "v2.0" },
}),
},
]);
const { stdout, exitCode } = await runCliTest(
["issue", "view", "42", "--fields", "assignees,milestone"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
// Default fields are still present; the extra fields are appended.
expect(stdout).toContain("state: open");
expect(stdout).toContain("assignees: alexion");
expect(stdout).toContain("milestone: v2.0");
});
it("renders comment_count: 0 when there are no comments", async () => {
server = await startFixtureServer([
{ method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 0 }) },