From db8b1b64af0371c6c9fc96d6ca47ba7b8e61bf3c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 02:17:10 +0000 Subject: [PATCH] feat: improve github issue lifecycle cli --- AGENTS.md | 2 +- docs/reference/cli.md | 7 +- scripts/src/gh.ts | 416 ++++++++++++++++++++++++++++++++++++++---- scripts/src/output.ts | 5 +- 4 files changed, 385 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af06651c..a9bc403d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。 - `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD;`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod,`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。 - `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contract,runner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`。 -- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment|merge` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight、guarded PR merge 与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 会先执行 closeout 预检并拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 +- `bun scripts/cli.ts gh preflight|auth status|issue ...|pr list|files|diff --stat|read|view|preflight|closeout|create|edit|update|comment|merge` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、分页 issue list、inactive issue stale-close、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)、低噪声 PR 收口 preflight、guarded PR merge 与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 会先执行 closeout 预检并拒绝非 open、draft、冲突、非 CLEAN、失败或 pending checks 的 PR,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 - `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run|prompt-lint --kind gpt55-pr`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合、ClaudeQQ 高风险请示草案和 GPT-5.5 PR prompt 边界辅助 lint;当前只返回 dry-run 计划和 backend-core `microservice proxy claudeqq` 授权后候选命令,不接 live bridge、不接管人工指挥官,不发送消息,`prompt-lint` 不作为业务 PR 门禁也不改变 `codex submit` 默认行为,规则见 `docs/reference/host-codex-commander.md`。 - `bun scripts/cli.ts hwlab g14 monitor-prs`:一行启动异步监控 HWLAB base=G14 的未合并 PR;可合并时走 UniDesk `gh pr merge` 合并、监控 G14 Tekton/GitOps/Argo DEV rollout,并向 #7 索引的北京日期每日简报追加 CI/CD 耗时与上线 changelog,规则见 `docs/reference/g14.md` 与 `docs/reference/cli.md`。 - `bun scripts/cli.ts agentrun v01 control-plane status|trigger-current [--dry-run|--confirm]`:通过 G14 route 只读观察或手动触发 AgentRun `v0.1` commit-pinned Tekton/Argo PipelineRun,规则见 `docs/reference/agentrun.md` 与 `docs/reference/cli.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index badc9331..3f502d2e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -59,9 +59,10 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P - `hwlab cd status|audit|preflight|apply --env dev [--dry-run]` 是旧 D601 HWLAB DEV CD 指挥侧 wrapper,仅用于显式 legacy 诊断和迁移对照。默认通过 UniDesk provider `host.ssh` 进入 D601,再调用 HWLAB repo-owned `scripts/dev-cd-apply.mjs`,不内嵌发布 kubectl 逻辑:`status` 汇总固定 CD mirror、Git clean/main/origin-main、`deploy/deploy.json`/artifact catalog/report、D601 native k3s guard 和 CD Lease lock,并用 `scripts/dev-cd-apply.mjs --status --skip-live-verify` 取得 target/promotion 摘要;`audit` 在 k3s/CD 恢复后做只读健康审计,返回有界 JSON 的 blocker 分类、D601 guard/node、SecretRef 存在性、registry 可达性、Lease phase/holder/staleness、deploy.json 与 artifact/workload image 收敛、current Deployment image/revision/rollout、16666/16667 public health commit/readiness 和 DB/runtime durability 摘要;`preflight` 进一步检查必需 SecretRef 对象/键存在性并运行 HWLAB `scripts/dev-cd-apply.mjs --dry-run --skip-live-verify` 受控事务摘要。完整远端 stdout/stderr 写入 D601 `~/.state/unidesk-hwlab-cd//` 和本地 `.state/hwlab-cd//` task dump,stdout 只返回有界摘要。默认 HWLAB CD repo 是 `/home/ubuntu/hwlab_cd`,`/home/ubuntu/hwlab` runner 历史目录不得作为发布真相。wrapper 强制 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并只以这个显式目标作为 gate;显式目标出现 `docker-desktop`、`desktop-control-plane` 或 `127.0.0.1:11700` 信号会结构化拒绝,audit/preflight/apply --dry-run 都必须观察到 node `d601`。真实 apply 只暴露 `scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report` 命令形状并标注 host-commander-only,本 runner 不执行 live apply、rollout、Lease mutation 或 DEV deploy apply。长期规则见 `docs/reference/hwlab.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`、`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`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。 - `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true`、`mutation=false`、`declaredClass`、`effectiveClass`、`requiredClass`、`dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only`、`live-read`、`live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run` 与 `codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。 -- `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]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`owner/repo` 位置参数是 `--repo owner/repo` 的兼容别名;若位置 repo 与 `--repo` 冲突,或位置参数不是 `owner/repo`,必须结构化失败,禁止静默 fallback 到默认 repo。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;`--search` 使用 GitHub Search Issues API,并自动追加 `repo:/`、`type:issue` 和 state qualifier,用于创建新 issue 前做低摩擦查重;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`--label` 是 GitHub REST `labels=label1,label2` 服务端过滤,支持重复 `--label` 和逗号分隔;filter 不在本命令上下文中使用(如 `issue read`、`pr list`)必须结构化失败并指明 `gh issue create and gh issue list` 才是合法作用域。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。`--raw|--full` 在 `gh issue list` 上是绕过 20 KiB stdout 截断的显式开关:响应结果会带 `noDump=true`,`output.ts` 据此跳过 head/tail 替换并把完整数据 inline 输出;当响应未超阈值时 `--raw|--full` 行为等价默认。 -- `gh issue lifecycle`:`--state` 只能作为 `gh issue list` / `gh issue board-row list` / `gh pr list` 的过滤参数;`gh issue update` / `gh issue edit` 只写 body/title,**不接受** `--state` 改 open/closed。把 `gh issue update --state closed` 落到错命令上时,CLI 必须返回 `validation-failed` 并显式提示 `gh issue close ` / `gh issue reopen `(PR 用 `gh pr close|reopen `),并把 5 条受支持命令放进 `supportedCommands`,禁止把"无 `--state` 改 issue 状态"的命令升级为"接受 `--state`"。issue 硬删除走 `close`,PR 硬删除走 `close`,两者都没有"delete"语义。 -- `gh issue read [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`,CLI 必须结构化失败并给出 `gh issue read --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw` 与 `--full` 是显式完整披露别名:read/view 会选择完整支持字段集;issue update/edit 只有显式传入时才在成功响应里包含完整 `.data.issue.body`。当最终 `gh` JSON 超过 20 KiB 时,CLI 必须把完整 JSON 写入 `/tmp/unidesk-cli-output/*.json`,stdout 只返回 `outputTruncated=true`、dump path、总 bytes/lines 和 head/tail 预览。默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title --body-file <file|-> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file|-> [--title ...] [--dry-run] [--full|--raw]`、`gh issue comment create <number> (--body-file <file|->|--body <short-text>) [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--body` 仅用于 issue comment 的短单行文本;空白、多行、疑似 shell 污染、secret-like 或过长 inline body 必须结构化失败,Markdown/生成内容/长评论继续用 `--body-file <file|->`。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 +- `gh issue 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]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`owner/repo` 位置参数是 `--repo owner/repo` 的兼容别名;若位置 repo 与 `--repo` 冲突,或位置参数不是 `owner/repo`,必须结构化失败,禁止静默 fallback 到默认 repo。`--limit` 是 CLI 返回上限,不等同 GitHub 单页 `per_page`:当 `--limit > 100` 或默认页中混入 PR 时,CLI 必须分页抓取 GitHub REST/Search page,过滤 PR 后再返回 issue,并在输出中披露 `pagination.fetchedPages/rawCount/hasMore`;`hasMore=true` 时只能说明当前有界扫描未穷尽,禁止把它当作“仓库没有更多 issue”。`--search` 使用 GitHub Search Issues API,并自动追加 `repo:<owner>/<name>`、`type:issue` 和 state qualifier,用于创建新 issue 前做低摩擦查重;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。`--label` 是 GitHub REST `labels=label1,label2` 或 Search `label:` 服务端过滤,支持重复 `--label` 和逗号分隔;filter 不在本命令上下文中使用(如 `issue read`、`pr list`)必须结构化失败并指明 `gh issue create/list/stale-close` 才是合法作用域。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。`--raw|--full` 在 `gh issue list` 上是绕过 20 KiB stdout 截断的显式开关:响应结果会带 `noDump=true`,`output.ts` 据此跳过 head/tail 替换并把完整数据 inline 输出;当响应未超阈值时 `--raw|--full` 行为等价默认。 +- `gh issue lifecycle`:`--state` 只能作为 `gh issue list` / `gh issue board-row list` / `gh pr list` 的过滤参数;`gh issue update` / `gh issue edit` 只写 body/title,**不接受** `--state` 改 open/closed。把 `gh issue update <n> --state closed` 落到错命令上时,CLI 必须返回 `validation-failed` 并显式提示 `gh issue close <n>` / `gh issue reopen <n>`(PR 用 `gh pr close|reopen <n>`),并把 5 条受支持命令放进 `supportedCommands`,禁止把"无 `--state` 改 issue 状态"的命令升级为"接受 `--state`"。`gh issue close|reopen` 成功输出默认是 compact issue 摘要,不得回显完整 `issue.body`;需要正文时后续使用返回的 `readCommands` 或 `gh issue read --json body|--full|--raw`。issue 硬删除走 `close`,PR 硬删除走 `close`,两者都没有"delete"语义。 +- `gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]` 是可复用批量生命周期清理入口,用于“超过 N 小时无回复或修改的 open issue 一律关闭”这类策略。判定基准固定为 GitHub `updatedAt < observedAt - inactiveHours`,issue comment、body/title 修改和 state 变化都会刷新 `updatedAt` 并视为活跃;PR 必须过滤,不参与 issue 关闭。默认 `inactive-hours=48`,默认扫描预算为 issue list 上限,输出必须包含 `observedAt`、`cutoffAt`、`scannedCount`、`staleCount`、`pagination.hasMore`、候选/关闭 issue 的 compact 摘要和失败列表,不得打印完整正文。正式关闭前建议先跑 `--dry-run`;真实执行后用同一命令加 `--dry-run` 验证 `staleCount=0`,且只有 `hasMore=false` 才能把当前扫描视为完整穷尽。HWLAB 当前长期策略使用 `bun scripts/cli.ts gh issue stale-close --repo pikasTech/HWLAB --inactive-hours 48 --dry-run` 观察,再移除 `--dry-run` 关闭。 +- `gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`,CLI 必须结构化失败并给出 `gh issue read <number> --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw` 与 `--full` 是显式完整披露别名:read/view 会选择完整支持字段集;issue update/edit 只有显式传入时才在成功响应里包含完整 `.data.issue.body`。当最终 `gh` JSON 超过 20 KiB 时,CLI 必须把完整 JSON 写入 `/tmp/unidesk-cli-output/*.json`,stdout 只返回 `outputTruncated=true`、dump path、总 bytes/lines 和 head/tail 预览。默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file|-> [--title ...] [--dry-run] [--full|--raw]`、`gh issue comment create <number> (--body-file <file|->|--body <short-text>) [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]`、`gh issue stale-close [--inactive-hours N] [--dry-run]` 都走 REST,不依赖 `gh` binary。`--body` 仅用于 issue comment 的短单行文本;空白、多行、疑似 shell 污染、secret-like 或过长 inline body 必须结构化失败,Markdown/生成内容/长评论继续用 `--body-file <file|->`。`--label` 用于 `issue create`、`issue list` 和 `issue stale-close`,支持重复传入和逗号分隔;`issue create --dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 - `gh issue update <number> --mode replace|append --body-file <file|->` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件或 stdin 正文替换现有 body;`append` 先读取当前 body,再按 UTF-8 文件或 stdin 字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和指挥简报类 issue 是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用 #20/#24 legacy guard:#20 必须包含 `## 看板(OPEN)`,#24 legacy 指挥简报必须包含 `## 常驻观察与长期建议`。显式 `--body-profile commander-brief` 不再固定 #24;#24 仍兼容,标题为 `YYYY-MM-DD 指挥简报(北京时间)` 或既有正文首行/关键 heading 表明为每日滚动指挥简报的 issue 也合法,并仍必须包含 `## 常驻观察与长期建议`。对非简报 issue 显式使用 `commander-brief` 会结构化失败为 `profile-issue-mismatch`。`--dry-run` 不 PATCH GitHub,输出有界 `bodyPreview`/`bodyPreviewLines`、新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格、shell 污染信号、`guard`、`concurrency`、`bodyOnlySafety` 和 `wouldPatch`;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入默认先读取当前 issue,执行 guard 和显式 `--expect-*` 并发校验,再 PATCH;成功输出 compact issue 摘要、old/new body SHA、updatedAt、bodySource 和 drill-down `readCommands`,不包含完整 `issue.body`。完整正文必须显式 `--full|--raw` 或后续执行 `readCommands.body/full/raw` 获取。 - #20 只允许承担长期 UniDesk 指挥官 / Code Queue / CLI / infra 治理总看板职责;每日进展必须写入当天滚动指挥简报 issue,并由 #20 顶部“指挥简报索引”引用。HWLAB 用户反馈、Cloud Workbench、DEV-LIVE、M3 虚拟硬件可信闭环等产品 issue 必须写到 `pikasTech/HWLAB`;#20 只可记录 UniDesk 侧 commander/Code Queue/CLI/infra 支撑工作。`gh issue read/view 20` 会返回 `codeQueueBoardHint`;`gh issue update/edit 20` 的 body guard 会拒绝 `## 更新 YYYY-MM-DD HH:mm 北京时间`、`## YYYY-MM-DD HH:mm 北京时间指挥更新` 和 `### YYYY-MM-DD HH:mm CST:...` 这类简报段落;把 `pikasTech/HWLAB#N`、`HWLAB#N` 或 HWLAB 产品/live 验证行写入 #20 时只返回 warning 和 `codeQueueBoardHint`,不再拒绝正文 replace,以避免历史正文或治理交叉引用造成次生阻塞;`gh issue board-row list|get|update|add|move|delete|upsert --board-issue 20` 也会返回同一 hint,提醒不要把每日简报或 HWLAB 产品看板混入 #20。 - `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`;`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL` 只接受 backend-core `/api/microservices/claudeqq/proxy` 等价路径,非 proxy URL 会结构化为 `notification-path-unavailable`。可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`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/scripts/src/gh.ts b/scripts/src/gh.ts index c8a1ba41..d5a4001e 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -10,6 +10,11 @@ const PREVIEW_CHARS = 240; const REQUEST_TIMEOUT_MS = 20_000; const MIN_SAFE_ISSUE_BODY_CHARS = 20; const MAX_INLINE_ISSUE_COMMENT_BODY_CHARS = 1000; +const GITHUB_REST_PAGE_SIZE = 100; +const MAX_ISSUE_LIST_LIMIT = 1000; +const DEFAULT_STALE_CLOSE_INACTIVE_HOURS = 48; +const MAX_STALE_CLOSE_INACTIVE_HOURS = 24 * 365 * 10; +const ISSUE_LIFECYCLE_PREVIEW_LIMIT = 80; const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL = "http://backend-core:8080/api/microservices/claudeqq/proxy"; const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593"; const CODE_QUEUE_BOARD_TARGET_ISSUE = 20; @@ -35,7 +40,7 @@ const GH_VALUE_OPTIONS = new Set([ "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr", - "--search", + "--search", "--inactive-hours", ]); const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]); const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS; @@ -318,6 +323,7 @@ interface GitHubOptions { raw: boolean; full: boolean; limit: number; + inactiveHours: number; boardIssue: number; knownMetaIssues: number[]; ignoredIssues: number[]; @@ -419,6 +425,24 @@ interface GitHubIssueSearchResponse { items?: GitHubIssue[]; } +interface GitHubIssueListPage { + path: string; + rawCount: number; + issueCount: number; +} + +interface GitHubIssueListResult { + items: GitHubIssue[]; + rawCount: number; + fetchedPages: number; + pageSize: number; + exhausted: boolean; + hasMore: boolean; + pages: GitHubIssueListPage[]; + searchTotalCount?: number; + searchIncomplete?: boolean; +} + interface GitHubPullRequest { id: number; number: number; @@ -592,6 +616,14 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe return Math.min(value, maxValue); } +function positiveNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number { + const raw = optionValue(args, name); + if (raw === undefined) return defaultValue; + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`); + return Math.min(value, maxValue); +} + function validateEnumValue<T extends string>(name: string, raw: string, allowedValues: readonly T[]): T { if ((allowedValues as readonly string[]).includes(raw)) return raw as T; throw new Error(`unsupported ${name} ${raw}; supported values: ${allowedValues.join(",")}`); @@ -759,13 +791,18 @@ function parseOptions(args: string[]): GitHubOptions { validateKnownOptions(args); const [top, sub] = args; const requestedJsonFields = commaListOption(args, "--json"); - const limitMax = top === "pr" && (sub === "files" || sub === "diff") ? MAX_PR_FILES_LIMIT : 100; + const limitMax = top === "pr" && (sub === "files" || sub === "diff") + ? MAX_PR_FILES_LIMIT + : top === "issue" && (sub === "list" || sub === "stale-close" || sub === "scan-escape" || sub === "cleanup-plan") + ? MAX_ISSUE_LIST_LIMIT + : 100; return { repo: resolveRepoOption(args), dryRun: hasFlag(args, "--dry-run"), raw: hasFlag(args, "--raw"), full: hasFlag(args, "--full"), - limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : 30, limitMax), + limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : top === "issue" && sub === "stale-close" ? MAX_ISSUE_LIST_LIMIT : 30, limitMax), + inactiveHours: positiveNumberOption(args, "--inactive-hours", DEFAULT_STALE_CLOSE_INACTIVE_HOURS, MAX_STALE_CLOSE_INACTIVE_HOURS), boardIssue: positiveIntegerSingleOption(args, "--board-issue", CODE_QUEUE_BOARD_TARGET_ISSUE), knownMetaIssues: positiveIntegerValuesOption(args, "--known-meta-issue"), ignoredIssues: positiveIntegerValuesOption(args, "--ignore-issue"), @@ -1992,6 +2029,48 @@ function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; prev return summary; } +function issueLifecycleSummary(issue: GitHubIssue): Record<string, unknown> { + return { + id: issue.id, + number: issue.number, + title: issue.title, + state: issue.state, + url: issue.html_url, + author: issue.user?.login ?? null, + createdAt: issue.created_at ?? null, + updatedAt: issue.updated_at ?? null, + commentCount: issue.comments ?? null, + labels: issueLabelNames(issue), + bodyChars: (issue.body ?? "").length, + bodySha: bodySha(issue.body ?? ""), + bodyOmitted: true, + fullBodyIncluded: false, + }; +} + +function issueLifecycleDisclosure(repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> { + return { + defaultCompact: true, + explicitFullDisclosure: false, + fullBodyIncluded: false, + bodyOmitted: true, + dryRunBoundedPreview: dryRun, + note: "Issue lifecycle write output omits full issue.body; use readCommands.full/raw or gh issue read --json body when full text is needed.", + readCommands: issueBodyReadCommands(repo, issueNumber), + }; +} + +function issueLifecycleBatchSummary(issues: GitHubIssue[]): Record<string, unknown> { + const visible = issues.slice(0, ISSUE_LIFECYCLE_PREVIEW_LIMIT); + return { + count: issues.length, + returned: visible.length, + omitted: Math.max(0, issues.length - visible.length), + numbers: visible.map((issue) => issue.number), + issues: visible.map(issueLifecycleSummary), + }; +} + 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 { @@ -4915,17 +4994,78 @@ 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, search?: string): Promise<GitHubIssue[] | GitHubErrorPayload | GitHubIssueSearchResponse> { +function githubSearchLabelQualifier(label: string): string { + if (/^[A-Za-z0-9_.:-]+$/u.test(label)) return `label:${label}`; + return `label:"${label.replace(/["\\]/gu, "\\$&")}"`; +} + +function issueListPaginationSummary(result: GitHubIssueListResult): Record<string, unknown> { + return { + pageSize: result.pageSize, + fetchedPages: result.fetchedPages, + exhausted: result.exhausted, + hasMore: result.hasMore, + pages: result.pages, + }; +} + +async function listIssues(token: string, repo: string, state: IssueListState, limit: number, search?: string, labels: string[] = []): Promise<GitHubIssueListResult | GitHubErrorPayload> { const { owner, name } = repoParts(repo); const normalizedSearch = String(search ?? "").trim(); - if (normalizedSearch) { - const qualifiers = [`repo:${owner}/${name}`, "type:issue"]; - if (state !== "all") qualifiers.push(`state:${state}`); - const params = new URLSearchParams({ q: `${normalizedSearch} ${qualifiers.join(" ")}`, per_page: String(limit) }); - return githubRequest<GitHubIssueSearchResponse>(token, "GET", `/search/issues?${params.toString()}`); + const pageSize = GITHUB_REST_PAGE_SIZE; + const collected: GitHubIssue[] = []; + const pages: GitHubIssueListPage[] = []; + let rawCount = 0; + let fetchedPages = 0; + let exhausted = false; + let searchTotalCount: number | undefined; + let searchIncomplete: boolean | undefined; + const maxPages = Math.max(1, Math.ceil(limit / pageSize) + 10); + for (let page = 1; collected.length < limit && page <= maxPages; page += 1) { + const perPage = pageSize; + let path: string; + let rawIssueItems: GitHubIssue[]; + if (normalizedSearch) { + const qualifiers = [`repo:${owner}/${name}`, "type:issue"]; + if (state !== "all") qualifiers.push(`state:${state}`); + for (const label of labels) qualifiers.push(githubSearchLabelQualifier(label)); + const params = new URLSearchParams({ q: `${normalizedSearch} ${qualifiers.join(" ")}`, per_page: String(perPage), page: String(page) }); + path = `/search/issues?${params.toString()}`; + const response = await githubRequest<GitHubIssueSearchResponse>(token, "GET", path); + if (isGitHubError(response)) return response; + rawIssueItems = Array.isArray(response.items) ? response.items : []; + if (searchTotalCount === undefined) searchTotalCount = response.total_count; + searchIncomplete = searchIncomplete === true || response.incomplete_results === true; + } else { + const params = new URLSearchParams({ state, per_page: String(perPage), page: String(page) }); + if (labels.length > 0) params.set("labels", labels.join(",")); + path = `/repos/${owner}/${name}/issues?${params.toString()}`; + const response = await githubRequest<GitHubIssue[]>(token, "GET", path); + if (isGitHubError(response)) return response; + rawIssueItems = response; + } + const issueItems = rawIssueItems.filter((issue) => issue.pull_request === undefined); + rawCount += rawIssueItems.length; + fetchedPages += 1; + pages.push({ path, rawCount: rawIssueItems.length, issueCount: issueItems.length }); + collected.push(...issueItems); + if (rawIssueItems.length < perPage) { + exhausted = true; + break; + } } - const params = new URLSearchParams({ state, per_page: String(limit) }); - return githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?${params.toString()}`); + const hasMore = collected.length > limit || !exhausted; + return { + items: collected.slice(0, limit), + rawCount, + fetchedPages, + pageSize, + exhausted, + hasMore, + pages, + searchTotalCount, + searchIncomplete, + }; } async function getIssue(token: string, repo: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> { @@ -4975,33 +5115,67 @@ async function issueView(repo: string, token: string, issueNumber: number, jsonF return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure); } -async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string): Promise<GitHubCommandResult> { +function shellWord(value: string): string { + return JSON.stringify(value); +} + +function issueListNextCommand(repo: string, state: IssueListState, limit: number, search: string, labels: string[]): string { + const parts = [ + "bun scripts/cli.ts gh issue list", + "--repo", shellWord(repo), + "--state", state, + "--limit", String(Math.min(limit * 2, MAX_ISSUE_LIST_LIMIT)), + ]; + if (search.length > 0) parts.push("--search", shellWord(search)); + for (const label of labels) parts.push("--label", shellWord(label)); + return parts.join(" "); +} + +async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string, labels: string[] = [], noDump = false): Promise<GitHubCommandResult> { const normalizedSearch = String(search ?? "").trim(); - const rawIssues = await listIssues(token, repo, state, limit, normalizedSearch); - if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit, search: normalizedSearch || null }); - const searchResponse = Array.isArray(rawIssues) ? null : rawIssues; - const rawIssueItems = Array.isArray(rawIssues) ? rawIssues : Array.isArray(rawIssues.items) ? rawIssues.items : []; - const issues = rawIssueItems.filter((issue) => issue.pull_request === undefined).slice(0, limit); + const result = await listIssues(token, repo, state, limit, normalizedSearch, labels); + if (isGitHubError(result)) return commandError("issue list", repo, result, { state, limit, search: normalizedSearch || null, labels }); + const issues = result.items; const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice(); return { ok: true, command: "issue list", repo, + ...(noDump ? { noDump: true } : {}), state, limit, search: normalizedSearch || null, + labels, count: issues.length, - rawCount: rawIssueItems.length, - searchTotalCount: searchResponse?.total_count, - searchIncomplete: searchResponse?.incomplete_results, + rawCount: result.rawCount, + searchTotalCount: result.searchTotalCount, + searchIncomplete: result.searchIncomplete, + pagination: issueListPaginationSummary(result), + hasMore: result.hasMore, jsonFields: fields, issues: issues.map((issue) => issueListSummary(issue, fields)), + ...(result.hasMore && limit < MAX_ISSUE_LIST_LIMIT + ? { + next: { + command: issueListNextCommand(repo, state, limit, normalizedSearch, labels), + note: "Increase --limit to continue scanning beyond the current bounded result set.", + }, + } + : result.hasMore + ? { + scanLimit: { + maxLimitReached: true, + maxLimit: MAX_ISSUE_LIST_LIMIT, + note: "The command reached the maximum bounded issue scan; narrow with --state/--label/--search before treating the repository as exhausted.", + }, + } + : {}), request: { method: "GET", path: normalizedSearch ? "/search/issues" : "/repos/{owner}/{repo}/issues", query: normalizedSearch - ? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}`, per_page: limit } - : { state, per_page: limit }, + ? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE } + : { state, labels, per_page: GITHUB_REST_PAGE_SIZE }, }, note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.", }; @@ -5405,11 +5579,164 @@ async function commentDelete(repo: string, token: string, ownerKind: "issue" | " } async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> { - if (dryRun) return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", dryRun: true, repo, issueNumber, wouldPatch: { state } }; + const command = state === "closed" ? "issue close" : "issue reopen"; + if (dryRun) { + return { + ok: true, + command, + dryRun: true, + repo, + issueNumber, + disclosure: issueLifecycleDisclosure(repo, issueNumber, true), + readCommands: issueBodyReadCommands(repo, issueNumber), + wouldPatch: { state }, + }; + } const { owner, name } = repoParts(repo); const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state }); - if (isGitHubError(issue)) return commandError(state === "closed" ? "issue close" : "issue reopen", repo, issue, { issueNumber }); - return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true }; + if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber }); + return { + ok: true, + command, + repo, + issue: issueLifecycleSummary(issue), + disclosure: issueLifecycleDisclosure(repo, issueNumber, false), + readCommands: issueBodyReadCommands(repo, issueNumber), + rest: true, + }; +} + +function parseGitHubTimestamp(value: string | undefined): number | null { + if (value === undefined) return null; + const millis = Date.parse(value); + return Number.isFinite(millis) ? millis : null; +} + +function inactiveIssueCandidates(issues: GitHubIssue[], cutoffMs: number): GitHubIssue[] { + return issues.filter((issue) => { + const updatedAtMs = parseGitHubTimestamp(issue.updated_at); + return updatedAtMs !== null && updatedAtMs < cutoffMs; + }); +} + +async function closeIssueForBatch(repo: string, token: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state: "closed" }); +} + +async function issueStaleClose(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { + const command = "issue stale-close"; + const observedAt = new Date(); + const cutoffMs = observedAt.getTime() - Math.round(options.inactiveHours * 60 * 60 * 1000); + const cutoffAt = new Date(cutoffMs).toISOString(); + const result = await listIssues(token, repo, "open", options.limit, "", options.labels); + if (isGitHubError(result)) { + return commandError(command, repo, result, { + state: "open", + limit: options.limit, + inactiveHours: options.inactiveHours, + cutoffAt, + labels: options.labels, + }); + } + const staleIssues = inactiveIssueCandidates(result.items, cutoffMs); + const base = { + ok: true, + command, + repo, + dryRun: options.dryRun, + state: "open", + inactiveHours: options.inactiveHours, + observedAt: observedAt.toISOString(), + cutoffAt, + limit: options.limit, + labels: options.labels, + scannedCount: result.items.length, + rawCount: result.rawCount, + staleCount: staleIssues.length, + pagination: issueListPaginationSummary(result), + hasMore: result.hasMore, + stale: issueLifecycleBatchSummary(staleIssues), + policy: { + basis: "GitHub issue updatedAt", + selectedWhen: "updatedAt is older than observedAt - inactiveHours", + commentsAndStateChangesCountAsActivity: true, + pullRequestsFiltered: true, + }, + readCommands: { + dryRun: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${options.limit} --dry-run`, + openList: `bun scripts/cli.ts gh issue list --repo ${repo} --state open --limit ${options.limit} --json number,title,state,url,updatedAt`, + }, + ...(result.hasMore && options.limit < MAX_ISSUE_LIST_LIMIT + ? { + next: { + command: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${Math.min(options.limit * 2, MAX_ISSUE_LIST_LIMIT)} --dry-run`, + note: "The scan reached the bounded --limit before GitHub pagination was exhausted; increase --limit before treating the cleanup as complete.", + }, + } + : result.hasMore + ? { + scanLimit: { + maxLimitReached: true, + maxLimit: MAX_ISSUE_LIST_LIMIT, + note: "The cleanup reached the maximum bounded issue scan; narrow with --label or split the policy before treating it as complete.", + }, + } + : {}), + }; + if (options.dryRun) { + return { + ...base, + planned: true, + wouldCloseCount: staleIssues.length, + wouldCloseNumbers: staleIssues.map((issue) => issue.number), + note: staleIssues.length === 0 + ? "No open issues matched the inactive-hours policy; no GitHub issue would be closed." + : "Dry-run only; no GitHub issue was closed.", + }; + } + + const closed: GitHubIssue[] = []; + const failures: Array<Record<string, unknown>> = []; + for (const issue of staleIssues) { + const closedIssue = await closeIssueForBatch(repo, token, issue.number); + if (isGitHubError(closedIssue)) { + failures.push({ + number: issue.number, + title: issue.title, + url: issue.html_url, + updatedAt: issue.updated_at ?? null, + degradedReason: closedIssue.degradedReason, + runnerDisposition: closedIssue.runnerDisposition, + message: closedIssue.message, + status: closedIssue.status ?? null, + }); + continue; + } + closed.push(closedIssue); + } + + return { + ...base, + dryRun: false, + planned: false, + closedCount: closed.length, + failedCount: failures.length, + closed: issueLifecycleBatchSummary(closed), + failures, + rest: true, + ok: failures.length === 0, + ...(failures.length > 0 + ? { + degradedReason: "github-transient" as GitHubDegradedReason, + runnerDisposition: "infra-blocked" as RunnerDisposition, + retryable: true, + } + : {}), + note: failures.length === 0 + ? "Closed all open issues that matched the inactive-hours policy." + : "Some stale issue close operations failed; rerun the same command after checking failures.", + }; } function escapeSnippet(text: string, index: number, radius = 80): string { @@ -5703,10 +6030,9 @@ function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupS } async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise<GitHubCommandResult> { - const { owner, name } = repoParts(repo); - const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`); + const issues = await listIssues(token, repo, "all", limit); if (isGitHubError(issues)) return commandError(commandName, repo, issues); - const issueOnly = issues.filter((issue) => issue.pull_request === undefined).slice(0, limit); + const issueOnly = issues.items; const patterns = textFindingPatterns(); const findings: EscapeScanEntry[] = []; @@ -5754,7 +6080,8 @@ async function issueScanEscape(repo: string, token: string, limit: number, dryRu dryRun, planned: true, scannedIssues: issueOnly.length, - rawIssues: issues.length, + rawIssues: issues.rawCount, + pagination: issueListPaginationSummary(issues), scannedComments, findings, cleanupSuggestions, @@ -5844,7 +6171,7 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> { }; } -async function prList(repo: string, token: string, state: PrListState, limit: number, jsonFields: PrListJsonField[] | undefined): Promise<GitHubCommandResult> { +async function prList(repo: string, token: string, state: PrListState, limit: number, jsonFields: PrListJsonField[] | undefined, noDump = false): Promise<GitHubCommandResult> { const { owner, name } = repoParts(repo); const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=${state}&per_page=${limit}`); if (isGitHubError(prs)) return commandError("pr list", repo, prs, { state, limit }); @@ -5853,6 +6180,7 @@ async function prList(repo: string, token: string, state: PrListState, limit: nu ok: true, command: "pr list", repo, + ...(noDump ? { noDump: true } : {}), state, limit, count: prs.length, @@ -5959,7 +6287,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 [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]", + "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 <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]", "bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]", "bun scripts/cli.ts gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--repo owner/name] [--dry-run]", @@ -5969,6 +6297,7 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue comment create <number> --body-file <file|->|--body <short-text> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]", "bun scripts/cli.ts gh issue delete <number> [unsupported: use close]", "bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]", "bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]", @@ -6002,10 +6331,10 @@ 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; --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,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.", - "--raw and --full are explicit full-disclosure aliases for gh issue read/view/update/edit and gh pr 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.", + "--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.", "--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.", @@ -6014,6 +6343,8 @@ export function ghHelp(): unknown { "issue update --body-file accepts files or - for stdin, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).", "issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.", "issue comment create accepts --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.", + "issue close/reopen default success output is compact and omits full issue.body. Use gh issue read <number> --json body or --full/--raw on read when full text is needed.", + "issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.", "For one-shot issue writes, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - or gh issue comment create <number> --body-file -. When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.", "For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-file <file|-> for long or multiline content.", "issue scan-escape classifies literal \\n findings as suspected-pollution, explanatory-mention, or risk, and emits cleanupSuggestions with body/comment ids plus diff-like previews. cleanup-plan is an alias that remains dry-run/read-only.", @@ -6064,13 +6395,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list"); } } - if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "update" || sub === "edit")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "preflight" || sub === "closeout")))) { + if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "list" || sub === "update" || sub === "edit")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view/update/edit, gh pr read/view, and gh pr preflight/closeout.", { + return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit, gh pr list/read/view, and gh pr preflight/closeout.", { supportedCommands: [ + "bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full", "bun scripts/cli.ts gh issue read owner/name#<number> --raw", "bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments", "cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full", + "bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full", "bun scripts/cli.ts gh pr read owner/name#<number> --raw", `bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`, "bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full", @@ -6135,14 +6468,18 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; return validationError(command, options.repo, "--category, --branch, --tasks, --summary, --focus, --validation, and --progress are only supported by gh issue board-row upsert"); } - if (optionWasProvided(args, "--label") && !(top === "issue" && (sub === "create" || sub === "list"))) { + if (optionWasProvided(args, "--label") && !(top === "issue" && (sub === "create" || sub === "list" || sub === "stale-close"))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--label is only supported by gh issue create and gh issue list"); + return validationError(command, options.repo, "--label is only supported by gh issue create, gh issue list, and gh issue stale-close"); } if (optionWasProvided(args, "--search") && !(top === "issue" && sub === "list")) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; return validationError(command, options.repo, "--search is only supported by gh issue list"); } + if (optionWasProvided(args, "--inactive-hours") && !(top === "issue" && sub === "stale-close")) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--inactive-hours is only supported by gh issue stale-close"); + } if (top === "auth" && sub === "status") return authStatus(options.repo); @@ -6234,7 +6571,8 @@ 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, options.search); + if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields, options.search, options.labels, options.raw || options.full); + if (sub === "stale-close") return issueStaleClose(options.repo, token, options); if (sub === "create") return issueCreate(options.repo, token, options); if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options); if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update"); @@ -6356,7 +6694,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult const { token, probe } = resolveToken(true); const missing = authRequired(options.repo, `pr ${sub}`, probe); if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); - if (sub === "list") return prList(options.repo, token, options.prListState, options.limit, options.prListJsonFields); + if (sub === "list") return prList(options.repo, token, options.prListState, options.limit, options.prListJsonFields, options.raw || options.full); } return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() }); diff --git a/scripts/src/output.ts b/scripts/src/output.ts index 456f0c66..49ebaa79 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -80,7 +80,7 @@ function shouldSuppressStack(prefix: string): boolean { function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string { const fullText = `${JSON.stringify(envelope, null, 2)}\n`; - if (!shouldDumpLargeOutput(command, fullText)) return fullText; + if (!shouldDumpLargeOutput(command, fullText, envelope)) return fullText; const dump = dumpLargeOutput(command, fullText); const compactPayload = { @@ -96,9 +96,10 @@ function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string { return `${JSON.stringify(compactEnvelope, null, 2)}\n`; } -function shouldDumpLargeOutput(command: string, text: string): boolean { +function shouldDumpLargeOutput(command: string, text: string, envelope: JsonEnvelope<unknown>): boolean { if (!(command === "gh" || command.startsWith("gh "))) return false; if (process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_DISABLED === "1") return false; + if (typeof envelope.data === "object" && envelope.data !== null && (envelope.data as { noDump?: unknown }).noDump === true) return false; const threshold = configuredDumpThresholdBytes(); return Buffer.byteLength(text, "utf8") > threshold; }