diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 6715022b..9036991a 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -183,7 +183,7 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 `#20` 当前仍受 body profile 保护,正文必须保留 `## 看板(OPEN)` heading;该 heading 可用于承载紧凑的 P0/P1 直达表或当前活跃入口,不再要求恢复旧式 OPEN/CLOSED 全覆盖明细表。`gh issue board-audit` 只做只读结构审计,不再负责检查 GitHub open/closed issue 是否被表格完全覆盖;维护旧式表格时才使用 `board-row` 系列命令。 -GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`github-transient`、`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 应用它区分基础设施阻塞和业务/参数失败。`github-transient` 专指 GitHub DNS 或 API 连接在收到 HTTP 状态前失败,例如 `Temporary failure in name resolution`、`Could not resolve host: github.com/api.github.com` 或 `error connecting to api.github.com`;它必须带 `retryable=true` 或等价 retry/backoff 指示,并且不是 `missing-token`、`auth-failed`、`scope-insufficient`、`validation-failed` 或 PR 语义失败。指挥官看到这类结果时,优先重试或退避;如果对应 Code Queue 任务 heartbeat/trace 仍新鲜,应保持任务运行并继续监督,不要立即 close/requeue 业务工作。runner 不应直接运行系统 `gh auth status` 并把输出贴入 Code Queue 日志;系统 `gh` 的 masked token 行仍会暴露 token 前缀和 scope 片段。需要验证当前 runner GitHub auth 时使用 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 或 `bun scripts/cli.ts codex pr-preflight --remote`,输出只能保留 token 是否存在、来源、长度和掩码,不得打印 token 值或 token 片段。Code Queue 输出层必须在保留 command output、trace、raw output 页面和 commander 摘要前 redaction `gh auth status` 风格 token 行,并给出 UniDesk CLI wrapper 提示。`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 read --json body` 是 canonical 入口,正文仍应从 `.data.issue.body` 读取;`view` 只保留为兼容别名。未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读审计目标 board issue 正文结构,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;它不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表覆盖关系。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留为空数组或 0。显式 `gh issue update --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list --board-issue 20 --state open|closed|all`、`gh issue board-row get --board-issue 20` 和 `gh issue board-row update --board-issue 20 --field progress|status|validation|branch|tasks|focus --value `;`board-row update` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`gh issue board-row upsert` 可更新既有行或按 section 生成完整新行;`board-row add/move/delete` 已支持行级新增、OPEN/CLOSED 迁移和删除,全部默认 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。`gh pr list --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单;`gh pr read|view --json ...` 还支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`stateDetail=open|closed|merged` 用于区分 REST `state=closed` 中的普通关闭和已合并;`closed*`、`merged*`、`mergeCommit` 和分支名字段都来自 REST。只有 mergeability/check rollup 需要请求 GraphQL,适合 PR 收口前判断可合并性和检查汇总。GraphQL 权限不足、网络失败、GitHub 仍返回 `UNKNOWN`/null、或需要 UniDesk CLI 尚未开放的官方字段、review/merge 操作时,回退系统 `gh` 只读观察或 GitHub UI;不要把缺失元数据当成已可合并。issue/PR 创建、更新、评论、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`gh pr merge` 仍然不开放。 +GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`github-transient`、`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 应用它区分基础设施阻塞和业务/参数失败。`github-transient` 专指 GitHub DNS 或 API 连接在收到 HTTP 状态前失败,例如 `Temporary failure in name resolution`、`Could not resolve host: github.com/api.github.com` 或 `error connecting to api.github.com`;它必须带 `retryable=true` 或等价 retry/backoff 指示,并且不是 `missing-token`、`auth-failed`、`scope-insufficient`、`validation-failed` 或 PR 语义失败。指挥官看到这类结果时,优先重试或退避;如果对应 Code Queue 任务 heartbeat/trace 仍新鲜,应保持任务运行并继续监督,不要立即 close/requeue 业务工作。runner 不应直接运行系统 `gh auth status` 并把输出贴入 Code Queue 日志;系统 `gh` 的 masked token 行仍会暴露 token 前缀和 scope 片段。需要验证当前 runner GitHub auth 时使用 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 或 `bun scripts/cli.ts codex pr-preflight --remote`,输出只能保留 token 是否存在、来源、长度和掩码,不得打印 token 值或 token 片段。Code Queue 输出层必须在保留 command output、trace、raw output 页面和 commander 摘要前 redaction `gh auth status` 风格 token 行,并给出 UniDesk CLI wrapper 提示。`gh issue list --state open --limit N --json number,title,state,closed,closedAt,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue read --json body,title,state,closed,closedAt` 是 canonical 入口,正文仍应从 `.data.issue.body` 读取;`view` 只保留为兼容别名。未知 `--json` 字段必须失败,不得让调用方把空正文或缺失生命周期字段误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读审计目标 board issue 正文结构,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;它不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表覆盖关系。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留为空数组或 0。显式 `gh issue update --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list --board-issue 20 --state open|closed|all`、`gh issue board-row get --board-issue 20` 和 `gh issue board-row update --board-issue 20 --field progress|status|validation|branch|tasks|focus --value `;`board-row update` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`gh issue board-row upsert` 可更新既有行或按 section 生成完整新行;`board-row add/move/delete` 已支持行级新增、OPEN/CLOSED 迁移和删除,全部默认 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。`gh pr list --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单;`gh pr read|view --json ...` 还支持 `stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`。`stateDetail=open|closed|merged` 用于区分 REST `state=closed` 中的普通关闭和已合并;`closed*`、`merged*`、`mergeCommit` 和分支名字段都来自 REST。只有 mergeability/check rollup 需要请求 GraphQL,适合 PR 收口前判断可合并性和检查汇总。GraphQL 权限不足、网络失败、GitHub 仍返回 `UNKNOWN`/null、或需要 UniDesk CLI 尚未开放的官方字段、review/merge 操作时,回退系统 `gh` 只读观察或 GitHub UI;不要把缺失元数据当成已可合并。issue/PR 创建、更新、评论、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`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 ea21b7d9..a4e808c4 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -98,6 +98,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque user: { login: "tester" }, created_at: "2026-05-20T00:00:00Z", updated_at: "2026-05-20T01:00:00Z", + closed_at: null, }; const shorthandIssue = { id: 7000, @@ -110,6 +111,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque user: { login: "tester" }, created_at: "2026-05-20T02:00:00Z", updated_at: shorthandIssueUpdatedAt, + closed_at: null, }; const boardIssueBodyInitial = [ "# Code Queue", @@ -254,6 +256,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque labels: [{ name: "cli", color: "1d76db", description: "CLI work" }], created_at: "2026-05-20T02:00:00Z", updated_at: "2026-05-20T03:00:00Z", + closed_at: null, }, { id: 2002, @@ -267,6 +270,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque labels: [], created_at: "2026-05-20T02:05:00Z", updated_at: "2026-05-20T03:05:00Z", + closed_at: null, }, { id: 3001, @@ -296,6 +300,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque labels: [], created_at: "2026-05-20T02:30:00Z", updated_at: "2026-05-20T03:30:00Z", + closed_at: null, }, ]; const scanIssues = [ @@ -413,6 +418,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque labels: [], created_at: "2026-05-18T02:00:00Z", updated_at: "2026-05-20T03:00:00Z", + closed_at: "2026-05-20T03:15:00Z", }, { id: 2040, @@ -426,6 +432,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque labels: [], created_at: "2026-05-19T02:00:00Z", updated_at: "2026-05-20T03:00:00Z", + closed_at: "2026-05-20T03:20:00Z", }, { id: 2041, @@ -439,6 +446,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque labels: [], created_at: "2026-05-19T02:05:00Z", updated_at: "2026-05-20T03:05:00Z", + closed_at: "2026-05-20T03:25:00Z", }, ]; const comments = [ @@ -663,6 +671,11 @@ export async function runGhCliIssueGuardContract(): Promise { const mock = await startMockGitHub(); const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-issue-guard-")); + const startedAt = Date.now(); + const heartbeat = setInterval(() => { + const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); + process.stderr.write(`[gh-issue-contract] elapsed=${elapsedSeconds}s requests=${mock.requests.length}\n`); + }, 10_000); const env = { GH_TOKEN: "contract-token-should-not-print", UNIDESK_GITHUB_API_URL: mock.baseUrl, @@ -680,6 +693,18 @@ export async function runGhCliIssueGuardContract(): Promise { 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 listOpenLifecycle = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "2", "--json", "number,state,closed,closedAt"], env); + assertCondition(listOpenLifecycle.status === 0, "issue list lifecycle fields should succeed", listOpenLifecycle.json ?? { stdout: listOpenLifecycle.stdout }); + const listOpenLifecycleData = dataOf(listOpenLifecycle.json ?? {}); + const listOpenLifecycleIssues = listOpenLifecycleData.issues as JsonRecord[]; + assertCondition(listOpenLifecycleIssues[0]?.closed === false && listOpenLifecycleIssues[0]?.closedAt === null, "open issue list rows should expose closed=false and closedAt=null", listOpenLifecycleData); + + const listClosedLifecycle = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "closed", "--limit", "2", "--json", "number,state,closed,closedAt"], env); + assertCondition(listClosedLifecycle.status === 0, "closed issue list lifecycle fields should succeed", listClosedLifecycle.json ?? { stdout: listClosedLifecycle.stdout }); + const listClosedLifecycleData = dataOf(listClosedLifecycle.json ?? {}); + const listClosedLifecycleIssues = listClosedLifecycleData.issues as JsonRecord[]; + assertCondition(listClosedLifecycleIssues[0]?.state === "closed" && listClosedLifecycleIssues[0]?.closed === true && listClosedLifecycleIssues[0]?.closedAt === "2026-05-20T03:15:00Z", "closed issue list rows should expose closed=true and closedAt", listClosedLifecycleData); + 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 ?? {}); @@ -1256,18 +1281,19 @@ export async function runGhCliIssueGuardContract(): Promise { assertCondition(shorthandConflictData.degradedReason === "validation-failed", "conflicting --repo should be validation-failed", shorthandConflictData); assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "conflict message should name the derived repo", shorthandConflictData); const issueConflictCommands = shorthandConflictData.supportedCommands as string[]; - assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue read 7 --repo pikasTech/HWLAB --json body,title,state,comments"), "conflict should include the exact supported issue read command", shorthandConflictData); + assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue read 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue read command", shorthandConflictData); const rawIssueList = await runCli(["gh", "issue", "list", "--raw"], env); assertCondition(rawIssueList.status === 0, "issue list --raw should be a supported explicit list disclosure path", rawIssueList.json ?? { stdout: rawIssueList.stdout }); const rawIssueListData = dataOf(rawIssueList.json ?? {}); assertCondition(rawIssueListData.command === "issue list" && rawIssueListData.rawCount === 3, "issue list --raw should keep compact list semantics with raw pagination metadata", rawIssueListData); - const readFields = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,comments"], env); + const readFields = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,closed,closedAt,comments"], env); assertCondition(readFields.status === 0, "common --json field selection should succeed", readFields.json ?? { stdout: readFields.stdout }); const readFieldsData = dataOf(readFields.json ?? {}); const fieldsJson = readFieldsData.json as JsonRecord; assertCondition(fieldsJson.title === "长期总看板", "selected json title should be exposed", fieldsJson); + assertCondition(fieldsJson.closed === false && fieldsJson.closedAt === null, "open issue read should expose lifecycle fields", fieldsJson); assertCondition(Array.isArray(fieldsJson.comments) && fieldsJson.comments.length === 1, "selected json comments should be exposed", fieldsJson); const unsupported = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,unknown"], env); @@ -1760,7 +1786,7 @@ export async function runGhCliIssueGuardContract(): Promise { "issue create dry-run parses repeated/comma labels, supports stdin, rejects inline --body, 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 read supports body,title,state,comments selection", + "issue read supports body,title,state,closed,closedAt,comments selection", "unknown/full disclosure option guidance remains actionable", "unsupported --json fields fail structurally", "issue edit --body-file rejects literal null", @@ -1783,6 +1809,7 @@ export async function runGhCliIssueGuardContract(): Promise { ], }; } finally { + clearInterval(heartbeat); rmSync(tmp, { recursive: true, force: true }); await mock.close(); } diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index f9bca4f9..88eb16be 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -24,8 +24,8 @@ const DEFAULT_BOARD_KNOWN_META_ISSUES = [CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDE const BOARD_AUDIT_REQUIRED_COLUMNS = ["branch", "acceptance", "relatedTask", "progress"] as const; const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks", "focus"] as const; const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const; -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_VIEW_JSON_FIELDS = ["body", "title", "state", "closed", "closedAt", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const; +const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "closed", "closedAt", "url", "updatedAt", "createdAt", "author", "labels"] as const; const PR_LIST_JSON_FIELDS = [ "body", "title", @@ -428,6 +428,7 @@ interface GitHubIssue { pull_request?: unknown; created_at?: string; updated_at?: string; + closed_at?: string | null; } interface GitHubComment { @@ -972,7 +973,7 @@ function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: n function readViewSupportedJsonFields(kind: "issue" | "pr"): string { return kind === "issue" - ? "body,title,state,comments" + ? ISSUE_VIEW_JSON_FIELDS.join(",") : "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"; } @@ -2072,6 +2073,8 @@ function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; prev number: issue.number, title: issue.title, state: issue.state, + closed: issue.state === "closed", + closedAt: issue.closed_at ?? null, url: issue.html_url, author: issue.user?.login ?? null, createdAt: issue.created_at ?? null, @@ -2097,6 +2100,8 @@ function issueLifecycleSummary(issue: GitHubIssue): Record { number: issue.number, title: issue.title, state: issue.state, + closed: issue.state === "closed", + closedAt: issue.closed_at ?? null, url: issue.html_url, author: issue.user?.login ?? null, createdAt: issue.created_at ?? null, @@ -2151,6 +2156,8 @@ function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Rec number: issue.number, title: issue.title, state: issue.state, + closed: issue.state === "closed", + closedAt: issue.closed_at ?? null, url: issue.html_url, updatedAt: issue.updated_at ?? null, createdAt: issue.created_at ?? null, @@ -6376,8 +6383,8 @@ export function ghHelp(): unknown { output: "json", usage: [ "bun scripts/cli.ts gh auth status [--repo owner/name]", - "bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels] [--raw|--full]", - "bun scripts/cli.ts gh issue read [--repo owner/name] [--json body,title,state,comments] [--raw|--full]", + "bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]", + "bun scripts/cli.ts gh issue read [--repo owner/name] [--json body,title,state,closed,closedAt,comments] [--raw|--full]", "bun scripts/cli.ts gh issue view [--repo owner/name] [--raw|--full] [compatibility alias for issue read]", "bun scripts/cli.ts gh issue create --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] [--full|--raw]", @@ -6420,9 +6427,9 @@ export function ghHelp(): unknown { "Issue and PR create/read/update/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 and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.", - "issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", + "issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", "PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.", - "issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", + "issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", "--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.", "GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.", "issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",