Add gh issue list REST contract

This commit is contained in:
Codex
2026-05-20 12:27:12 +00:00
parent 688851414a
commit 4b65f713fa
5 changed files with 312 additions and 33 deletions
@@ -86,6 +86,48 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T01:00:00Z",
};
const issueList = [
{
id: 2001,
number: 35,
title: "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力",
body: "issue list body",
state: "open",
html_url: "https://github.com/pikasTech/unidesk/issues/35",
comments: 0,
user: { login: "commander" },
labels: [{ name: "cli", color: "1d76db", description: "CLI work" }],
created_at: "2026-05-20T02:00:00Z",
updated_at: "2026-05-20T03:00:00Z",
},
{
id: 2002,
number: 36,
title: "second issue",
body: "second body",
state: "open",
html_url: "https://github.com/pikasTech/unidesk/issues/36",
comments: 0,
user: { login: "runner" },
labels: [],
created_at: "2026-05-20T02:05:00Z",
updated_at: "2026-05-20T03:05:00Z",
},
{
id: 3001,
number: 37,
title: "pull request should be filtered",
body: "pr body",
state: "open",
html_url: "https://github.com/pikasTech/unidesk/pull/37",
comments: 0,
user: { login: "runner" },
labels: [],
pull_request: { html_url: "https://github.com/pikasTech/unidesk/pull/37" },
created_at: "2026-05-20T02:10:00Z",
updated_at: "2026-05-20T03:10:00Z",
},
];
const comments = [
{
id: 1,
@@ -107,6 +149,18 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 200, comments);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=2") {
sendJson(res, 200, issueList.slice(0, 2));
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=5") {
sendJson(res, 200, issueList);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=all&per_page=3") {
sendJson(res, 200, issueList);
return;
}
if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/20") {
const parsed = JSON.parse(body) as JsonRecord;
sendJson(res, 200, { ...issue, body: String(parsed.body ?? issue.body), updated_at: "2026-05-20T01:05:00Z" });
@@ -134,6 +188,45 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
UNIDESK_GITHUB_API_URL: mock.baseUrl,
};
try {
const listOpen = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "2", "--json", "number,title,state,url"], env);
assertCondition(listOpen.status === 0, "issue list should support state/limit/json", listOpen.json ?? { stdout: listOpen.stdout });
const listOpenData = dataOf(listOpen.json ?? {});
assertCondition(listOpenData.state === "open", "issue list should preserve state", listOpenData);
assertCondition(listOpenData.limit === 2, "issue list should preserve limit", listOpenData);
assertCondition(listOpenData.count === 2, "issue list should return bounded issues", listOpenData);
const listOpenIssues = listOpenData.issues as JsonRecord[];
assertCondition(Array.isArray(listOpenIssues), "issue list should expose issues array", listOpenData);
assertCondition(listOpenIssues[0]?.number === 35, "issue list should expose number field", listOpenData);
assertCondition(listOpenIssues[0]?.title === "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力", "issue list should expose title field", listOpenData);
assertCondition(!("labels" in listOpenIssues[0]), "issue list --json should select only requested fields", listOpenIssues[0]);
const acceptanceList = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "5", "--json", "number,title,state,url"], env);
assertCondition(acceptanceList.status === 0, "acceptance issue list command should succeed under mock GitHub", acceptanceList.json ?? { stdout: acceptanceList.stdout });
const acceptanceListData = dataOf(acceptanceList.json ?? {});
assertCondition(acceptanceListData.limit === 5, "acceptance issue list command should preserve limit=5", acceptanceListData);
assertCondition(acceptanceListData.count === 2, "acceptance issue list command should filter PRs and keep issues", acceptanceListData);
const listDefaultFields = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "all", "--limit", "3"], env);
assertCondition(listDefaultFields.status === 0, "issue list should support default fields", listDefaultFields.json ?? { stdout: listDefaultFields.stdout });
const listDefaultData = dataOf(listDefaultFields.json ?? {});
assertCondition(listDefaultData.count === 2, "issue list should filter pull requests from GitHub issues endpoint", listDefaultData);
const defaultIssues = listDefaultData.issues as JsonRecord[];
const firstLabels = defaultIssues[0]?.labels as JsonRecord[];
assertCondition(Array.isArray(firstLabels) && firstLabels[0]?.name === "cli", "issue list default fields should include labels", listDefaultData);
assertCondition(defaultIssues.every((item) => typeof item.number === "number" && typeof item.url === "string"), "issue list default fields should expose stable JSON", listDefaultData);
const badListField = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--json", "number,body"], env);
assertCondition(badListField.status !== 0, "issue list unsupported --json field should fail", badListField.json ?? { stdout: badListField.stdout });
const badListFieldData = failedDataOf(badListField.json ?? {});
assertCondition(badListFieldData.degradedReason === "validation-failed", "issue list unsupported --json should be validation-failed", badListFieldData);
assertCondition(badListFieldData.runnerDisposition === "business-failed", "issue list unsupported --json should be business-failed", badListFieldData);
const badState = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "triaged"], env);
assertCondition(badState.status !== 0, "issue list unsupported state should fail", badState.json ?? { stdout: badState.stdout });
const badStateData = failedDataOf(badState.json ?? {});
assertCondition(badStateData.degradedReason === "validation-failed", "issue list unsupported state should be validation-failed", badStateData);
assertCondition(badStateData.runnerDisposition === "business-failed", "issue list unsupported state should be business-failed", badStateData);
const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env);
assertCondition(viewBody.status === 0, "issue view --json body should succeed", viewBody.json ?? { stdout: viewBody.stdout });
const viewBodyData = dataOf(viewBody.json ?? {});
@@ -213,6 +306,10 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
ok: true,
checks: [
"issue view --json body preserves .data.issue.body",
"issue list supports state/limit/json with stable selected fields",
"acceptance issue list command succeeds under mock GitHub",
"issue list default fields include labels and filter pull requests",
"issue list unsupported fields and states fail structurally",
"issue view supports body,title,state,comments selection",
"unsupported --json fields fail structurally",
"issue edit --body-file rejects literal null",
+118 -23
View File
@@ -1,4 +1,6 @@
import { spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -9,23 +11,93 @@ function assertCondition(condition: unknown, message: string, detail: JsonRecord
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function runCli(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } {
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> {
return new Promise((resolve, reject) => {
const child = spawn("bun", ["scripts/cli.ts", ...args], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, ...env },
});
const stdout = String(result.stdout || "");
let json: JsonRecord | null = null;
try {
json = JSON.parse(stdout) as JsonRecord;
} catch {
json = null;
}
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on("error", reject);
child.on("close", (status) => {
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
let json: JsonRecord | null = null;
try {
json = JSON.parse(stdout) as JsonRecord;
} catch {
json = null;
}
resolve({
status,
stdout,
stderr: Buffer.concat(stderrChunks).toString("utf8"),
json,
});
});
});
}
interface MockRequest {
method: string;
url: string;
body: string;
}
function collectBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
}
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(payload));
}
async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise<void> }> {
const requests: MockRequest[] = [];
const pullRequest = {
id: 4200,
number: 42,
title: "contract PR",
body: "PR body",
state: "open",
html_url: "https://github.com/pikasTech/unidesk/pull/42",
draft: false,
user: { login: "runner" },
head: { ref: "feature/pr-contract", sha: "head-sha" },
base: { ref: "master", sha: "base-sha" },
created_at: "2026-05-20T04:00:00Z",
updated_at: "2026-05-20T05:00:00Z",
};
const server = createServer(async (req, res) => {
const body = await collectBody(req);
requests.push({ method: req.method ?? "", url: req.url ?? "", body });
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=all&per_page=4") {
sendJson(res, 200, [pullRequest]);
return;
}
if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/42") {
sendJson(res, 200, pullRequest);
return;
}
sendJson(res, 404, { message: "not found" });
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assertCondition(typeof address === "object" && address !== null, "mock server should expose address");
const port = (address as AddressInfo).port;
assertCondition(typeof port === "number", "mock server should expose port");
return {
status: result.status,
stdout,
stderr: String(result.stderr || ""),
json,
baseUrl: `http://127.0.0.1:${port}`,
requests,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
};
}
@@ -35,19 +107,41 @@ function dataOf(response: JsonRecord): JsonRecord {
return response.data as JsonRecord;
}
export function runGhCliPrContract(): JsonRecord {
const help = runCli(["gh", "help"]);
export async function runGhCliPrContract(): Promise<JsonRecord> {
const help = await runCli(["gh", "help"]);
assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout });
const helpData = dataOf(help.json ?? {});
const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : [];
assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage });
assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage });
const mock = await startMockGitHub();
const env = {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: mock.baseUrl,
};
try {
const list = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--limit", "4"], env);
assertCondition(list.status === 0, "pr list should succeed through REST", list.json ?? { stdout: list.stdout });
const listData = dataOf(list.json ?? {});
const pullRequests = listData.pullRequests as JsonRecord[];
assertCondition(Array.isArray(pullRequests) && pullRequests.length === 1, "pr list should return pullRequests", listData);
assertCondition(pullRequests[0]?.number === 42 && pullRequests[0]?.base && pullRequests[0]?.head, "pr list should expose PR summary", pullRequests[0]);
const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk"], env);
assertCondition(view.status === 0, "pr view should succeed through REST", view.json ?? { stdout: view.stdout });
const viewData = dataOf(view.json ?? {});
const pullRequest = viewData.pullRequest as JsonRecord;
assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr view should expose PR details", viewData);
} finally {
await mock.close();
}
const title = "contract pr create";
const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`);
writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8");
try {
const createDryRun = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]);
const createDryRun = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]);
assertCondition(createDryRun.status === 0, "pr create dry-run should succeed", createDryRun.json ?? { stdout: createDryRun.stdout });
const createData = dataOf(createDryRun.json ?? {});
assertCondition(createData.dryRun === true, "dry-run create must set dryRun=true", createData);
@@ -61,7 +155,7 @@ export function runGhCliPrContract(): JsonRecord {
assertCondition(String(createData.bodyPreview ?? "").includes("`code`"), "dry-run create should preserve backticks in preview", createData);
assertCondition(createData.request && typeof createData.request === "object", "dry-run create should include request plan", createData);
const commentDryRun = runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]);
const commentDryRun = await runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]);
assertCondition(commentDryRun.status === 0, "pr comment dry-run should succeed", commentDryRun.json ?? { stdout: commentDryRun.stdout });
const commentData = dataOf(commentDryRun.json ?? {});
assertCondition(commentData.dryRun === true, "dry-run comment must set dryRun=true", commentData);
@@ -69,19 +163,19 @@ export function runGhCliPrContract(): JsonRecord {
assertCondition(commentData.issueNumber === 42, "dry-run comment should preserve PR number", commentData);
assertCondition(Number(commentData.bodyChars ?? 0) > 0, "dry-run comment should expose bodyChars", commentData);
const mergeBlocked = runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]);
const mergeBlocked = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]);
assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout });
const mergeData = mergeBlocked.json?.data as JsonRecord | undefined;
assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {});
assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {});
const createMissingBody = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]);
const createMissingBody = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]);
assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout });
const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined;
assertCondition(createMissingBodyData?.degradedReason === "validation-failed", "missing body source should be validation-failed", createMissingBodyData ?? {});
assertCondition(createMissingBodyData?.runnerDisposition === "business-failed", "validation should classify as business-failed", createMissingBodyData ?? {});
const unknownOption = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]);
const unknownOption = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]);
assertCondition(unknownOption.status !== 0, "unknown gh option should fail", unknownOption.json ?? { stdout: unknownOption.stdout });
const unknownOptionData = unknownOption.json?.data as JsonRecord | undefined;
assertCondition(unknownOptionData?.degradedReason === "unsupported-command", "unknown option should be unsupported-command", unknownOptionData ?? {});
@@ -94,6 +188,7 @@ export function runGhCliPrContract(): JsonRecord {
ok: true,
checks: [
"gh help lists pr create/comment",
"pr list/view work through REST with token and no gh binary dependency",
"pr create dry-run exposes planned operation",
"pr comment dry-run preserves markdown text",
"pr merge is blocked",
@@ -104,6 +199,6 @@ export function runGhCliPrContract(): JsonRecord {
}
if (import.meta.main) {
const result = runGhCliPrContract();
const result = await runGhCliPrContract();
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
+95 -9
View File
@@ -14,6 +14,8 @@ const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593";
const CODE_QUEUE_BOARD_TARGET_ISSUE = 20;
const COMMANDER_BRIEF_TARGET_ISSUE = 24;
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const ISSUE_LIST_STATES = ["open", "closed", "all"] as const;
const ISSUE_BODY_PROFILES = {
"code-queue-board": {
label: "Code Queue long board issue #20",
@@ -28,6 +30,8 @@ const ISSUE_BODY_PROFILES = {
} as const;
type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number];
type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number];
type IssueListState = typeof ISSUE_LIST_STATES[number];
type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES;
type IssueBodyProfileOption = "auto" | IssueBodyProfileName;
@@ -129,6 +133,8 @@ interface GitHubOptions {
base?: string;
head?: string;
jsonFields?: IssueViewJsonField[];
issueListJsonFields?: IssueListJsonField[];
listState: IssueListState;
expectUpdatedAt?: string;
expectBodySha?: string;
bodyProfile: IssueBodyProfileOption;
@@ -160,6 +166,8 @@ interface GitHubIssue {
html_url: string;
comments: number;
user?: { login?: string };
labels?: Array<string | { name?: string; color?: string; description?: string | null }>;
pull_request?: unknown;
created_at?: string;
updated_at?: string;
}
@@ -243,15 +251,28 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe
return Math.min(value, maxValue);
}
function parseIssueViewJsonFields(args: string[]): IssueViewJsonField[] | undefined {
const requested = commaListOption(args, "--json");
function validateJsonFields<T extends string>(command: string, requested: string[] | undefined, allowedFields: readonly T[]): T[] | undefined {
if (requested === undefined) return undefined;
const allowed = new Set<string>(ISSUE_VIEW_JSON_FIELDS);
const allowed = new Set<string>(allowedFields);
const unsupported = requested.filter((field) => !allowed.has(field));
if (unsupported.length > 0) {
throw new Error(`unsupported gh issue view --json field(s): ${unsupported.join(", ")}; supported fields: ${ISSUE_VIEW_JSON_FIELDS.join(",")}`);
throw new Error(`unsupported ${command} --json field(s): ${unsupported.join(", ")}; supported fields: ${allowedFields.join(",")}`);
}
return requested as IssueViewJsonField[];
return requested as T[];
}
function parseIssueViewJsonFields(requested: string[] | undefined): IssueViewJsonField[] | undefined {
return validateJsonFields("gh issue view", requested, ISSUE_VIEW_JSON_FIELDS);
}
function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined {
return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS);
}
function parseIssueListState(args: string[]): IssueListState {
const raw = optionValue(args, "--state") ?? "open";
if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as IssueListState;
throw new Error(`unsupported gh issue list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`);
}
function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
@@ -261,7 +282,7 @@ function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
}
function validateKnownOptions(args: string[]): void {
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--expect-updated-at", "--expect-body-sha", "--body-profile"]);
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--expect-updated-at", "--expect-body-sha", "--body-profile"]);
const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
@@ -277,6 +298,8 @@ function validateKnownOptions(args: string[]): void {
function parseOptions(args: string[]): GitHubOptions {
validateKnownOptions(args);
const [top, sub] = args;
const requestedJsonFields = commaListOption(args, "--json");
return {
repo: optionValue(args, "--repo") ?? DEFAULT_REPO,
dryRun: hasFlag(args, "--dry-run"),
@@ -289,7 +312,9 @@ function parseOptions(args: string[]): GitHubOptions {
bodyFile: optionValue(args, "--body-file"),
base: optionValue(args, "--base"),
head: optionValue(args, "--head"),
jsonFields: parseIssueViewJsonFields(args),
jsonFields: top === "issue" && sub === "view" ? parseIssueViewJsonFields(requestedJsonFields) : undefined,
issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined,
listState: parseIssueListState(args),
expectUpdatedAt: optionValue(args, "--expect-updated-at"),
expectBodySha: optionValue(args, "--expect-body-sha"),
bodyProfile: parseIssueBodyProfile(args),
@@ -900,6 +925,31 @@ function issueSummary(issue: GitHubIssue): Record<string, unknown> {
};
}
function labelSummary(label: string | { name?: string; color?: string; description?: string | null }): Record<string, unknown> {
if (typeof label === "string") return { name: label, color: null, description: null };
return {
name: label.name ?? "",
color: label.color ?? null,
description: label.description ?? null,
};
}
function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Record<string, unknown> {
const summary: Record<IssueListJsonField, unknown> = {
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
updatedAt: issue.updated_at ?? null,
createdAt: issue.created_at ?? null,
author: issue.user?.login ?? null,
labels: (issue.labels ?? []).map(labelSummary),
};
const selected: Record<string, unknown> = {};
for (const field of fields) selected[field] = summary[field];
return selected;
}
function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null {
if (requested !== "auto") return requested;
if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board";
@@ -1297,6 +1347,11 @@ async function listIssueComments(token: string, repo: string, issueNumber: numbe
return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`);
}
async function listIssues(token: string, repo: string, state: IssueListState, limit: number): Promise<GitHubIssue[] | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=${state}&per_page=${limit}`);
}
async function getIssue(token: string, repo: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`);
@@ -1338,6 +1393,30 @@ async function issueView(repo: string, token: string, issueNumber: number, jsonF
};
}
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined): Promise<GitHubCommandResult> {
const rawIssues = await listIssues(token, repo, state, limit);
if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit });
const issues = rawIssues.filter((issue) => issue.pull_request === undefined).slice(0, limit);
const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice();
return {
ok: true,
command: "issue list",
repo,
state,
limit,
count: issues.length,
rawCount: rawIssues.length,
jsonFields: fields,
issues: issues.map((issue) => issueListSummary(issue, fields)),
request: {
method: "GET",
path: "/repos/{owner}/{repo}/issues",
query: { state, per_page: limit },
},
note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.",
};
}
async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) throw new Error("issue create requires --title <title>");
const body = readBodyFile(options.bodyFile, "issue create");
@@ -1675,6 +1754,7 @@ export function ghHelp(): unknown {
output: "json",
usage: [
"bun scripts/cli.ts gh auth status [--repo owner/name]",
"bun scripts/cli.ts gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
"bun scripts/cli.ts gh issue view <number> [--repo owner/name] [--json body,title,state,comments]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body]",
@@ -1691,6 +1771,7 @@ export function ghHelp(): unknown {
notes: [
"Issue create/edit/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
"Token values are never printed; auth status reports only token source and presence.",
"issue list defaults to --state open and bounded --limit 30; supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
"issue view supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"issue edit --body-file refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 and #24 body-only profiles require their stable headings.",
@@ -1721,9 +1802,13 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--notify-claudeqq-brief-diff is only supported by gh issue edit 24");
}
if (options.jsonFields !== undefined && !(top === "issue" && sub === "view")) {
if (optionWasProvided(args, "--state") && !(top === "issue" && sub === "list")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--json field selection is only supported by gh issue view");
return validationError(command, options.repo, "--state is only supported by gh issue list");
}
if (optionWasProvided(args, "--json") && !(top === "issue" && (sub === "view" || sub === "list"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--json field selection is only supported by gh issue view and gh issue list");
}
if ((options.allowShortBody || options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined || optionWasProvided(args, "--body-profile")) && !(top === "issue" && sub === "edit")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
@@ -1748,6 +1833,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields);
if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view"), options.jsonFields);
if (sub === "create") return issueCreate(options.repo, token, options);
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);