`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.
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
|
import { runCliTest, testModeEnv } from "./harness.js";
|
|
|
|
const ISSUE_PATH = "/api/v1/repos/testowner/testrepo/issues/42";
|
|
const COMMENTS_PATH = "/api/v1/repos/testowner/testrepo/issues/42/comments";
|
|
|
|
let server: FixtureServer;
|
|
|
|
afterEach(async () => {
|
|
await server.close();
|
|
});
|
|
|
|
function issueBody(fields: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
number: 42,
|
|
title: "Fix the thing",
|
|
state: "open",
|
|
user: { login: "alexion" },
|
|
created_at: "2026-07-01T00:00:00Z",
|
|
comments: 0,
|
|
body: "",
|
|
...fields,
|
|
};
|
|
}
|
|
|
|
describe("issue view", () => {
|
|
it("renders the default detail fields plus comment_count", async () => {
|
|
server = await startFixtureServer([
|
|
{
|
|
method: "GET",
|
|
path: ISSUE_PATH,
|
|
body: issueBody({ comments: 3, body: "A short body." }),
|
|
},
|
|
]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view", "42"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("issue:");
|
|
expect(stdout).toContain("number: 42");
|
|
expect(stdout).toContain("title: Fix the thing");
|
|
expect(stdout).toContain("state: open");
|
|
expect(stdout).toContain("author: alexion");
|
|
expect(stdout).toContain("body: A short body.");
|
|
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 }) },
|
|
]);
|
|
const { stdout } = await runCliTest(["issue", "view", "42"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(stdout).toContain("comment_count: 0");
|
|
expect(stdout).not.toContain("use --comments");
|
|
});
|
|
|
|
it("passes a body at or under 500 chars through untouched", async () => {
|
|
const body = "b".repeat(500);
|
|
server = await startFixtureServer([
|
|
{ method: "GET", path: ISSUE_PATH, body: issueBody({ body }) },
|
|
]);
|
|
const { stdout } = await runCliTest(["issue", "view", "42"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(stdout).toContain(body);
|
|
expect(stdout).not.toContain("truncated");
|
|
expect(stdout).not.toContain("cleaned");
|
|
});
|
|
|
|
it("cleans then truncates a body over 500 chars, with the inline hint", async () => {
|
|
const body = `See http://127.0.0.1/o/r/issues/7 for context. ${"y".repeat(600)}`;
|
|
server = await startFixtureServer([
|
|
{ method: "GET", path: ISSUE_PATH, body: issueBody({ body }) },
|
|
]);
|
|
const { stdout } = await runCliTest(["issue", "view", "42"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
// cleanBody normalized the host URL before truncation kicked in.
|
|
expect(stdout).toContain("Issue#7");
|
|
expect(stdout).toContain(
|
|
`... (truncated, ${body.length} chars total - use --full to see complete body)`,
|
|
);
|
|
expect(stdout).toContain("issue view 42 --full");
|
|
});
|
|
|
|
it("returns the cleaned body with a note when cleaning brings it under 500", async () => {
|
|
const longUrl = `http://127.0.0.1/${"c".repeat(110)}`;
|
|
const body = `${"d".repeat(480)} ${longUrl}`;
|
|
server = await startFixtureServer([
|
|
{ method: "GET", path: ISSUE_PATH, body: issueBody({ body }) },
|
|
]);
|
|
const { stdout } = await runCliTest(["issue", "view", "42"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(stdout).toContain("[long URL removed]");
|
|
expect(stdout).toContain(`(cleaned, ${body.length} chars original - use --full to see original)`);
|
|
expect(stdout).not.toContain("truncated,");
|
|
});
|
|
|
|
it("suppresses body truncation with --full", async () => {
|
|
const body = "e".repeat(900);
|
|
server = await startFixtureServer([
|
|
{ method: "GET", path: ISSUE_PATH, body: issueBody({ body }) },
|
|
]);
|
|
const { stdout } = await runCliTest(["issue", "view", "42", "--full"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(stdout).toContain(body);
|
|
expect(stdout).not.toContain("truncated");
|
|
});
|
|
|
|
it("renders every comment with --comments, truncating bodies at 800 chars", async () => {
|
|
const longComment = "f".repeat(1000);
|
|
server = await startFixtureServer([
|
|
{ method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 2 }) },
|
|
{
|
|
method: "GET",
|
|
path: COMMENTS_PATH,
|
|
body: [
|
|
{ user: { login: "bob" }, created_at: "2026-07-02T00:00:00Z", body: "short reply" },
|
|
{ user: { login: "sue" }, created_at: "2026-07-03T00:00:00Z", body: longComment },
|
|
],
|
|
},
|
|
]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view", "42", "--comments"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("comments[2]");
|
|
expect(stdout).toContain("bob");
|
|
expect(stdout).toContain("short reply");
|
|
expect(stdout).toContain(
|
|
`... (truncated, ${longComment.length} chars total - use --full to see complete body)`,
|
|
);
|
|
// With --comments the redundant scalar count is replaced by the block.
|
|
expect(stdout).not.toContain("comment_count");
|
|
});
|
|
|
|
it("suppresses comment truncation with --full --comments", async () => {
|
|
const longComment = "g".repeat(1000);
|
|
server = await startFixtureServer([
|
|
{ method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 1 }) },
|
|
{
|
|
method: "GET",
|
|
path: COMMENTS_PATH,
|
|
body: [{ user: { login: "sue" }, created_at: "2026-07-03T00:00:00Z", body: longComment }],
|
|
},
|
|
]);
|
|
const { stdout } = await runCliTest(["issue", "view", "42", "--full", "--comments"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(stdout).toContain(longComment);
|
|
expect(stdout).not.toContain("truncated");
|
|
});
|
|
|
|
it("renders an explicit empty comments block when there are none", async () => {
|
|
server = await startFixtureServer([
|
|
{ method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 0 }) },
|
|
{ method: "GET", path: COMMENTS_PATH, body: [] },
|
|
]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view", "42", "--comments"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("comments[0]: (none)");
|
|
});
|
|
|
|
it("surfaces a failure to fetch comments", async () => {
|
|
server = await startFixtureServer([
|
|
{ method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 2 }) },
|
|
{ method: "GET", path: COMMENTS_PATH, status: 403, body: { message: "forbidden" } },
|
|
]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view", "42", "--comments"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout).toContain("code: FORBIDDEN");
|
|
});
|
|
|
|
it("prints help with --help without calling the API", async () => {
|
|
server = await startFixtureServer([]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view", "--help"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain("usage: gitea-axi issue view <number>");
|
|
expect(server.requests).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects a pull request number with VALIDATION_ERROR and a pr view hint", async () => {
|
|
server = await startFixtureServer([
|
|
{
|
|
method: "GET",
|
|
path: ISSUE_PATH,
|
|
body: issueBody({ pull_request: { merged: false, html_url: "http://x/pulls/42" } }),
|
|
},
|
|
]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view", "42"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
|
expect(stdout).toContain("issue #42 is a pull request");
|
|
expect(stdout).toContain("pr view 42");
|
|
});
|
|
|
|
it("reports a nonexistent issue as ISSUE_NOT_FOUND with exit 1", async () => {
|
|
server = await startFixtureServer([
|
|
{
|
|
method: "GET",
|
|
path: ISSUE_PATH,
|
|
status: 404,
|
|
body: { message: "Not Found" },
|
|
},
|
|
]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view", "42"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout).toContain("code: ISSUE_NOT_FOUND");
|
|
});
|
|
|
|
it("rejects a missing issue number with exit 2", async () => {
|
|
server = await startFixtureServer([]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
|
expect(server.requests).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects a non-numeric issue number with exit 2", async () => {
|
|
server = await startFixtureServer([]);
|
|
const { stdout, exitCode } = await runCliTest(["issue", "view", "abc"], {
|
|
env: testModeEnv(server.url),
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
|
expect(server.requests).toHaveLength(0);
|
|
});
|
|
});
|