Add gh issue list REST contract
This commit is contained in:
+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