fix: support labels for gh issue create
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user