diff --git a/TEST.md b/TEST.md index 84634038..e36dc87c 100644 --- a/TEST.md +++ b/TEST.md @@ -137,4 +137,4 @@ ## T27 GitHub Issue/Comment 换行转义卫生扫描 -阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue scan-escape` 和 `gh issue cleanup-plan`,notes 中明确推荐 `--body-file`、quoted heredoc 和只读 cleanup-plan。运行 `bun scripts/gh-cli-issue-guard-contract-test.ts`,确认 mock GitHub 覆盖污染命中、说明性 `\n` 命中不误报、短 body/null body guard、body-file dry-run 写入路径、comment-id/body-id 定位和 cleanupSuggestions。对真实仓库只允许运行 `bun scripts/cli.ts gh issue scan-escape --repo pikasTech/unidesk --limit --dry-run` 或 `bun scripts/cli.ts gh issue cleanup-plan --repo pikasTech/unidesk --limit `,确认输出 JSON 中包含 `classification=suspected-pollution|explanatory-mention|risk`、`bodyKind`、`bodyId`、`issueNumber`、`issueId`、`commentId`、`cleanupSuggestions` 和 diff-like preview;不得运行真实历史评论清理、不得改写 #20/#24 正文,除非另有明确人工指令并先审阅 `--body-file` dry-run。 +阅读 `AGENTS.md` 和 `docs/reference/cli.md`,然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts gh help`,确认 help 中包含 `gh issue create --title --body-file <file> [--label label[,label...]]...`、`gh issue scan-escape` 和 `gh issue cleanup-plan`,notes 中明确推荐 `--body-file`、quoted heredoc 和只读 cleanup-plan。运行 `bun scripts/gh-cli-issue-guard-contract-test.ts`,确认 mock GitHub 覆盖污染命中、说明性 `\n` 命中不误报、短 body/null body guard、body-file dry-run 写入路径、`issue create --label cli,infra --label ops --dry-run` labels 解析和 request plan、真实 create REST payload labels、missing label 的结构化 `validation-failed`、comment-id/body-id 定位和 cleanupSuggestions。对真实仓库只允许运行 `bun scripts/cli.ts gh issue scan-escape --repo pikasTech/unidesk --limit <N> --dry-run` 或 `bun scripts/cli.ts gh issue cleanup-plan --repo pikasTech/unidesk --limit <N>`,确认输出 JSON 中包含 `classification=suspected-pollution|explanatory-mention|risk`、`bodyKind`、`bodyId`、`issueNumber`、`issueId`、`commentId`、`cleanupSuggestions` 和 diff-like preview;不得运行真实历史评论清理、不得改写 #20/#24 正文,除非另有明确人工指令并先审阅 `--body-file` dry-run。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b0fe45ac..2e1731de 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -31,7 +31,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`。 - `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。 - `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 -- `gh issue view <number> [--repo owner/name] [--json body,title,state,comments]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`gh issue create --title <title> --body-file <file> [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]`、`gh issue comment create <number> --body-file <file> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 +- `gh issue view <number> [--repo owner/name] [--json body,title,state,comments]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]`、`gh issue comment create <number> --body-file <file> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 - `gh issue update <number> --mode replace|append --body-file <file>` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件正文替换现有 body;`append` 先读取当前 body,再按 UTF-8 文件字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和 #24 指挥简报是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用结构 guard:#20 必须包含 `## 看板(OPEN)`,#24 必须包含 `## 常驻观察与长期建议`;也可显式使用 `--body-profile code-queue-board|commander-brief`。`--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>` 或 `--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。 - `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是指挥简报 #24 的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。 - `gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape、escaped backtick、ANSI escape 字符串、短 body、blank body 和 null body。输出固定 JSON,`findings` 会带 `bodyKind=issue-body|comment-body`、`issueNumber`、`issueId`、`commentId`、`lineNumber`、`column`、`kind`、`snippet` 和 `classification=suspected-pollution|explanatory-mention|risk`,用于区分说明性提到 `\n` 和疑似污染;`cleanupSuggestions` 只给 dry-run 清理建议、body/comment 定位和 diff-like preview,不 PATCH、不 DELETE、不真实清理历史 comment。`gh issue cleanup-plan` 是同一只读能力的别名,默认 `dryRun=true`。`gh pr list|view [--json ...]` 提供 REST 列表和详情,PR 字段白名单是 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt`。`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]`、`gh pr update <number> --mode replace|append --body-file <file>|--body <text> [--title ...] [--dry-run]`、`gh pr comment create <number> --body-file <file>|--body <text> [--dry-run]`、`gh pr comment delete <commentId> [--dry-run]`、`gh pr close|reopen <number> [--dry-run]` 是 PR CRUD/生命周期入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr update --mode append` 会先读取当前 PR body 再追加正文。`gh pr delete <number>` 和 `gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`;PR 生命周期删除语义请使用 `close`。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index dee0d269..3a3c5662 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -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", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 9b0685ac..455878a5 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -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);