Compare commits
5 Commits
05b72f6987
...
040daed39d
| Author | SHA1 | Date | |
|---|---|---|---|
| 040daed39d | |||
| 6e65032944 | |||
| 8653b89612 | |||
| ab59e699c1 | |||
| 14d494afa2 |
@@ -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.
|
||||
|
||||
@@ -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 (75–83% 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._
|
||||
|
||||
@@ -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 —
|
||||
|
||||
36
bench/arm.ts
36
bench/arm.ts
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
Reference in New Issue
Block a user