diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0b8dc586..2be55418 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -30,6 +30,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 - `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 [--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 --body-file <file> [--dry-run]`、`gh issue edit <number> --body-file <file> [--title ...] [--dry-run]`、`gh issue comment <number> --body-file <file> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。 - `gh issue edit <number> --body-file <file>` 默认拒绝字面量 `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` 覆盖。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 54fbf030..48696e26 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -70,7 +70,7 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 如果某个 worker 任务需要依赖 GitHub issue 内容,但 runner 的 issue 可达性尚未被单独验证,指挥官不能默认 worker 已能读取该 issue。此时 worker prompt 必须直接内嵌完整需求、约束和验收点,issue URL 只能作为辅助引用。若要把 issue 作为任务输入源,先单独做可达性探测,再决定是否把 issue 作为常规前置条件。 -GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue view/create/edit/comment/close/reopen/scan-escape`、`gh pr list|view`、`gh pr create` 和 `gh pr comment`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `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` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue view <number> --json body` 是兼容入口,正文仍应从 `.data.issue.body` 读取;未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。issue 创建、编辑、评论和关闭以及 PR 创建和评论使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。`gh pr merge` 仍然不开放。 +GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/view/create/edit/comment/close/reopen/scan-escape`、`gh pr list|view`、`gh pr create` 和 `gh pr comment`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `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` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue view <number> --json body` 是兼容入口,正文仍应从 `.data.issue.body` 读取;未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。issue 创建、编辑、评论和关闭以及 PR 创建和评论使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。`gh pr merge` 仍然不开放。 CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index 3de4005d..a760eccb 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -86,6 +86,48 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque created_at: "2026-05-20T00:00:00Z", updated_at: "2026-05-20T01:00:00Z", }; + const issueList = [ + { + id: 2001, + number: 35, + title: "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力", + body: "issue list body", + state: "open", + html_url: "https://github.com/pikasTech/unidesk/issues/35", + comments: 0, + user: { login: "commander" }, + labels: [{ name: "cli", color: "1d76db", description: "CLI work" }], + created_at: "2026-05-20T02:00:00Z", + updated_at: "2026-05-20T03:00:00Z", + }, + { + id: 2002, + number: 36, + title: "second issue", + body: "second body", + state: "open", + html_url: "https://github.com/pikasTech/unidesk/issues/36", + comments: 0, + user: { login: "runner" }, + labels: [], + created_at: "2026-05-20T02:05:00Z", + updated_at: "2026-05-20T03:05:00Z", + }, + { + id: 3001, + number: 37, + title: "pull request should be filtered", + body: "pr body", + state: "open", + html_url: "https://github.com/pikasTech/unidesk/pull/37", + comments: 0, + user: { login: "runner" }, + labels: [], + pull_request: { html_url: "https://github.com/pikasTech/unidesk/pull/37" }, + created_at: "2026-05-20T02:10:00Z", + updated_at: "2026-05-20T03:10:00Z", + }, + ]; const comments = [ { id: 1, @@ -107,6 +149,18 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, comments); return; } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=2") { + sendJson(res, 200, issueList.slice(0, 2)); + return; + } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=5") { + sendJson(res, 200, issueList); + return; + } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=all&per_page=3") { + sendJson(res, 200, issueList); + return; + } if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/20") { const parsed = JSON.parse(body) as JsonRecord; sendJson(res, 200, { ...issue, body: String(parsed.body ?? issue.body), updated_at: "2026-05-20T01:05:00Z" }); @@ -134,6 +188,45 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { UNIDESK_GITHUB_API_URL: mock.baseUrl, }; try { + const listOpen = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "2", "--json", "number,title,state,url"], env); + assertCondition(listOpen.status === 0, "issue list should support state/limit/json", listOpen.json ?? { stdout: listOpen.stdout }); + const listOpenData = dataOf(listOpen.json ?? {}); + assertCondition(listOpenData.state === "open", "issue list should preserve state", listOpenData); + assertCondition(listOpenData.limit === 2, "issue list should preserve limit", listOpenData); + assertCondition(listOpenData.count === 2, "issue list should return bounded issues", listOpenData); + const listOpenIssues = listOpenData.issues as JsonRecord[]; + assertCondition(Array.isArray(listOpenIssues), "issue list should expose issues array", listOpenData); + assertCondition(listOpenIssues[0]?.number === 35, "issue list should expose number field", listOpenData); + assertCondition(listOpenIssues[0]?.title === "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力", "issue list should expose title field", listOpenData); + assertCondition(!("labels" in listOpenIssues[0]), "issue list --json should select only requested fields", listOpenIssues[0]); + + const acceptanceList = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "5", "--json", "number,title,state,url"], env); + assertCondition(acceptanceList.status === 0, "acceptance issue list command should succeed under mock GitHub", acceptanceList.json ?? { stdout: acceptanceList.stdout }); + const acceptanceListData = dataOf(acceptanceList.json ?? {}); + assertCondition(acceptanceListData.limit === 5, "acceptance issue list command should preserve limit=5", acceptanceListData); + assertCondition(acceptanceListData.count === 2, "acceptance issue list command should filter PRs and keep issues", acceptanceListData); + + const listDefaultFields = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "all", "--limit", "3"], env); + assertCondition(listDefaultFields.status === 0, "issue list should support default fields", listDefaultFields.json ?? { stdout: listDefaultFields.stdout }); + const listDefaultData = dataOf(listDefaultFields.json ?? {}); + assertCondition(listDefaultData.count === 2, "issue list should filter pull requests from GitHub issues endpoint", listDefaultData); + const defaultIssues = listDefaultData.issues as JsonRecord[]; + const firstLabels = defaultIssues[0]?.labels as JsonRecord[]; + assertCondition(Array.isArray(firstLabels) && firstLabels[0]?.name === "cli", "issue list default fields should include labels", listDefaultData); + assertCondition(defaultIssues.every((item) => typeof item.number === "number" && typeof item.url === "string"), "issue list default fields should expose stable JSON", listDefaultData); + + const badListField = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--json", "number,body"], env); + assertCondition(badListField.status !== 0, "issue list unsupported --json field should fail", badListField.json ?? { stdout: badListField.stdout }); + const badListFieldData = failedDataOf(badListField.json ?? {}); + assertCondition(badListFieldData.degradedReason === "validation-failed", "issue list unsupported --json should be validation-failed", badListFieldData); + assertCondition(badListFieldData.runnerDisposition === "business-failed", "issue list unsupported --json should be business-failed", badListFieldData); + + const badState = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "triaged"], env); + assertCondition(badState.status !== 0, "issue list unsupported state should fail", badState.json ?? { stdout: badState.stdout }); + const badStateData = failedDataOf(badState.json ?? {}); + assertCondition(badStateData.degradedReason === "validation-failed", "issue list unsupported state should be validation-failed", badStateData); + assertCondition(badStateData.runnerDisposition === "business-failed", "issue list unsupported state should be business-failed", badStateData); + const viewBody = await runCli(["gh", "issue", "view", "20", "--repo", "pikasTech/unidesk", "--json", "body"], env); assertCondition(viewBody.status === 0, "issue view --json body should succeed", viewBody.json ?? { stdout: viewBody.stdout }); const viewBodyData = dataOf(viewBody.json ?? {}); @@ -213,6 +306,10 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { ok: true, checks: [ "issue view --json body preserves .data.issue.body", + "issue list supports state/limit/json with stable selected fields", + "acceptance issue list command succeeds under mock GitHub", + "issue list default fields include labels and filter pull requests", + "issue list unsupported fields and states fail structurally", "issue view supports body,title,state,comments selection", "unsupported --json fields fail structurally", "issue edit --body-file rejects literal null", diff --git a/scripts/gh-cli-pr-contract-test.ts b/scripts/gh-cli-pr-contract-test.ts index 36090a40..e1165396 100644 --- a/scripts/gh-cli-pr-contract-test.ts +++ b/scripts/gh-cli-pr-contract-test.ts @@ -1,4 +1,6 @@ -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; import { writeFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -9,23 +11,93 @@ function assertCondition(condition: unknown, message: string, detail: JsonRecord if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); } -function runCli(args: string[]): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } { - const result = spawnSync("bun", ["scripts/cli.ts", ...args], { +function runCli(args: string[], env: Record<string, string> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: JsonRecord | null }> { + return new Promise((resolve, reject) => { + const child = spawn("bun", ["scripts/cli.ts", ...args], { cwd: process.cwd(), - encoding: "utf8", + env: { ...process.env, ...env }, }); - const stdout = String(result.stdout || ""); - let json: JsonRecord | null = null; - try { - json = JSON.parse(stdout) as JsonRecord; - } catch { - json = null; - } + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (status) => { + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + let json: JsonRecord | null = null; + try { + json = JSON.parse(stdout) as JsonRecord; + } catch { + json = null; + } + resolve({ + status, + stdout, + stderr: Buffer.concat(stderrChunks).toString("utf8"), + json, + }); + }); + }); +} + +interface MockRequest { + method: string; + url: string; + body: string; +} + +function collectBody(req: IncomingMessage): Promise<string> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); +} + +function sendJson(res: ServerResponse, status: number, payload: unknown): void { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(payload)); +} + +async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise<void> }> { + const requests: MockRequest[] = []; + const pullRequest = { + id: 4200, + number: 42, + title: "contract PR", + body: "PR body", + state: "open", + html_url: "https://github.com/pikasTech/unidesk/pull/42", + draft: false, + user: { login: "runner" }, + head: { ref: "feature/pr-contract", sha: "head-sha" }, + base: { ref: "master", sha: "base-sha" }, + created_at: "2026-05-20T04:00:00Z", + updated_at: "2026-05-20T05:00:00Z", + }; + const server = createServer(async (req, res) => { + const body = await collectBody(req); + requests.push({ method: req.method ?? "", url: req.url ?? "", body }); + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls?state=all&per_page=4") { + sendJson(res, 200, [pullRequest]); + return; + } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/pulls/42") { + sendJson(res, 200, pullRequest); + return; + } + sendJson(res, 404, { message: "not found" }); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assertCondition(typeof address === "object" && address !== null, "mock server should expose address"); + const port = (address as AddressInfo).port; + assertCondition(typeof port === "number", "mock server should expose port"); return { - status: result.status, - stdout, - stderr: String(result.stderr || ""), - json, + baseUrl: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), }; } @@ -35,19 +107,41 @@ function dataOf(response: JsonRecord): JsonRecord { return response.data as JsonRecord; } -export function runGhCliPrContract(): JsonRecord { - const help = runCli(["gh", "help"]); +export async function runGhCliPrContract(): Promise<JsonRecord> { + const help = await runCli(["gh", "help"]); assertCondition(help.status === 0, "gh help should succeed", help.json ?? { stdout: help.stdout }); const helpData = dataOf(help.json ?? {}); const usage = Array.isArray(helpData.usage) ? helpData.usage.map((value) => String(value)) : []; assertCondition(usage.some((line) => line.includes("gh pr create")), "gh help should list pr create", { usage }); assertCondition(usage.some((line) => line.includes("gh pr comment")), "gh help should list pr comment", { usage }); + const mock = await startMockGitHub(); + const env = { + GH_TOKEN: "contract-token", + UNIDESK_GITHUB_API_URL: mock.baseUrl, + }; + try { + const list = await runCli(["gh", "pr", "list", "--repo", "pikasTech/unidesk", "--limit", "4"], env); + assertCondition(list.status === 0, "pr list should succeed through REST", list.json ?? { stdout: list.stdout }); + const listData = dataOf(list.json ?? {}); + const pullRequests = listData.pullRequests as JsonRecord[]; + assertCondition(Array.isArray(pullRequests) && pullRequests.length === 1, "pr list should return pullRequests", listData); + assertCondition(pullRequests[0]?.number === 42 && pullRequests[0]?.base && pullRequests[0]?.head, "pr list should expose PR summary", pullRequests[0]); + + const view = await runCli(["gh", "pr", "view", "42", "--repo", "pikasTech/unidesk"], env); + assertCondition(view.status === 0, "pr view should succeed through REST", view.json ?? { stdout: view.stdout }); + const viewData = dataOf(view.json ?? {}); + const pullRequest = viewData.pullRequest as JsonRecord; + assertCondition(pullRequest.number === 42 && pullRequest.url === "https://github.com/pikasTech/unidesk/pull/42", "pr view should expose PR details", viewData); + } finally { + await mock.close(); + } + const title = "contract pr create"; const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`); writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8"); try { - const createDryRun = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]); + const createDryRun = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--draft", "--dry-run"]); assertCondition(createDryRun.status === 0, "pr create dry-run should succeed", createDryRun.json ?? { stdout: createDryRun.stdout }); const createData = dataOf(createDryRun.json ?? {}); assertCondition(createData.dryRun === true, "dry-run create must set dryRun=true", createData); @@ -61,7 +155,7 @@ export function runGhCliPrContract(): JsonRecord { assertCondition(String(createData.bodyPreview ?? "").includes("`code`"), "dry-run create should preserve backticks in preview", createData); assertCondition(createData.request && typeof createData.request === "object", "dry-run create should include request plan", createData); - const commentDryRun = runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]); + const commentDryRun = await runCli(["gh", "pr", "comment", "42", "--repo", "pikasTech/unidesk", "--body-file", bodyFile, "--dry-run"]); assertCondition(commentDryRun.status === 0, "pr comment dry-run should succeed", commentDryRun.json ?? { stdout: commentDryRun.stdout }); const commentData = dataOf(commentDryRun.json ?? {}); assertCondition(commentData.dryRun === true, "dry-run comment must set dryRun=true", commentData); @@ -69,19 +163,19 @@ export function runGhCliPrContract(): JsonRecord { assertCondition(commentData.issueNumber === 42, "dry-run comment should preserve PR number", commentData); assertCondition(Number(commentData.bodyChars ?? 0) > 0, "dry-run comment should expose bodyChars", commentData); - const mergeBlocked = runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]); + const mergeBlocked = await runCli(["gh", "pr", "merge", "42", "--repo", "pikasTech/unidesk"]); assertCondition(mergeBlocked.status !== 0, "pr merge should fail", mergeBlocked.json ?? { stdout: mergeBlocked.stdout }); const mergeData = mergeBlocked.json?.data as JsonRecord | undefined; assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {}); assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {}); - const createMissingBody = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]); + const createMissingBody = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--base", "master", "--head", "feature/pr-contract", "--dry-run"]); assertCondition(createMissingBody.status !== 0, "pr create without body source should fail", createMissingBody.json ?? { stdout: createMissingBody.stdout }); const createMissingBodyData = createMissingBody.json?.data as JsonRecord | undefined; assertCondition(createMissingBodyData?.degradedReason === "validation-failed", "missing body source should be validation-failed", createMissingBodyData ?? {}); assertCondition(createMissingBodyData?.runnerDisposition === "business-failed", "validation should classify as business-failed", createMissingBodyData ?? {}); - const unknownOption = runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]); + const unknownOption = await runCli(["gh", "pr", "create", "--repo", "pikasTech/unidesk", "--title", title, "--body-file", bodyFile, "--base", "master", "--head", "feature/pr-contract", "--dry-run", "--bad-option"]); assertCondition(unknownOption.status !== 0, "unknown gh option should fail", unknownOption.json ?? { stdout: unknownOption.stdout }); const unknownOptionData = unknownOption.json?.data as JsonRecord | undefined; assertCondition(unknownOptionData?.degradedReason === "unsupported-command", "unknown option should be unsupported-command", unknownOptionData ?? {}); @@ -94,6 +188,7 @@ export function runGhCliPrContract(): JsonRecord { ok: true, checks: [ "gh help lists pr create/comment", + "pr list/view work through REST with token and no gh binary dependency", "pr create dry-run exposes planned operation", "pr comment dry-run preserves markdown text", "pr merge is blocked", @@ -104,6 +199,6 @@ export function runGhCliPrContract(): JsonRecord { } if (import.meta.main) { - const result = runGhCliPrContract(); + const result = await runGhCliPrContract(); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index b0204440..16998b0d 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -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);