fix: support labels for gh issue create

This commit is contained in:
Codex
2026-05-20 20:22:50 +00:00
parent 8a822c686e
commit 19933a6d15
4 changed files with 125 additions and 14 deletions
+66 -10
View File
@@ -200,6 +200,7 @@ interface GitHubOptions {
draft: boolean;
notifyClaudeQqBriefDiff: boolean;
allowShortBody: boolean;
labels: string[];
title?: string;
body?: string;
bodyFile?: string;
@@ -302,6 +303,18 @@ function optionValue(args: string[], name: string): string | undefined {
return value;
}
function optionValues(args: string[], name: string): string[] {
const values: string[] = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] !== name) continue;
const value = args[index + 1];
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
values.push(value);
index += 1;
}
return values;
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
@@ -318,6 +331,22 @@ function commaListOption(args: string[], name: string): string[] | undefined {
return values;
}
function labelsOption(args: string[]): string[] {
const rawValues = optionValues(args, "--label");
const labels: string[] = [];
const seen = new Set<string>();
for (const raw of rawValues) {
const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
if (parts.length === 0) throw new Error("--label requires at least one non-empty label");
for (const label of parts) {
if (seen.has(label)) continue;
labels.push(label);
seen.add(label);
}
}
return labels;
}
function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;
@@ -367,7 +396,7 @@ function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption {
}
function validateKnownOptions(args: string[]): void {
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile"]);
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head", "--json", "--state", "--mode", "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label"]);
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];
@@ -392,6 +421,7 @@ function parseOptions(args: string[]): GitHubOptions {
draft: hasFlag(args, "--draft"),
notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"),
allowShortBody: hasFlag(args, "--allow-short-body"),
labels: labelsOption(args),
title: optionValue(args, "--title"),
body: optionValue(args, "--body"),
bodyFile: optionValue(args, "--body-file"),
@@ -859,6 +889,12 @@ function commanderBriefNotificationPlan(issueNumber: number, body: string, diff:
function writeBodyPlan(command: "issue create" | "issue comment create" | "pr create" | "pr comment", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
const isIssueWrite = command === "issue create" || command === "issue comment create";
const requestBody: Record<string, unknown> = {
bodyChars: body.length,
bodySource,
};
if (command === "issue create" && typeof extra.title === "string") requestBody.title = extra.title;
if (command === "issue create" && Array.isArray(extra.labels)) requestBody.labels = extra.labels;
return {
repo,
bodySource,
@@ -870,10 +906,7 @@ function writeBodyPlan(command: "issue create" | "issue comment create" | "pr cr
path: isIssueWrite
? (command === "issue create" ? "/repos/{owner}/{repo}/issues" : "/repos/{owner}/{repo}/issues/{issue_number}/comments")
: (command === "pr create" ? "/repos/{owner}/{repo}/pulls" : "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
body: {
bodyChars: body.length,
bodySource,
},
body: requestBody,
},
validation: {
source: String(bodySource.kind ?? "unknown"),
@@ -1047,6 +1080,10 @@ function labelSummary(label: string | { name?: string; color?: string; descripti
};
}
function issueLabelNames(issue: GitHubIssue): string[] {
return (issue.labels ?? []).map((label) => typeof label === "string" ? label : label.name ?? "").filter((label) => label.length > 0);
}
function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Record<string, unknown> {
const summary: Record<IssueListJsonField, unknown> = {
number: issue.number,
@@ -1619,6 +1656,7 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
if (options.title === undefined) throw new Error("issue create requires --title <title>");
const body = readBodyFile(options.bodyFile, "issue create");
const bodySource = { kind: "body-file", path: options.bodyFile ?? null };
const labels = options.labels;
if (options.dryRun) {
return {
ok: true,
@@ -1627,13 +1665,26 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
dryRun: true,
planned: true,
title: options.title,
...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title }),
labels,
...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title, labels }),
};
}
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, { title: options.title, body });
if (isGitHubError(issue)) return commandError("issue create", repo, issue);
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), bodySource, rest: true };
const payload: Record<string, unknown> = { title: options.title, body };
if (labels.length > 0) payload.labels = labels;
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, payload);
if (isGitHubError(issue)) return commandError("issue create", repo, issue, { title: options.title, labels });
const appliedLabels = issueLabelNames(issue);
const missingLabels = labels.filter((label) => !appliedLabels.includes(label));
if (missingLabels.length > 0) {
return validationError("issue create", repo, "GitHub created the issue but did not return all requested labels; refusing to report silent label success", {
issue: issueSummary(issue),
requestedLabels: labels,
appliedLabels,
missingLabels,
});
}
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true };
}
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> {
@@ -2307,7 +2358,7 @@ export function ghHelp(): unknown {
"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 create --title <title> --body-file <file> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append --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]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [compat alias for issue update --mode replace]",
"bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]",
@@ -2332,6 +2383,7 @@ export function ghHelp(): unknown {
"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.",
"issue create accepts repeatable --label values and comma-separated labels; dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
"issue edit is a compatibility alias for issue update --mode replace.",
@@ -2385,6 +2437,10 @@ 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, "--allow-short-body, --expect-updated-at, --expect-body-sha, and --body-profile are only supported by gh issue update/edit");
}
if (optionWasProvided(args, "--label") && !(top === "issue" && sub === "create")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--label is only supported by gh issue create");
}
if (top === "auth" && sub === "status") return authStatus(options.repo);