Add gh issue list REST contract
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user