Add gh issue list REST contract

This commit is contained in:
Codex
2026-05-20 12:27:12 +00:00
parent 688851414a
commit 4b65f713fa
5 changed files with 312 additions and 33 deletions
+1
View File
@@ -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 可能混入 PRCLI 会从 `.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 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 北京时间` 段落发送给 ClaudeQQClaudeQQ 失败不会回滚 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` 覆盖。
+1 -1
View File
@@ -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 并派单。
@@ -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",
+118 -23
View File
@@ -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`);
}
+95 -9
View File
@@ -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);