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
+57 -2
View File
@@ -245,6 +245,32 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
sendJson(res, 201, { id: 9001, body: String(parsed.body ?? ""), html_url: "https://github.com/pikasTech/unidesk/issues/20#issuecomment-9001", user: { login: "tester" }, created_at: "2026-05-20T06:00:00Z", updated_at: "2026-05-20T06:00:00Z" });
return;
}
if (req.method === "POST" && req.url === "/repos/pikasTech/unidesk/issues") {
const parsed = JSON.parse(body) as JsonRecord;
const labels = Array.isArray(parsed.labels) ? parsed.labels.map(String) : [];
if (labels.includes("missing-label")) {
sendJson(res, 422, {
message: "Validation Failed",
errors: [{ resource: "Issue", field: "labels", code: "invalid", value: "missing-label" }],
documentation_url: "https://docs.github.com/rest/issues/issues#create-an-issue",
});
return;
}
sendJson(res, 201, {
id: 9100,
number: 91,
title: String(parsed.title ?? ""),
body: String(parsed.body ?? ""),
state: "open",
html_url: "https://github.com/pikasTech/unidesk/issues/91",
comments: 0,
user: { login: "tester" },
labels: labels.map((name) => ({ name })),
created_at: "2026-05-20T06:05:00Z",
updated_at: "2026-05-20T06:05:00Z",
});
return;
}
if (req.method === "DELETE" && req.url === "/repos/pikasTech/unidesk/issues/comments/9001") {
res.statusCode = 204;
res.end();
@@ -415,12 +441,40 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const appendFile = join(tmp, "append.md");
writeFileSync(appendFile, "\n- appended `code`\n| c | d |\n| --- | --- |\n| 3 | 4 |\n", "utf8");
const issueCreateDryRun = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body file dry-run", "--body-file", appendFile, "--dry-run"], env);
const issueCreateRequestCountBeforeDryRun = mock.requests.length;
const issueCreateDryRun = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body file dry-run", "--body-file", appendFile, "--label", "cli,infra", "--label", "ops", "--dry-run"], env);
assertCondition(issueCreateDryRun.status === 0, "issue create dry-run should succeed", issueCreateDryRun.json ?? { stdout: issueCreateDryRun.stdout });
const issueCreateDryRunData = dataOf(issueCreateDryRun.json ?? {});
const issueCreateBodySource = issueCreateDryRunData.bodySource as JsonRecord;
assertCondition(issueCreateDryRunData.planned === true && issueCreateBodySource.kind === "body-file" && issueCreateBodySource.path === appendFile, "issue create dry-run should expose body-file source", issueCreateDryRunData);
const issueCreateDryRunLabels = issueCreateDryRunData.labels as unknown[];
assertCondition(Array.isArray(issueCreateDryRunLabels) && issueCreateDryRunLabels.join(",") === "cli,infra,ops", "issue create dry-run should parse repeated and comma labels", issueCreateDryRunData);
const issueCreateDryRunRequest = issueCreateDryRunData.request as JsonRecord;
const issueCreateDryRunRequestBody = issueCreateDryRunRequest.body as JsonRecord;
assertCondition(Array.isArray(issueCreateDryRunRequestBody.labels) && (issueCreateDryRunRequestBody.labels as unknown[]).join(",") === "cli,infra,ops", "issue create dry-run request plan should include labels", issueCreateDryRunData);
assertCondition(issueCreateDryRunData.request && typeof issueCreateDryRunData.request === "object", "issue create dry-run should expose request plan", issueCreateDryRunData);
const issueCreateDryRunWriteCount = mock.requests.slice(issueCreateRequestCountBeforeDryRun).filter((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues").length;
assertCondition(issueCreateDryRunWriteCount === 0, "issue create dry-run must not POST GitHub", { requests: mock.requests.slice(issueCreateRequestCountBeforeDryRun) });
const issueCreateRequestCountBeforeWrite = mock.requests.length;
const issueCreate = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "body file write", "--body-file", appendFile, "--label", "cli", "--label", "infra,ops"], env);
assertCondition(issueCreate.status === 0, "issue create with labels should succeed", issueCreate.json ?? { stdout: issueCreate.stdout });
const issueCreateData = dataOf(issueCreate.json ?? {});
assertCondition(issueCreateData.command === "issue create", "issue create should report command name", issueCreateData);
const issueCreateLabels = issueCreateData.labels as unknown[];
assertCondition(Array.isArray(issueCreateLabels) && issueCreateLabels.join(",") === "cli,infra,ops", "issue create should report labels", issueCreateData);
const issueCreateRequest = mock.requests.slice(issueCreateRequestCountBeforeWrite).find((request) => request.method === "POST" && request.url === "/repos/pikasTech/unidesk/issues");
assertCondition(issueCreateRequest !== undefined, "issue create should POST to GitHub REST issues endpoint", { requests: mock.requests.slice(issueCreateRequestCountBeforeWrite) });
const issueCreatePayload = JSON.parse(issueCreateRequest?.body ?? "{}") as JsonRecord;
assertCondition(Array.isArray(issueCreatePayload.labels) && (issueCreatePayload.labels as unknown[]).join(",") === "cli,infra,ops", "issue create REST payload should include labels", issueCreatePayload);
const issueCreateMissingLabel = await runCli(["gh", "issue", "create", "--repo", "pikasTech/unidesk", "--title", "bad label", "--body-file", appendFile, "--label", "missing-label"], env);
assertCondition(issueCreateMissingLabel.status !== 0, "issue create missing label should fail structurally", issueCreateMissingLabel.json ?? { stdout: issueCreateMissingLabel.stdout });
const missingLabelData = failedDataOf(issueCreateMissingLabel.json ?? {});
assertCondition(missingLabelData.degradedReason === "validation-failed", "missing label should map to validation-failed", missingLabelData);
const missingLabelDetails = missingLabelData.details as JsonRecord;
const missingLabelNestedDetails = missingLabelDetails.details as JsonRecord;
assertCondition(Array.isArray(missingLabelNestedDetails.errors), "missing label error should preserve GitHub validation errors", missingLabelData);
const appendDryRun = await runCli(["gh", "issue", "update", "20", "--repo", "pikasTech/unidesk", "--mode", "append", "--body-file", appendFile, "--dry-run"], env);
assertCondition(appendDryRun.status === 0, "issue update append dry-run should succeed", appendDryRun.json ?? { stdout: appendDryRun.stdout });
@@ -464,7 +518,8 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue list default fields include labels and filter pull requests",
"issue scan-escape classifies pollution, explanatory mentions, and body risks",
"issue cleanup-plan remains dry-run with body/comment cleanup suggestions",
"issue create dry-run exposes body-file source and request plan",
"issue create dry-run parses repeated/comma labels and exposes request plan",
"issue create sends labels through REST and preserves GitHub validation errors for missing labels",
"issue list unsupported fields and states fail structurally",
"issue view supports body,title,state,comments selection",
"unsupported --json fields fail structurally",
+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);