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
+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);