From edc437cdd8dd457c8087a295774b118e6d1f077c Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:16:53 +0800 Subject: [PATCH] refactor: split github cli implementation (#908) Co-authored-by: Codex --- .../specs/PJ2026-0106-platform-ops.md | 24 +- .../PJ2026-010606-github-controlled-entry.md | 203 + scripts/src/gh.ts | 8850 +---------------- scripts/src/gh/attachments.ts | 455 + scripts/src/gh/auth-and-safety.ts | 120 + scripts/src/gh/auth-pr-read.ts | 204 + scripts/src/gh/board-audit.ts | 102 + scripts/src/gh/board-commands.ts | 331 + scripts/src/gh/board-parser.ts | 530 + scripts/src/gh/board-plans.ts | 962 ++ scripts/src/gh/body-guards.ts | 234 + scripts/src/gh/body-input.ts | 222 + scripts/src/gh/body-profiles.ts | 229 + scripts/src/gh/client.ts | 384 + scripts/src/gh/comments-and-state.ts | 339 + scripts/src/gh/escape-scan.ts | 365 + scripts/src/gh/help.ts | 195 + scripts/src/gh/index.ts | 522 + scripts/src/gh/issue-list.ts | 143 + scripts/src/gh/issue-read.ts | 153 + scripts/src/gh/issue-summary.ts | 201 + scripts/src/gh/issue-write.ts | 406 + scripts/src/gh/notify-claudeqq.ts | 204 + scripts/src/gh/options.ts | 371 + scripts/src/gh/pr-commands.ts | 279 + scripts/src/gh/pr-merge.ts | 308 + scripts/src/gh/pr-summary.ts | 516 + scripts/src/gh/refs.ts | 373 + scripts/src/gh/render.ts | 71 + scripts/src/gh/repo-compare.ts | 137 + scripts/src/gh/stale-close.ts | 142 + scripts/src/gh/types.ts | 678 ++ 32 files changed, 9404 insertions(+), 8849 deletions(-) create mode 100644 project-management/PJ2026-01/specs/PJ2026-010606-github-controlled-entry.md create mode 100644 scripts/src/gh/attachments.ts create mode 100644 scripts/src/gh/auth-and-safety.ts create mode 100644 scripts/src/gh/auth-pr-read.ts create mode 100644 scripts/src/gh/board-audit.ts create mode 100644 scripts/src/gh/board-commands.ts create mode 100644 scripts/src/gh/board-parser.ts create mode 100644 scripts/src/gh/board-plans.ts create mode 100644 scripts/src/gh/body-guards.ts create mode 100644 scripts/src/gh/body-input.ts create mode 100644 scripts/src/gh/body-profiles.ts create mode 100644 scripts/src/gh/client.ts create mode 100644 scripts/src/gh/comments-and-state.ts create mode 100644 scripts/src/gh/escape-scan.ts create mode 100644 scripts/src/gh/help.ts create mode 100644 scripts/src/gh/index.ts create mode 100644 scripts/src/gh/issue-list.ts create mode 100644 scripts/src/gh/issue-read.ts create mode 100644 scripts/src/gh/issue-summary.ts create mode 100644 scripts/src/gh/issue-write.ts create mode 100644 scripts/src/gh/notify-claudeqq.ts create mode 100644 scripts/src/gh/options.ts create mode 100644 scripts/src/gh/pr-commands.ts create mode 100644 scripts/src/gh/pr-merge.ts create mode 100644 scripts/src/gh/pr-summary.ts create mode 100644 scripts/src/gh/refs.ts create mode 100644 scripts/src/gh/render.ts create mode 100644 scripts/src/gh/repo-compare.ts create mode 100644 scripts/src/gh/stale-close.ts create mode 100644 scripts/src/gh/types.ts diff --git a/project-management/PJ2026-01/specs/PJ2026-0106-platform-ops.md b/project-management/PJ2026-01/specs/PJ2026-0106-platform-ops.md index f5f2b311..ce8e9dbe 100644 --- a/project-management/PJ2026-01/specs/PJ2026-0106-platform-ops.md +++ b/project-management/PJ2026-01/specs/PJ2026-0106-platform-ops.md @@ -32,7 +32,7 @@ ### 2.1 目的 -平台运维负责为 HWLAB 各业务方向提供受控发布、源码同步、YAML-first 自动化分布式运维、公开入口和 Prometheus 运维监控支撑。它是 HWLAB 的后勤保障方向,不把运维动作包装成对外产品能力,也不替代业务 L1 定义功能需求。 +平台运维负责为 HWLAB 各业务方向提供受控发布、源码同步、YAML-first 自动化分布式运维、公开入口、Prometheus 运维监控和 GitHub 受控入口支撑。它是 HWLAB 的后勤保障方向,不把运维动作包装成对外产品能力,也不替代业务 L1 定义功能需求。 ### 2.2 范围内 @@ -42,6 +42,7 @@ - YAML-first 运维下的 SecretRef 子项,包括 sourceRef、targetKey、fingerprint、presence、轮换和敏感输出约束。 - FRP、Caddy、domain、TLS、public health 和公开入口漂移处理。 - Prometheus metrics、OpenTelemetry tracing、scrape target、alert rule、服务健康指标、资源指标、trace backend 查询和运行面监控口径。 +- GitHub issue/PR、正文 patch、board、attachment、scan、preflight 和 merge 的受控 CLI 入口。 ### 2.3 范围外 @@ -51,6 +52,7 @@ - HWPOD 资源、硬件事实和 probe 能力归 [硬件池](PJ2026-0101-hardware-pool.md)。 - CaseRun 评价、回放、训练反馈和执行结果语义归 [HarnessRL](PJ2026-0103-harness-rl.md)。 - Web、CLI 和 HTTP API 的用户入口体验归 [客户端](PJ2026-0104-client.md)。 +- GitHub issue/PR 中承载的业务需求、执行证据和讨论内容归对应业务规格或执行 issue;平台运维只负责 GitHub 受控入口本身。 ## 3. 术语表 @@ -63,6 +65,7 @@ | 公开入口 | FRP、Caddy、域名、TLS 和健康检查共同形成的外部访问面。 | | 运维监控 | 通过 Prometheus 采集 metrics、通过 OpenTelemetry 关联 trace/span,并维护 scrape target、trace backend、alert rule 和服务健康指标的运维监控能力。 | | Trace Explorer URL 模板 | YAML-first 运维配置中声明的 trace backend 打开链接模板,只允许静态 URL 与 `{trace_id}` 占位符,用于客户端展示“打开 Trace”。 | +| GitHub 受控入口 | 通过 UniDesk `gh` CLI 统一执行 issue/PR 读写、正文 patch、board mutation、attachment、preflight 和 merge guard 的操作面。 | ## 4. 系统边界和接口 @@ -72,10 +75,10 @@ | --- | --- | | 外部使用者 | HWLAB 各 L1 模块、平台管理员、发布操作人员和需要运行状态的自动化任务。 | | 外部输入 | Git commit、PR merge、镜像产物、YAML 配置、SecretRef 子项、公开入口声明、metrics endpoint、OTLP endpoint、Trace Explorer URL 模板、服务健康指标和运行状态查询。 | -| 受控资源 | CI/CD pipeline、Git mirror、运行 lane、Kubernetes 资源、YAML-first 运维配置、SecretRef 下发、FRP/Caddy、Prometheus scrape target、OTel Collector、trace backend、metrics 和 alert rule。 | -| 外部输出 | 发布状态、source commit、配置渲染结果、Secret 摘要、public URL health、Prometheus target 状态、metrics 查询结果、trace 查询摘要、Trace Explorer URL 模板摘要、alert 状态和运行支撑记录。 | -| 用户接口 | UniDesk CLI、HWLAB 控制面命令、受控 Secret 命令、平台基础设施命令、Prometheus 查询、OpenTelemetry trace 查询和告警入口。 | -| 系统边界 | 平台运维负责交付、YAML-first 自动化分布式运维、入口、Prometheus metrics 和 OpenTelemetry tracing 可控;不定义硬件、Agent、Harness、客户端或用户管理的业务成功标准。 | +| 受控资源 | CI/CD pipeline、Git mirror、运行 lane、Kubernetes 资源、YAML-first 运维配置、SecretRef 下发、FRP/Caddy、Prometheus scrape target、OTel Collector、trace backend、metrics、alert rule 和 GitHub 受控入口。 | +| 外部输出 | 发布状态、source commit、配置渲染结果、Secret 摘要、public URL health、Prometheus target 状态、metrics 查询结果、trace 查询摘要、Trace Explorer URL 模板摘要、alert 状态、GitHub 写入摘要和运行支撑记录。 | +| 用户接口 | UniDesk CLI、HWLAB 控制面命令、受控 Secret 命令、平台基础设施命令、Prometheus 查询、OpenTelemetry trace 查询、告警入口和 GitHub 受控 CLI。 | +| 系统边界 | 平台运维负责交付、YAML-first 自动化分布式运维、入口、Prometheus metrics、OpenTelemetry tracing 和 GitHub 操作入口可控;不定义硬件、Agent、Harness、客户端或用户管理的业务成功标准。 | ## 5. 内部分工与规格索引 @@ -86,6 +89,7 @@ | PJ2026-010603 | YAML-first运维 | [PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md) | YAML-first 自动化分布式运维;SecretRef、targetKey、fingerprint 是其密钥分发子项 | 各 L1 配置需求、密钥来源 | 全部 L1 | | PJ2026-010604 | 公开入口 | [PJ2026-010604 公开入口](PJ2026-010604-public-entry.md) | FRP、Caddy、domain、TLS、public health 和入口漂移处理 | 客户端/API 服务、运行 lane | 客户端、用户管理、Agent编排 | | PJ2026-010605 | 运维监控 | [PJ2026-010605 运维监控](PJ2026-010605-observability-monitoring.md) | Prometheus metrics、OpenTelemetry tracing、scrape target、trace backend、alert rule、服务健康指标和资源指标 | 运行面 metrics endpoint、OTLP endpoint、各 L1 服务健康指标和关键请求 span | 全部 L1 | +| PJ2026-010606 | GitHub入口 | [PJ2026-010606 GitHub入口](PJ2026-010606-github-controlled-entry.md) | issue/PR 读写、正文 guard、board mutation、attachment、scan、preflight 和 merge guard | GitHub API、源码同步、发布流水 | 项目治理、PR closeout、agent 执行 | ## 6. 原子需求 @@ -144,3 +148,13 @@ YAML-first 运维的命令输出只能披露摘要、对象名、key 名、sourc 运维监控不能替代业务功能实现、CaseRun 评价、Agent 状态模型、硬件事实或用户账本。业务成功仍由对应 L1 定义,监控只用于运行面健康、资源状态和单次请求因果链排障。 Trace Explorer URL 模板、OTLP endpoint、采样/导出开关、trace backend endpoint、Collector/Tempo/Jaeger/Grafana 等组件镜像和保留策略必须归属 YAML-first 运维配置;SPEC 只定义能力和脱敏边界,不硬编码具体 URL、token、namespace、采样率或保留周期。运行面对象和 trace 查询结果只能作为观测证据,不能反推为配置真相。 + +### 6.6 OPS-L1-REQ-006 GitHub 受控入口 + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-L1-REQ-006 | GitHub入口 | [PJ2026-010606 GitHub入口](PJ2026-010606-github-controlled-entry.md) | [源码同步](PJ2026-010602-source-sync.md)、[发布流水](PJ2026-010601-controlled-release.md) | + +平台运维应提供 GitHub 受控入口,使 issue/PR 读写、正文 patch、评论、board mutation、attachment、escape scan、PR preflight 和 merge 都通过 UniDesk CLI 的统一 guard 路径执行。 + +GitHub 受控入口只保证 GitHub 操作路径、保护策略和输出契约可控。issue/PR 正文承载的业务需求、运行证据和收口结论仍归对应业务规格与执行 issue;不能用 GitHub 写入成功替代业务能力验收,也不能绕过受控 CLI 使用原生 `gh`、raw `curl` 或手写 GitHub API 作为正式写入路径。 diff --git a/project-management/PJ2026-01/specs/PJ2026-010606-github-controlled-entry.md b/project-management/PJ2026-01/specs/PJ2026-010606-github-controlled-entry.md new file mode 100644 index 00000000..ff95b4a4 --- /dev/null +++ b/project-management/PJ2026-01/specs/PJ2026-010606-github-controlled-entry.md @@ -0,0 +1,203 @@ +# PJ2026-010606 GitHub入口 + +## 修改历史 + +| 版本 | 对应 commit id | 更新日期 | 变更说明 | +| --- | --- | --- | --- | + +当前正文仍在规格治理草稿中;未定稿前不新增版本号,不为单次编辑追加 `待提交` 版本。 + +## 正文 + +## PJ2026-010606 GitHub入口需求规格 + +## 1. 文档控制 + +| 字段 | 内容 | +| --- | --- | +| 编号 | PJ2026-010606 | +| 短名 | GitHub入口 | +| 层级 | L2 课题 | +| 状态 | 已生效 | +| 实现引用版本 | draft-2026-06-25-gh-split | +| 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | +| 上级规格 | [PJ2026-0106 平台运维](PJ2026-0106-platform-ops.md) | +| 关联规格 | [PJ2026-010602 源码同步](PJ2026-010602-source-sync.md)、[PJ2026-010601 发布流水](PJ2026-010601-controlled-release.md)、[规格治理](spec-governance.md) | +| 规格治理索引 | [规格治理](spec-governance.md) | + +本文采用 ISO/IEC/IEEE 29148 需求规格模板的项目裁剪版:正文只保留 UniDesk GitHub 受控入口的稳定使命、范围、术语、系统边界、内部分工和原子需求。 + +## 2. 目的和范围 + +### 2.1 目的 + +GitHub入口负责为 UniDesk/HWLAB 执行过程提供受控 GitHub issue、PR、正文 patch、附件、看板、预检和 merge 操作面,使 agent 和维护者通过同一条 `bun scripts/cli.ts gh ...` 路径完成正式读写,不绕过 body guard、并发 guard、merge guard、敏感输出约束和中文写入规则。 + +本课题的目标状态是:`scripts/src/gh.ts` 只保留兼容入口和 public exports,GitHub REST/GraphQL client、option parsing、正文输入、body guard、issue/PR 命令、board mutation、附件下载、escape scan、help 和 dispatch 分布在 `scripts/src/gh/` 子模块中;后续新增 GitHub 子命令必须进入对应模块,不继续堆叠到根入口文件。 + +### 2.2 范围内 + +- `bun scripts/cli.ts gh ...` 的 GitHub issue/PR/list/read/write/comment/close/reopen/merge/preflight 等受控命令。 +- `trans gh:/owner/repo/...` 虚拟文件写回所依赖的 issue/PR body read、body patch、sha guard 和 update guard。 +- GitHub REST/GraphQL token resolution、request、错误分类、rate/repo/issue 探测和脱敏输出。 +- issue/PR 正文 stdin/file/patch 输入、短 body safety、profile guard、body sha、updatedAt 和 comment concurrency guard。 +- PR closeout/preflight、merge method、delete branch guard 和 merge 后状态表达。 +- board table parse、board-row mutation plan/dry-run、stale-close、escape scan、attachment list/download dry-run 和下载边界。 +- 默认低噪声摘要、`--json`、`--raw`、`--full`、scoped help 和 bounded dump 行为。 + +### 2.3 范围外 + +- GitHub issue/PR 的项目需求正文归对应 OA 规格和执行 issue;GitHub入口只保证读写路径受控,不定义业务需求。 +- Git remote、mirror、source truth 和 GitOps artifact 归 [源码同步](PJ2026-010602-source-sync.md)。 +- CI/CD、发布候选、rollout 和 runtime 验收归 [发布流水](PJ2026-010601-controlled-release.md)。 +- `trans` 的 SSH、k3s、文件系统和 apply-patch v2 通用路由归 UniDesk CLI/route 参考,不在本规格复写。 +- 原生 `gh` binary、手写 GitHub API、raw `curl` 和一次性脚本不能作为正式 GitHub 写入入口。 + +## 3. 术语表 + +| 术语 | 定义 | +| --- | --- | +| GitHub入口 | UniDesk 通过 `bun scripts/cli.ts gh ...` 暴露的 GitHub issue/PR 受控读写命令面。 | +| body guard | 写入 issue/PR/comment 正文前执行的正文长度、安全 profile、body sha、updatedAt 和并发保护。 | +| merge guard | PR 合并前的 state、draft、conflict、mergeable、status check 和 closeout 预检。 | +| `trans gh:` | 把 GitHub issue/PR 正文映射为虚拟文件的 route,最终仍调用 UniDesk GitHub入口写回。 | +| scoped help | `gh issue --help`、`gh pr merge --help` 这类按命令前缀过滤的有界帮助输出。 | +| bounded dump | CLI 输出超过全局阈值时写入临时 dump,并在 stdout 返回摘要和读取命令。 | + +## 4. 系统边界和接口 + +| 边界项 | 内容 | +| --- | --- | +| 外部使用者 | 维护者、agent、项目治理流程、PR closeout 流程和 `trans gh:` 路由。 | +| 外部输入 | GitHub token 来源、owner/repo、issue/PR number、正文、patch、commentId、board row、attachment 选择、merge method 和输出模式。 | +| 受控资源 | `scripts/src/gh.ts` 兼容入口、`scripts/src/gh/` 子模块、GitHub REST/GraphQL request、body guard、merge guard、board mutation plan、attachment 下载和 help/dispatch。 | +| 外部输出 | GitHub 资源摘要、受控写入结果、body sha、comment id、PR preflight、merge 状态、bounded dump、错误分类和后续 drill-down 命令。 | +| 用户接口 | `bun scripts/cli.ts gh ...`、`bun scripts/cli.ts help` 中的 gh 入口、`trans gh:/owner/repo/...`。 | +| 系统边界 | GitHub入口负责 GitHub 读写路径、保护策略、输出契约和模块边界;不拥有业务 issue 的需求内容、Git source truth 或发布判定。 | + +## 5. 内部分工与规格索引 + +| 编号 | 模块或课题 | 规格文档 | 主责边界 | 上游依赖 | 下游支撑 | +| --- | --- | --- | --- | --- | --- | +| PJ2026-01060601 | 入口兼容 | 本规格 6.1 | `gh.ts` 薄入口、public exports、help/dispatch 兼容 | UniDesk CLI root | 全部 gh 命令 | +| PJ2026-01060602 | 请求与选项 | 本规格 6.2 | token、REST/GraphQL、repo/number/options/json fields | GitHub API、token source | issue/PR/board/attachment | +| PJ2026-01060603 | 正文保护 | 本规格 6.3 | stdin/file/patch、body sha、profile、concurrency guard | GitHub issue/comment body | issue/PR/comment 写入 | +| PJ2026-01060604 | Issue与PR | 本规格 6.4 | issue/PR CRUD、comment lifecycle、preflight、merge guard | 请求与正文保护 | 项目治理、PR closeout | +| PJ2026-01060605 | Board附件扫描 | 本规格 6.5 | board-row plan、attachment list/download、escape scan、stale close | GitHub issue/read model | 看板维护、污染排查 | +| PJ2026-01060606 | 输出帮助 | 本规格 6.6 | 默认摘要、JSON/raw/full、bounded dump、scoped help | 全部命令结果 | 维护者和 agent | + +### 5.1 目标架构图 + +```mermaid +flowchart LR + CLI[bun scripts/cli.ts gh] --> Root[scripts/src/gh.ts thin entry] + Root --> Dispatch[scripts/src/gh/index.ts dispatch] + Dispatch --> Opt[options/refs] + Dispatch --> Client[client/auth] + Dispatch --> Body[body-input/body-guards] + Dispatch --> Issue[issue modules] + Dispatch --> PR[pr modules] + Dispatch --> Board[board modules] + Dispatch --> Attach[attachments] + Dispatch --> Scan[escape-scan] + Dispatch --> Help[help/render] + Trans[trans gh:] --> Body + Body --> Client + Issue --> Client + PR --> Client + Board --> Client + Attach --> Client + Scan --> Client + Client --> GH[(GitHub REST/GraphQL)] +``` + +### 5.2 数据流图 + +```mermaid +flowchart TD + Args[CLI args] --> Parse[parse options and target] + Parse --> Auth[token probe] + Auth --> Read[read current GitHub resource] + Read --> Guard[body/profile/concurrency guard] + Guard --> Plan[write or dry-run plan] + Plan --> Request[REST/GraphQL request] + Request --> Render[bounded summary/json/raw/full] + Render --> User[agent or maintainer] +``` + +### 5.3 `trans gh:` 写回时序 + +```mermaid +sequenceDiagram + participant A as Agent + participant T as trans gh route + participant G as UniDesk gh command + participant B as body guard + participant H as GitHub API + A->>T: apply-patch body.md + T->>G: gh issue/pr read + G->>H: GET body and metadata + H-->>G: body, sha, updatedAt + T->>B: apply patch and validate + B->>G: gh issue/pr update with guard + G->>H: PATCH body + H-->>G: updated resource + G-->>T: compact result + T-->>A: write summary and next command +``` + +## 6. 原子需求 + +### 6.1 OPS-GH-REQ-001 入口兼容与模块边界 + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-GH-REQ-001 | 入口兼容 | PJ2026-01060601 入口兼容 | UniDesk CLI root、`trans gh:` | + +GitHub入口应保持 `bun scripts/cli.ts gh ...`、`ghHelp`、`ghScopedHelp`、`runGhCommand` 和 `commanderBriefDiff` 的 public import 兼容。`scripts/src/gh.ts` 只保留 SPEC 标注、兼容 re-export 和薄入口,不承载 GitHub 业务实现。 + +拆分后的 `scripts/src/gh/` 子模块应按职责维护。单文件超过 3000 行时必须继续拆分;新增 GitHub 子命令必须落到对应模块或新增同级模块,不能重新堆入根入口。 + +### 6.2 OPS-GH-REQ-002 请求与选项解析 + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-GH-REQ-002 | 请求选项 | PJ2026-01060602 请求与选项 | [源码同步](PJ2026-010602-source-sync.md) | + +GitHub入口应集中处理 token resolution、REST/GraphQL request、错误分类、owner/repo、issue/PR number、URL/shorthand、JSON fields、known option validation 和 read/view target resolution。认证失败、repo 不存在、权限不足、GitHub transient 和 unsupported command 必须结构化输出,不得静默 fallback 到原生 `gh`、raw `curl` 或裸 API 调用。 + +### 6.3 OPS-GH-REQ-003 正文保护与并发 guard + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-GH-REQ-003 | 正文保护 | PJ2026-01060603 正文保护 | 规格治理、`trans gh:` | + +GitHub入口应把 stdin、body file、inline short body、body patch、comment patch、profile guard、short body safety、body sha 和 updatedAt concurrency guard 收敛到正文保护模块。任何 issue/PR/comment 正式写入都必须经过该模块或等价共享 guard;dry-run 必须显示将要写入的目标、保护摘要和不会写入 GitHub 的结论。 + +### 6.4 OPS-GH-REQ-004 Issue/PR 命令与 merge guard + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-GH-REQ-004 | IssuePR | PJ2026-01060604 Issue与PR | [发布流水](PJ2026-010601-controlled-release.md) | + +GitHub入口应提供 issue/PR list/read/view/create/update/patch/comment/close/reopen、PR files、preflight、create、comment、update、close/reopen 和 merge 受控命令。PR merge 必须先执行 state/draft/conflict/mergeable/status check closeout 预检;`already merged`、不可合并和检查失败必须可见,不能直接调用不受控 merge。 + +### 6.5 OPS-GH-REQ-005 Board、附件和污染扫描 + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-GH-REQ-005 | Board附件 | PJ2026-01060605 Board附件扫描 | 项目治理、看板维护 | + +GitHub入口应支持 board table parse、board-row list/get/update/add/upsert/move/delete dry-run/guarded mutation、attachment list/download dry-run、安全下载大小边界、stale-close 和 escape scan。默认输出必须有界,完整正文、附件内容和大扫描结果只能通过显式文件、dump 或 drill-down 展开。 + +### 6.6 OPS-GH-REQ-006 输出、帮助和渐进披露 + +| 编号 | 短名 | 主责模块 | 关联模块 | +| --- | --- | --- | --- | +| OPS-GH-REQ-006 | 输出帮助 | PJ2026-01060606 输出帮助 | UniDesk CLI root | + +GitHub入口应保持低噪声默认输出、稳定 `--json` 字段选择、`--raw`、`--full`、bounded dump 和 scoped help 行为。`gh --help`、`gh issue --help`、`gh pr --help` 和子命令 help 必须快速返回有界 JSON,不因拆分丢失 usage、notes 或 next command。 + +## 7. 过程控制 + +本规格的执行 issue 为 [#906](https://github.com/pikasTech/unidesk/issues/906)。后续影响 GitHub入口稳定契约、模块边界、body guard、merge guard 或 `trans gh:` 写回语义的变更,应先更新本规格或在对应 issue 中说明为何不改变规格。 diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index c419ce37..93dd56b9 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -1,8845 +1,7 @@ -import { createHash } from "node:crypto"; -import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { ApplyPatchV2Error, deriveUpdatedContent, parseApplyPatchV2, type PatchHunk } from "./apply-patch-v2"; -import { coreInternalFetch } from "./microservices"; +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Compatibility entry for the split GitHub CLI implementation. -const DEFAULT_REPO = "pikasTech/unidesk"; -const GITHUB_API = (process.env.UNIDESK_GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/u, ""); -const USER_AGENT = "unidesk-cli-gh"; -const PREVIEW_CHARS = 240; -const REQUEST_TIMEOUT_MS = 20_000; -const MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES = 25 * 1024 * 1024; -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; -const COMMANDER_BRIEF_TARGET_ISSUE = 24; -const MAX_PR_FILES_LIMIT = 3000; -const DEFAULT_BOARD_KNOWN_META_ISSUES = [CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDER_BRIEF_TARGET_ISSUE] as const; -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", "closed", "closedAt", "comments", "commentCount", "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", - "state", - "number", - "url", - "author", - "head", - "base", - "draft", - "createdAt", - "updatedAt", - "closedAt", - "merged", - "mergedAt", - "mergeCommit", - "headRefName", - "baseRefName" -] as const; -const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const; -const PR_CLOSEOUT_JSON_FIELDS = ["mergeable", "mergeStateStatus", "statusCheckRollup"] as const; -const PR_CLOSEOUT_VIEW_JSON = "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"; -const ISSUE_LIST_STATES = ["open", "closed", "all"] as const; -const BODY_UPDATE_MODES = ["replace", "append"] as const; -const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const; -const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] as const; -const GH_VALUE_OPTIONS = new Set([ - "--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title", - "--body-file", "--body-patch-file", "--body", "--base", "--head", "--json", "--state", "--mode", - "--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", "--title-prefix", "--inactive-hours", "--comment", "--comment-file", "--description", - "--attachment", "--output", -]); -const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]); -const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS; -const ISSUE_SCAN_MAX_FINDINGS = 60; -const ISSUE_BODY_PROFILES = { - "code-queue-board": { - label: "Code Queue long board issue #20", - issueNumber: CODE_QUEUE_BOARD_TARGET_ISSUE, - requiredHeadings: ["## 看板(OPEN)"], - }, - "commander-brief": { - label: "Commander brief body-only issue (#24 legacy or daily rolling brief)", - legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE, - requiredHeadings: ["## 常驻观察与长期建议"], - }, -} as const; -const DAILY_COMMANDER_BRIEF_TITLE_PATTERN = /^\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)$/u; -const DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)\s*$/u; -const LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+指挥简报\s*$/u; -const COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS = [ - /^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u, - /^##\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间指挥更新\s*$/u, - /^###\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+CST[::].*$/u, -] as const; -const UNIDESK_CLI_CONFIG_PATH = "config/unidesk-cli.yaml"; - -type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number]; -type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number]; -type PrListJsonField = typeof PR_LIST_JSON_FIELDS[number]; -type PrReadJsonField = typeof PR_READ_JSON_FIELDS[number]; -type IssueListState = typeof ISSUE_LIST_STATES[number]; -type PrListState = typeof ISSUE_LIST_STATES[number]; -type BodyUpdateMode = typeof BODY_UPDATE_MODES[number]; -type PullRequestMergeMethod = "merge" | "squash" | "rebase"; -type RepoVisibility = "private" | "public"; -type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number]; -type BoardGithubStatus = typeof BOARD_GITHUB_STATUSES[number]; -type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES; -type IssueBodyProfileOption = "auto" | IssueBodyProfileName; -type EscapeFindingClassification = "suspected-pollution" | "explanatory-mention" | "risk"; -type EscapeFindingSeverity = "low" | "medium" | "high"; -type EscapeBodyKind = "issue-body" | "comment-body"; -type CleanupSuggestionAction = "rewrite-issue-body-with-body-stdin" | "review-comment-manually" | "review-body-length" | "no-cleanup-needed"; -type BoardSectionKind = "open" | "closed"; -type BoardRequiredColumn = typeof BOARD_AUDIT_REQUIRED_COLUMNS[number]; -type BoardColumnKind = BoardRequiredColumn | "focus" | "githubStatus" | "category" | "summary"; -type BoardRowField = typeof BOARD_ROW_FIELDS[number]; -type BoardRowUpsertTextField = typeof BOARD_ROW_UPSERT_TEXT_FIELDS[number]; -type BoardRowUpsertField = BoardRowUpsertTextField | "status"; -type BoardIgnoreReason = "known-meta" | "ignored" | "brief-index-managed"; - -type GitHubDegradedReason = - | "missing-binary" - | "missing-token" - | "auth-failed" - | "egress-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"; - -type RunnerDisposition = "infra-blocked" | "business-failed"; -type ClaudeQqTargetType = "private" | "group"; -type CommanderBriefDiffMode = "identical" | "append-only" | "heading-diff" | "unreliable"; - -interface PrMergeUnknownRetryConfig { - maxAttempts: number; - initialDelayMs: number; - maxDelayMs: number; - factor: number; -} - -interface CommanderBriefDiff { - ok: boolean; - mode: CommanderBriefDiffMode; - message: string; - chars: number; - sections: string[]; - sectionCount: number; - skippedReason?: string; -} - -interface ClaudeQqConfig { - enabled: boolean; - baseUrl: string; - targetType: ClaudeQqTargetType; - userId?: string; - groupId?: string; - timeoutMs: number; -} - -interface ClaudeQqSendResult { - ok: boolean; - attempted: boolean; - skipped?: boolean; - skippedReason?: string; - endpoint?: string; - status?: number; - degradedReason?: string; - message?: string; - response?: unknown; - target: Record; -} - -interface ClaudeQqEndpointResult { - ok: boolean; - endpoint: string; - status?: number; - degradedReason?: string; - message?: string; - response?: unknown; -} - -interface CommanderBriefSection { - heading: string; - text: string; - startLine: number; -} - -interface EscapePatternDefinition { - kind: string; - pattern: RegExp; - description: string; -} - -interface EscapeMatchFinding { - type: "issue" | "comment"; - bodyKind: EscapeBodyKind; - bodyId: string; - issueNumber: number; - issueId: number; - commentId?: number; - url: string; - kind: string; - classification: EscapeFindingClassification; - severity: EscapeFindingSeverity; - reason: string; - snippet: string; - lineNumber: number; - column: number; - match: string; - bodyChars: number | null; - bodyTrimmedChars: number | null; - cleanupPreview?: { - before: string; - after: string; - }; -} - -interface EscapeCleanupSuggestion { - type: EscapeBodyKind; - bodyId: string; - issueNumber: number; - issueId: number; - commentId?: number; - url: string; - classification: EscapeFindingClassification; - action: CleanupSuggestionAction; - reason: string; - findings: Array>; - plannedCommand?: string; - bodyInputHint?: string; - preview?: { - before: string; - after: string; - }; -} - -interface EscapeScanErrorFinding { - type: "comment-scan-error"; - bodyKind: EscapeBodyKind; - bodyId: string; - issueNumber: number; - issueId: number; - url: string; - degradedReason: GitHubDegradedReason; - runnerDisposition: RunnerDisposition; - details: GitHubErrorPayload; -} - -type EscapeScanEntry = EscapeMatchFinding | EscapeScanErrorFinding; - -interface BoardTableRow { - section: BoardSectionKind; - lineNumber: number; - raw: string; - cells: string[]; - issueNumbers: number[]; - issueNumber: number | null; - title: string | null; - columns: Partial>; -} - -interface BoardTableSection { - kind: BoardSectionKind; - heading: string; - headingLine: number; - headerLine: number; - headers: string[]; - rows: BoardTableRow[]; -} - -interface BoardIssueEntry { - number: number; - title: string; - state: string; - url: string; -} - -interface BoardIgnoredIssue { - number: number; - reason: BoardIgnoreReason; - title?: string; - state?: string; - url?: string; -} - -interface BoardRowValidationWarning { - issueNumber: number | null; - section: BoardSectionKind; - lineNumber: number; - kind: string; - message: string; - missingColumns?: BoardRequiredColumn[]; - columns?: Partial>; - rowPreview: string; -} - -interface BoardRowLookup { - ok: true; - section: BoardTableSection; - row: BoardTableRow; - matches: Array<{ section: BoardTableSection; row: BoardTableRow }>; -} - -interface BoardRowUpdatePlanResult { - ok: true; - plan: Record; - newBody: string; -} - -interface BoardRowMutationPlanResult { - ok: true; - plan: Record; - newBody: string; -} - -interface BoardRowUpsertValues { - category?: string; - branch?: string; - tasks?: string; - summary?: string; - focus?: string; - validation?: string; - progress?: string; - status?: BoardGithubStatus; -} - -interface GitHubCommandResult { - ok: boolean; - repo: string; - command: string; - degradedReason?: GitHubDegradedReason; - degraded?: GitHubDegradedReason[]; - runnerDisposition?: RunnerDisposition; - [key: string]: unknown; -} - -type GitHubCommandFailure = GitHubCommandResult & { ok: false }; - -interface GitHubTokenProbe { - present: boolean; - source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null; - ghFallbackAttempted: boolean; - ghBinaryFound?: boolean; - ghAuthTokenAvailable?: boolean | null; -} - -interface GitHubOptions { - repo: string; - dryRun: boolean; - raw: boolean; - full: boolean; - limit: number; - inactiveHours: number; - boardIssue: number; - knownMetaIssues: number[]; - ignoredIssues: number[]; - draft: boolean; - notifyClaudeQqBriefDiff: boolean; - allowShortBody: boolean; - labels: string[]; - search?: string; - titlePrefix?: string; - title?: string; - body?: string; - bodyFile?: string; - bodyPatchFile?: string; - comment?: string; - commentFile?: string; - repoDescription?: string; - repoVisibility?: RepoVisibility; - repoAutoInit: boolean; - base?: string; - head?: string; - jsonFields?: IssueViewJsonField[]; - issueListJsonFields?: IssueListJsonField[]; - prListJsonFields?: PrListJsonField[]; - prJsonFields?: PrReadJsonField[]; - listState: IssueListState; - prListState: PrListState; - mode: BodyUpdateMode; - expectUpdatedAt?: string; - expectBodySha?: string; - bodyProfile: IssueBodyProfileOption; - boardRowField?: BoardRowField; - boardRowValue?: string; - boardRowFile?: string; - boardSection?: BoardMutationSection; - boardMoveTo?: BoardMutationSection; - boardGithubStatus?: BoardGithubStatus; - boardRowUpsertValues: BoardRowUpsertValues; - mergeMethod: PullRequestMergeMethod; - deleteBranch: boolean; - attachmentSelector?: string; - outputPath?: string; -} - -interface GitHubShorthandReference { - input: string; - repo: string; - number: number; - source?: "owner-repo-number" | "github-url" | "number-option"; - urlKind?: "issue" | "pr"; - standardCommand?: string; -} - -interface GitHubResolvedNumberReference { - repo: string; - number: number; - shorthand?: GitHubShorthandReference; -} - -interface IssueProfileValidationContext { - issueTitle?: string | null; - issueBody?: string | null; - issueMetadataFetched?: boolean; -} - -interface GitHubIssueAttachment { - index: number; - url: string; - assetId: string | null; - source: "issue-body" | "comment"; - sourceUrl: string; - commentId?: number; - author?: string | null; - createdAt?: string | null; - updatedAt?: string | null; - marker: "markdown-image" | "html-img" | "plain-url"; - alt?: string | null; - width?: string | null; - height?: string | null; -} - -interface GitHubAttachmentDownload { - buffer: Buffer; - contentType: string | null; - contentLengthHeader: string | null; - finalUrl: string; -} - -interface GitHubErrorPayload { - ok: false; - degradedReason: GitHubDegradedReason; - runnerDisposition: RunnerDisposition; - status?: number; - message: string; - retryable?: boolean; - commanderAction?: string; - details?: unknown; - scopes?: { - accepted: string | null; - token: string | null; - }; - request?: { - method: string; - path: string; - }; -} - -interface GitHubIssue { - id: number; - number: number; - title: string; - body: string | null; - state: string; - html_url: string; - comments: number; - user?: { login?: string }; - labels?: Array; - pull_request?: unknown; - created_at?: string; - updated_at?: string; - closed_at?: string | null; -} - -interface GitHubComment { - id: number; - body: string | null; - html_url: string; - user?: { login?: string }; - created_at?: string; - updated_at?: string; -} - -interface GitHubIssueSearchResponse { - total_count?: number; - incomplete_results?: boolean; - 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; - title: string; - body: string | null; - state: string; - html_url: string; - draft?: boolean; - user?: { login?: string }; - head?: { ref?: string; sha?: string; repo?: { full_name?: string | null } | null }; - base?: { ref?: string; sha?: string }; - additions?: number; - deletions?: number; - changed_files?: number; - commits?: number; - closed_at?: string | null; - merged?: boolean | null; - merged_at?: string | null; - merge_commit_sha?: string | null; - mergeable?: string | null; - merge_state_status?: string | null; - created_at?: string; - updated_at?: string; -} - -interface GitHubPullRequestFile { - sha?: string; - filename: string; - status?: string; - additions?: number; - deletions?: number; - changes?: number; - blob_url?: string; - raw_url?: string; - contents_url?: string; - previous_filename?: string; - patch?: string; -} - -interface GitHubPullRequestGraphqlStatusContext { - __typename?: string; - name?: string | null; - status?: string | null; - conclusion?: string | null; - context?: string | null; - state?: string | null; - targetUrl?: string | null; - description?: string | null; -} - -interface GitHubPullRequestGraphqlStatusCheckRollup { - state?: string | null; - contexts?: { - nodes?: GitHubPullRequestGraphqlStatusContext[] | null; - } | null; -} - -interface GitHubPullRequestGraphqlMetadata { - mergeable?: string | null; - mergeStateStatus?: string | null; - headRefName?: string | null; - baseRefName?: string | null; - statusCheckRollup?: GitHubPullRequestGraphqlStatusCheckRollup | null; -} - -interface GitHubRepository { - id?: number; - name?: string; - full_name?: string; - private?: boolean; - html_url?: string; - clone_url?: string; - ssh_url?: string; - description?: string | null; - default_branch?: string; - permissions?: Record; - owner?: { login?: string }; - created_at?: string; - updated_at?: string; -} - -interface GitHubAuthenticatedUser { - login?: string; - id?: number; -} - -interface GitHubBranch { - name?: string; - commit?: { sha?: string }; -} - -interface GitHubCompare { - status?: string; - ahead_by?: number; - behind_by?: number; - total_commits?: number; - html_url?: string; - base_commit?: { sha?: string }; - merge_base_commit?: { sha?: string }; -} - -function optionValue(args: string[], name: string): string | undefined { - const index = args.indexOf(name); - if (index === -1) return undefined; - const value = args[index + 1]; - if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); - return value; -} - -function optionValues(args: string[], name: string): string[] { - const values: string[] = []; - for (let index = 0; index < args.length; index += 1) { - if (args[index] !== name) continue; - const value = args[index + 1]; - if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); - values.push(value); - index += 1; - } - return values; -} - -function hasFlag(args: string[], name: string): boolean { - return args.includes(name); -} - -function optionWasProvided(args: string[], name: string): boolean { - return args.includes(name); -} - -function commaListOption(args: string[], name: string): string[] | undefined { - const raw = optionValue(args, name); - if (raw === undefined) return undefined; - const values = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); - if (values.length === 0) throw new Error(`${name} requires at least one comma-separated field`); - return values; -} - -function labelsOption(args: string[]): string[] { - const rawValues = optionValues(args, "--label"); - const labels: string[] = []; - const seen = new Set(); - for (const raw of rawValues) { - const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); - if (parts.length === 0) throw new Error("--label requires at least one non-empty label"); - for (const label of parts) { - if (seen.has(label)) continue; - labels.push(label); - seen.add(label); - } - } - return labels; -} - -function positiveIntegerValuesOption(args: string[], name: string): number[] { - const values: number[] = []; - const seen = new Set(); - for (const raw of optionValues(args, name)) { - const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); - if (parts.length === 0) throw new Error(`${name} requires at least one positive integer`); - for (const part of parts) { - const value = Number(part.replace(/^#/u, "")); - if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} values must be positive issue numbers`); - if (seen.has(value)) continue; - values.push(value); - seen.add(value); - } - } - return values; -} - -function positiveIntegerSingleOption(args: string[], name: string, defaultValue: number): number { - const raw = optionValue(args, name); - if (raw === undefined) return defaultValue; - const value = Number(raw.replace(/^#/u, "")); - if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive issue number`); - return value; -} - -function positiveIntegerOption(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.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); - 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(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(",")}`); -} - -function validateJsonFields(command: string, requested: string[] | undefined, allowedFields: readonly T[]): T[] | undefined { - if (requested === undefined) return undefined; - const allowed = new Set(allowedFields); - const unsupported = requested.filter((field) => !allowed.has(field)); - if (unsupported.length > 0) { - throw new Error(`unsupported ${command} --json field(s): ${unsupported.join(", ")}; supported fields: ${allowedFields.join(",")}`); - } - return requested as T[]; -} - -function parseIssueViewJsonFields(requested: string[] | undefined): IssueViewJsonField[] | undefined { - return validateJsonFields("gh issue read/view", requested, ISSUE_VIEW_JSON_FIELDS); -} - -function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined { - return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS); -} - -function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined { - if (requested !== undefined) { - const listFields = new Set(PR_LIST_JSON_FIELDS); - const closeoutFields = requested.filter((field) => (PR_CLOSEOUT_JSON_FIELDS as readonly string[]).includes(field)); - const unsupported = requested.filter((field) => !listFields.has(field)); - if (closeoutFields.length > 0) { - throw new Error(`unsupported gh pr list --json closeout field(s): ${closeoutFields.join(", ")}; use gh pr view --json ${PR_CLOSEOUT_VIEW_JSON} for mergeability/statusCheckRollup metadata; pr list supported fields: ${PR_LIST_JSON_FIELDS.join(",")}`); - } - if (unsupported.length > 0) { - throw new Error(`unsupported gh pr list --json field(s): ${unsupported.join(", ")}; supported fields: ${PR_LIST_JSON_FIELDS.join(",")}; closeout metadata requires gh pr view --json ${PR_CLOSEOUT_VIEW_JSON}`); - } - } - return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS); -} - -function parsePrReadJsonFields(requested: string[] | undefined): PrReadJsonField[] | undefined { - return validateJsonFields("gh pr read/view", requested, PR_READ_JSON_FIELDS); -} - -function isIssueReadCommand(sub: string | undefined): boolean { - return sub === "read" || sub === "view"; -} - -function isPrReadCommand(sub: string | undefined): boolean { - return sub === "read" || sub === "view"; -} - -function allowsNumberTargetAlias(top: string | undefined, sub: string | undefined, third: string | undefined): boolean { - if (top === "preflight") return true; - if (top === "issue") { - if (sub === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "patch" || sub === "close" || sub === "reopen" || sub === "delete") return true; - if (sub === "comment") return true; - if (sub === "attachment" && (third === "list" || third === "download")) return true; - if (sub === "board-row" && ["get", "update", "add", "move", "delete", "upsert"].includes(third ?? "")) return true; - return false; - } - if (top === "pr") { - if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true; - if (sub === "comment") return true; - } - return false; -} - -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 parsePrListState(args: string[]): PrListState { - const raw = optionValue(args, "--state") ?? "all"; - if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as PrListState; - throw new Error(`unsupported gh pr list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`); -} - -function parseBodyUpdateMode(args: string[]): BodyUpdateMode { - const raw = optionValue(args, "--mode") ?? "replace"; - if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as BodyUpdateMode; - throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`); -} - -function parsePullRequestMergeMethod(args: string[]): PullRequestMergeMethod { - const selected = ["merge", "squash", "rebase"].filter((method) => hasFlag(args, `--${method}`)); - if (selected.length > 1) throw new Error("choose only one PR merge method flag: --merge, --squash, or --rebase"); - return (selected[0] ?? "merge") as PullRequestMergeMethod; -} - -function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption { - const raw = optionValue(args, "--body-profile") ?? "auto"; - if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw; - throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`); -} - -function parseBoardMutationSection(args: string[], name: "--section" | "--to"): BoardMutationSection | undefined { - const raw = optionValue(args, name); - if (raw === undefined) return undefined; - return validateEnumValue(name, raw, BOARD_MUTATION_SECTIONS); -} - -function parseBoardGithubStatus(args: string[]): BoardGithubStatus | undefined { - const raw = optionValue(args, "--status"); - if (raw === undefined) return undefined; - return validateEnumValue("--status", raw, BOARD_GITHUB_STATUSES); -} - -function parseBoardRowField(args: string[]): BoardRowField | undefined { - const raw = optionValue(args, "--field"); - if (raw === undefined) return undefined; - return validateEnumValue("--field", raw, BOARD_ROW_FIELDS); -} - -function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues { - return { - category: optionValue(args, "--category"), - branch: optionValue(args, "--branch"), - tasks: optionValue(args, "--tasks"), - summary: optionValue(args, "--summary"), - focus: optionValue(args, "--focus"), - validation: optionValue(args, "--validation"), - progress: optionValue(args, "--progress"), - status: parseBoardGithubStatus(args), - }; -} - -function parseRepoVisibility(args: string[]): RepoVisibility | undefined { - const privateRequested = hasFlag(args, "--private"); - const publicRequested = hasFlag(args, "--public"); - if (privateRequested && publicRequested) throw new Error("gh repo create accepts either --private or --public, not both"); - if (privateRequested) return "private"; - if (publicRequested) return "public"; - return undefined; -} - -function validateKnownOptions(args: string[]): void { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (!arg.startsWith("--")) continue; - if (GH_FLAG_OPTIONS.has(arg)) continue; - if (GH_VALUE_OPTIONS.has(arg)) { - index += 1; - continue; - } - throw new Error(`unknown gh option: ${arg}`); - } -} - -function positionalArgs(args: string[]): string[] { - const positionals: string[] = []; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] ?? ""; - if (arg.startsWith("--")) { - if (GH_VALUE_OPTIONS.has(arg)) index += 1; - continue; - } - positionals.push(arg); - } - return positionals; -} - -function isOwnerRepo(value: string): boolean { - return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value); -} - -function resolveRepoOption(args: string[]): string { - const [top, sub] = args; - const explicitRepo = optionValue(args, "--repo"); - if (top === "repo" && (sub === "view" || sub === "create")) { - const positionals = positionalArgs(args.slice(2)); - if (positionals.length > 1) { - throw new Error(`gh repo ${sub} accepts at most one positional owner/repo target`); - } - const positionalRepo = positionals[0]; - if (positionalRepo !== undefined) { - if (!isOwnerRepo(positionalRepo)) { - throw new Error(`gh repo ${sub} positional argument must be owner/repo`); - } - if (explicitRepo !== undefined && explicitRepo !== positionalRepo) { - throw new Error(`gh repo ${sub} received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target`); - } - return positionalRepo; - } - } - if ((top === "issue" || top === "pr") && sub === "list") { - const positionals = positionalArgs(args.slice(2)); - if (positionals.length > 1) { - throw new Error(`gh ${top} list accepts at most one positional owner/repo; use --repo owner/name`); - } - const positionalRepo = positionals[0]; - if (positionalRepo !== undefined) { - if (!isOwnerRepo(positionalRepo)) { - throw new Error(`gh ${top} list positional argument must be owner/repo; use --repo owner/name`); - } - if (explicitRepo !== undefined && explicitRepo !== positionalRepo) { - throw new Error(`gh ${top} list received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target, for example --repo ${positionalRepo}`); - } - return positionalRepo; - } - } - return explicitRepo ?? DEFAULT_REPO; -} - -function stdinAliasFileOption(args: string[], fileOption: string, stdinFlag: string): string | undefined { - const file = optionValue(args, fileOption); - const stdin = hasFlag(args, stdinFlag); - if (file !== undefined && stdin) { - throw new Error(`${fileOption} and ${stdinFlag} are mutually exclusive; use one body source`); - } - return stdin ? "-" : file; -} - -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 - : 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 : 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"), - draft: hasFlag(args, "--draft"), - notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"), - allowShortBody: hasFlag(args, "--allow-short-body"), - labels: labelsOption(args), - search: optionValue(args, "--search"), - titlePrefix: optionValue(args, "--title-prefix")?.trim(), - title: optionValue(args, "--title"), - body: optionValue(args, "--body"), - bodyFile: stdinAliasFileOption(args, "--body-file", "--body-stdin"), - bodyPatchFile: stdinAliasFileOption(args, "--body-patch-file", "--body-patch-stdin"), - comment: optionValue(args, "--comment"), - commentFile: stdinAliasFileOption(args, "--comment-file", "--comment-stdin"), - repoDescription: optionValue(args, "--description"), - repoVisibility: parseRepoVisibility(args), - repoAutoInit: hasFlag(args, "--auto-init"), - base: optionValue(args, "--base"), - head: optionValue(args, "--head"), - jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined, - issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined, - prListJsonFields: top === "pr" && sub === "list" ? parsePrListJsonFields(requestedJsonFields) : undefined, - prJsonFields: top === "pr" && isPrReadCommand(sub) ? parsePrReadJsonFields(requestedJsonFields) : undefined, - listState: top === "issue" && (sub === "list" || sub === "board-row" && args[2] === "list") ? parseIssueListState(args) : "open", - prListState: top === "pr" && sub === "list" ? parsePrListState(args) : "all", - mode: parseBodyUpdateMode(args), - expectUpdatedAt: optionValue(args, "--expect-updated-at"), - expectBodySha: optionValue(args, "--expect-body-sha"), - bodyProfile: parseIssueBodyProfile(args), - boardRowField: parseBoardRowField(args), - boardRowValue: optionValue(args, "--value"), - boardRowFile: optionValue(args, "--row-file"), - boardSection: parseBoardMutationSection(args, "--section"), - boardMoveTo: parseBoardMutationSection(args, "--to"), - boardGithubStatus: parseBoardGithubStatus(args), - boardRowUpsertValues: parseBoardRowUpsertValues(args), - mergeMethod: parsePullRequestMergeMethod(args), - deleteBranch: hasFlag(args, "--delete-branch"), - attachmentSelector: optionValue(args, "--attachment"), - outputPath: optionValue(args, "--output"), - }; -} - -function parseNumber(raw: string | undefined, label: string): number { - if (raw === undefined) throw new Error(`${label} requires a number`); - const value = Number(raw); - if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`); - return value; -} - -function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult { - try { - return parseNumber(raw, label); - } catch (error) { - return validationError(label, repo, error instanceof Error ? error.message : String(error)); - } -} - -function parsePositionalNumberForCommand(repo: string, args: string[], startIndex: number, label: string): number | GitHubCommandResult { - const targets = positionalArgs(args.slice(startIndex)); - if (targets.length !== 1) { - return validationError(label, repo, `${label} requires exactly one positive integer positional argument`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${label} --repo ${repo}`, - `bun scripts/cli.ts gh ${label} --repo ${repo} `, - ], - }); - } - return parseNumberForCommand(repo, targets[0], label); -} - -function resolvedNumberOptionReference(kind: "issue" | "pr", command: string, repo: string, raw: string): GitHubResolvedNumberReference | GitHubCommandResult { - const number = parseNumberForCommand(repo, raw, command); - if (typeof number !== "number") return number; - return { - repo, - number, - shorthand: { - input: `--number ${number}`, - repo, - number, - source: "number-option", - standardCommand: `bun scripts/cli.ts gh ${command} ${number} --repo ${repo}`, - urlKind: kind, - }, - }; -} - -function resolvePositionalNumberReference(kind: "issue" | "pr", args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { - const targets = positionalArgs(args.slice(startIndex)); - const numberOption = optionValue(args, "--number"); - if (targets.length > 0 && numberOption !== undefined) { - return validationError(label, options.repo, `${label} accepts either a positional numeric target or --number, not both`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} ${options.repo}#`, - ], - }); - } - if (numberOption !== undefined) return resolvedNumberOptionReference(kind, label, options.repo, numberOption); - if (targets.length !== 1) { - return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} ${options.repo}#`, - ], - }); - } - const shorthand = parseOwnerRepoNumberShorthand(targets[0]); - if (shorthand !== null) { - const explicitRepo = optionValue(args, "--repo"); - if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { - return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { - shorthand, - explicitRepo, - }); - } - return { repo: shorthand.repo, number: shorthand.number, shorthand }; - } - const number = parseNumberForCommand(options.repo, targets[0], label); - if (typeof number !== "number") return number; - return { repo: options.repo, number }; -} - -function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { - const targets = positionalArgs(args.slice(startIndex)); - const prOption = optionValue(args, "--pr"); - const numberOption = optionValue(args, "--number"); - if (prOption !== undefined && numberOption !== undefined) { - return validationError(label, options.repo, `${label} accepts either --pr or --number as a compatibility alias, not both`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} ${options.repo}#`, - ], - }); - } - if (targets.length > 0 && (prOption !== undefined || numberOption !== undefined)) { - return validationError(label, options.repo, `${label} accepts either a positional PR target or a compatibility number option, not both`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --pr --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} ${options.repo}#`, - ], - }); - } - if (numberOption !== undefined) return resolvedNumberOptionReference("pr", label, options.repo, numberOption); - const effectiveTargets = prOption !== undefined ? [prOption] : targets; - if (effectiveTargets.length !== 1) { - return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --pr --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} ${options.repo}#`, - ], - }); - } - const shorthand = parseOwnerRepoNumberShorthand(effectiveTargets[0]); - if (shorthand !== null) { - const explicitRepo = optionValue(args, "--repo"); - if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { - return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { - shorthand, - explicitRepo, - }); - } - return { repo: shorthand.repo, number: shorthand.number, shorthand }; - } - const number = parseNumberForCommand(options.repo, effectiveTargets[0], label); - if (typeof number !== "number") return number; - return { repo: options.repo, number }; -} - -function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions, allowNumberOption = true): GitHubResolvedNumberReference | GitHubCommandResult { - const targets = positionalArgs(args.slice(startIndex)); - const numberOption = allowNumberOption ? optionValue(args, "--number") : undefined; - if (targets.length > 0 && numberOption !== undefined) { - return validationError(label, options.repo, `${label} accepts either a positional issue target or --number, not both`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`, - `bun scripts/cli.ts gh ${label} ${options.repo}#`, - ], - }); - } - if (numberOption !== undefined) return resolvedNumberOptionReference("issue", label, options.repo, numberOption); - if (targets.length !== 1) { - return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${label} --repo ${options.repo}`, - ...(allowNumberOption ? [`bun scripts/cli.ts gh ${label} --number --repo ${options.repo}`] : []), - `bun scripts/cli.ts gh ${label} ${options.repo}#`, - ], - }); - } - const shorthand = parseOwnerRepoNumberShorthand(targets[0]); - if (shorthand !== null) { - const explicitRepo = optionValue(args, "--repo"); - if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { - return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { - shorthand, - explicitRepo, - }); - } - return { repo: shorthand.repo, number: shorthand.number, shorthand }; - } - const number = parseNumberForCommand(options.repo, targets[0], label); - if (typeof number !== "number") return number; - return { repo: options.repo, number }; -} - -function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null { - if (raw === undefined) return null; - const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw); - if (match === null) return null; - return { - input: raw, - repo: `${match[1]}/${match[2]}`, - number: Number(match[3]), - source: "owner-repo-number", - }; -} - -function parseGitHubIssueOrPrUrl(raw: string | undefined): GitHubShorthandReference | null { - if (raw === undefined) return null; - let parsed: URL; - try { - parsed = new URL(raw); - } catch { - return null; - } - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; - if (parsed.hostname.toLowerCase() !== "github.com" && parsed.hostname.toLowerCase() !== "www.github.com") return null; - const parts = parsed.pathname.split("/").filter((part) => part.length > 0); - if (parts.length < 4) return null; - const [owner, repoName, kind, numberRaw] = parts; - if (!owner || !repoName || (kind !== "issues" && kind !== "pull")) return null; - if (!/^[1-9]\d*$/u.test(numberRaw ?? "")) return null; - return { - input: raw, - repo: `${owner}/${repoName}`, - number: Number(numberRaw), - source: "github-url", - urlKind: kind === "issues" ? "issue" : "pr", - }; -} - -function parseReadViewTarget(raw: string | undefined): GitHubShorthandReference | null { - return parseOwnerRepoNumberShorthand(raw) ?? parseGitHubIssueOrPrUrl(raw); -} - -function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] { - return [ - `bun scripts/cli.ts gh ${kind} view ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`, - `bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`, - `bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)} [compatibility alias]`, - ]; -} - -function readViewSupportedJsonFields(kind: "issue" | "pr"): string { - return kind === "issue" - ? ISSUE_VIEW_JSON_FIELDS.join(",") - : "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"; -} - -function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", _raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult { - const command = `${kind} ${sub}`; - const targets = positionalArgs(args.slice(2)); - if (targets.length > 1) { - return validationError(command, options.repo, `${command} accepts one positional target: number, GitHub ${kind} URL, or owner/repo#number`, { - supportedCommands: [ - `bun scripts/cli.ts gh ${kind} view --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, - `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/ --raw`, - `bun scripts/cli.ts gh ${kind} view owner/name# --raw`, - ], - }); - } - const raw = targets[0]; - if (optionWasProvided(args, "--number")) { - const numberAliasRaw = optionValue(args, "--number"); - const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command); - if (typeof aliasNumber !== "number") return aliasNumber; - if (raw !== undefined) { - const parsedRaw = parseNumberForCommand(options.repo, raw, command); - if (typeof parsedRaw !== "number") return parsedRaw; - if (parsedRaw !== aliasNumber) { - return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one target number.`, { - positionalNumber: parsedRaw, - numberAlias: aliasNumber, - supportedCommands: readViewSupportedCommands(kind, options.repo, aliasNumber), - }); - } - } - return { - repo: options.repo, - number: aliasNumber, - shorthand: { - input: `--number ${aliasNumber}`, - repo: options.repo, - number: aliasNumber, - source: "number-option", - standardCommand: `bun scripts/cli.ts gh ${kind} view ${aliasNumber} --repo ${options.repo}`, - }, - }; - } - const shorthand = parseReadViewTarget(raw); - if (shorthand !== null) { - if (shorthand.urlKind !== undefined && shorthand.urlKind !== kind) { - return validationError(command, shorthand.repo, `${command} target ${shorthand.input} is a GitHub ${shorthand.urlKind} URL, not a ${kind} URL.`, { - shorthand, - supportedCommands: readViewSupportedCommands(shorthand.urlKind, shorthand.repo, shorthand.number), - }); - } - const explicitRepo = optionValue(args, "--repo"); - if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { - const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`; - return validationError(command, explicitRepo, message, { - message, - shorthand, - explicitRepo, - supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number), - }); - } - return { repo: shorthand.repo, number: shorthand.number, shorthand }; - } - const parsed = parseNumberForCommand(options.repo, raw, command); - if (typeof parsed !== "number") { - return { - ...parsed, - supportedCommands: [ - `bun scripts/cli.ts gh ${kind} view --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, - `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/ --raw`, - `bun scripts/cli.ts gh ${kind} view owner/name# --raw`, - ], - }; - } - return { repo: options.repo, number: parsed }; -} - -function issueReadJsonFields(options: GitHubOptions): IssueViewJsonField[] | undefined { - return options.jsonFields ?? (options.raw || options.full ? ISSUE_VIEW_JSON_FIELDS.slice() : undefined); -} - -function prReadJsonFields(options: GitHubOptions): PrReadJsonField[] | undefined { - return options.prJsonFields ?? (options.raw || options.full ? PR_READ_JSON_FIELDS.slice() : undefined); -} - -function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthandReference | undefined): Record | null { - if (!options.raw && !options.full && shorthand === undefined) return null; - return { - ...(options.raw ? { raw: true } : {}), - ...(options.full ? { full: true } : {}), - fullDisclosure: options.raw || options.full, - shorthand: shorthand ?? null, - ...(shorthand?.source === "number-option" ? { - compatibilityHint: "--number is accepted for low-friction compatibility; standard gh syntax uses a positional number or URL.", - standardCommand: shorthand.standardCommand, - } : {}), - }; -} - -function numberOptionCompatibilityHint(resolved: GitHubResolvedNumberReference): Record | null { - if (resolved.shorthand?.source !== "number-option") return null; - return { - acceptedOption: "--number", - compatibility: true, - message: "--number is accepted for low-friction compatibility; prefer the standard positional number target shown in standardCommand.", - standardCommand: resolved.shorthand.standardCommand ?? null, - }; -} - -async function withNumberOptionHint(result: GitHubCommandResult | Promise, resolved: GitHubResolvedNumberReference): Promise { - const output = await result; - const hint = numberOptionCompatibilityHint(resolved); - if (hint === null) return output; - return { - ...output, - standardSyntaxHint: hint, - }; -} - -function isGitHubCommandResult(value: unknown): value is GitHubCommandResult { - return typeof (value as { ok?: unknown }).ok === "boolean"; -} - -function unknownGhOptionDetails(args: string[], option: string): Record { - const [top, sub, third] = args; - const details: Record = { - unsupportedOption: option, - helpCommand: "bun scripts/cli.ts gh help", - }; - if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) { - const shorthand = parseReadViewTarget(third); - const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name"; - const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0); - details.supportedCommands = number > 0 - ? readViewSupportedCommands(top, repo, number) - : [ - `bun scripts/cli.ts gh ${top} view --repo owner/name --json ${readViewSupportedJsonFields(top)}`, - `bun scripts/cli.ts gh ${top} view owner/name# --raw`, - ]; - } - return details; -} - -function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource: Record } { - if (path === "-") { - return { body: readFileSync(0, "utf8"), bodySource: { kind: "stdin", path: "-" } }; - } - if (!existsSync(path)) throw new Error(`body file not found: ${path}`); - return { body: readFileSync(path, "utf8"), bodySource: { kind: "body-file", path } }; -} - -function readBodyPatchFileOrStdin(path: string): { patch: string; patchSource: Record } { - if (path === "-") { - return { patch: readFileSync(0, "utf8"), patchSource: { kind: "body-patch-stdin", path: "-" } }; - } - if (!existsSync(path)) throw new Error(`body patch file not found: ${path}`); - return { patch: readFileSync(path, "utf8"), patchSource: { kind: "body-patch-file", path } }; -} - -function readBodyPatch(options: GitHubOptions, command: string): { patch: string; patchSource: Record } { - const conflictingSources: string[] = []; - if (options.body !== undefined) conflictingSources.push("--body"); - if (options.bodyFile !== undefined) conflictingSources.push(options.bodyFile === "-" ? "--body-stdin" : "--body-file"); - if (options.comment !== undefined) conflictingSources.push("--comment"); - if (options.commentFile !== undefined) conflictingSources.push(options.commentFile === "-" ? "--comment-stdin" : "--comment-file"); - if (conflictingSources.length > 0) { - throw new Error(`${command} accepts only a patch source; remove ${conflictingSources.join(",")} and use --body-patch-stdin or --body-patch-file `); - } - if (options.bodyPatchFile === undefined) throw new Error(`${command} requires --body-patch-stdin or --body-patch-file `); - return readBodyPatchFileOrStdin(options.bodyPatchFile); -} - -function patchPathList(hunks: PatchHunk[]): string[] { - return Array.from(new Set(hunks.map((hunk) => hunk.path))); -} - -function applyVirtualBodyPatch(virtualPath: "issue.md" | "comment.md", oldBody: string, patchText: string): { newBody: string; patchSummary: Record } { - const parsed = parseApplyPatchV2(patchText); - let newBody = oldBody; - let updateChunkCount = 0; - for (let index = 0; index < parsed.hunks.length; index += 1) { - const hunk = parsed.hunks[index] as PatchHunk; - if (hunk.kind !== "update") { - throw new ApplyPatchV2Error("GitHub body patch supports only Update File hunks", { - hunk: index + 1, - action: hunk.kind, - path: hunk.path, - expectedPath: virtualPath, - }); - } - if (hunk.path !== virtualPath) { - throw new ApplyPatchV2Error("GitHub body patch target path mismatch", { - hunk: index + 1, - path: hunk.path, - expectedPath: virtualPath, - }); - } - if (hunk.movePath !== null && hunk.movePath !== hunk.path) { - throw new ApplyPatchV2Error("GitHub body patch does not support Move to", { - hunk: index + 1, - path: hunk.path, - targetPath: hunk.movePath, - expectedPath: virtualPath, - }); - } - updateChunkCount += hunk.chunks.length; - newBody = deriveUpdatedContent(virtualPath, newBody, hunk.chunks).newContent; - } - if (newBody === oldBody) { - throw new ApplyPatchV2Error("GitHub body patch did not change the body; no GitHub write will be performed", { - path: virtualPath, - hunkCount: parsed.hunks.length, - }); - } - return { - newBody, - patchSummary: { - engine: "apply-patch-v2", - virtualFile: virtualPath, - patchBytes: Buffer.byteLength(patchText, "utf8"), - hunkCount: parsed.hunks.length, - updateChunkCount, - paths: patchPathList(parsed.hunks), - hints: parsed.hints, - }, - }; -} - -function sanitizedPatchDiagnostics(value: unknown): Record | null { - if (!isRecord(value)) return null; - const diagnostics: Record = {}; - const candidateLines = value.firstExpectedLineCandidates; - if (Array.isArray(candidateLines)) diagnostics.firstExpectedLineCandidates = candidateLines.filter((item): item is number => typeof item === "number"); - for (const key of ["firstExpectedLineCandidatesTruncated", "bestPrefixMatchedLines", "bestPrefixStartLine", "likelyMissingAddedPrefixes", "likelyStaleOrOversizedContext"]) { - const item = value[key]; - if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) diagnostics[key] = item; - } - return Object.keys(diagnostics).length > 0 ? diagnostics : null; -} - -function sanitizedPatchDetails(value: unknown): Record | null { - if (!isRecord(value)) return null; - const result: Record = {}; - for (const key of ["path", "targetPath", "expectedPath", "hunk", "chunk", "action", "status", "line", "hunkCount"]) { - const item = value[key]; - if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) result[key] = item; - } - if (typeof value.expected === "string") { - result.expectedLineCount = value.expected.length === 0 ? 0 : value.expected.split(/\r?\n/u).length; - result.expectedChars = value.expected.length; - } - const diagnostics = sanitizedPatchDiagnostics(value.diagnostics); - if (diagnostics !== null) result.diagnostics = diagnostics; - if (Array.isArray(value.partialChanges)) result.partialChanges = value.partialChanges.filter((item): item is string => typeof item === "string"); - if (Array.isArray(value.outcomes)) { - result.outcomes = value.outcomes - .filter(isRecord) - .map((outcome) => { - const summary: Record = {}; - for (const key of ["hunk", "action", "status", "path", "targetPath", "change"]) { - const item = outcome[key]; - if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) summary[key] = item; - } - if (isRecord(outcome.error) && typeof outcome.error.message === "string") summary.errorMessage = outcome.error.message; - return summary; - }); - } - if (isRecord(value.failed)) result.failed = sanitizedPatchDetails(value.failed); - if (isRecord(value.cause)) result.cause = sanitizedPatchDetails(value.cause); - return Object.keys(result).length > 0 ? result : null; -} - -function bodyPatchErrorSummary(error: unknown): Record { - const message = error instanceof Error ? error.message : String(error); - const summary: Record = { - name: error instanceof Error ? error.name : "Error", - message, - bodyTextRedacted: true, - note: "Patch diagnostics omit expected/context text so GitHub body contents and credentials are not echoed.", - }; - const details = error instanceof ApplyPatchV2Error ? sanitizedPatchDetails(error.details) : null; - if (details !== null) summary.details = details; - return summary; -} - -function readOptionalMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } | null { - if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`); - if (options.bodyFile !== undefined) { - return readMarkdownBodyFileOrStdin(options.bodyFile); - } - if (options.body !== undefined) { - return { - body: options.body, - bodySource: { - kind: "inline", - warning: options.body.includes("\n") ? "inline body contains real newlines; --body-stdin with a quoted heredoc is safer for generated Markdown" : "inline body is intended only for short single-line text", - }, - }; - } - return null; -} - -function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } { - const body = readOptionalMarkdownBody(options, command); - if (body !== null) return body; - throw new Error(`${command} requires --body-stdin, --body-file , or --body `); -} - -function secretLikeInlineFindings(body: string): string[] { - const findings: string[] = []; - if (/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-token-like-value"); - if (/\bgithub_pat_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-pat-like-value"); - if (/\b(?:token|password|passwd|secret|api[_-]?key)\s*[:=]\s*["']?[^"'\s,;]{6,}/iu.test(body)) findings.push("credential-assignment-like-value"); - if (/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/iu.test(body)) findings.push("bearer-token-like-value"); - return findings; -} - -function readIssueCommentBody(options: GitHubOptions, command = "issue comment create"): { body: string; bodySource: Record } { - if (options.bodyFile !== undefined && options.body !== undefined) { - throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`); - } - if (options.bodyFile !== undefined) { - return readMarkdownBodyFileOrStdin(options.bodyFile); - } - if (options.body === undefined) throw new Error(`${command} requires --body-stdin, --body-file , or --body `); - const body = options.body; - const trimmed = body.trim(); - const shellPollution = shellPollutionEvidence(body); - const secretLike = secretLikeInlineFindings(body); - if (trimmed.length === 0) { - throw new Error(`${command} --body must not be blank; use --body-stdin with a quoted heredoc for reviewed Markdown`); - } - if (body.length > MAX_INLINE_ISSUE_COMMENT_BODY_CHARS) { - throw new Error(`${command} --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-stdin with a quoted heredoc for long Markdown`); - } - if (body.includes("\n") || body.includes("\r")) { - throw new Error(`${command} --body supports short single-line text only; use --body-stdin with a quoted heredoc for multiline Markdown`); - } - if (shellPollution.length > 0) { - throw new Error(`${command} --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-stdin with a quoted heredoc for reviewed Markdown bytes`); - } - if (secretLike.length > 0) { - throw new Error(`${command} --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`); - } - return { - body, - bodySource: { - kind: "inline", - maxInlineBodyChars: MAX_INLINE_ISSUE_COMMENT_BODY_CHARS, - warning: "inline issue comments are intended only for short single-line text; use --body-stdin with a quoted heredoc for Markdown or generated content", - }, - }; -} - -function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } | null { - if (options.comment === undefined && options.commentFile === undefined) return null; - if (options.comment !== undefined && options.commentFile !== undefined) { - throw new Error(`${command} --comment and --comment-file/--comment-stdin are mutually exclusive`); - } - if (options.body !== undefined || options.bodyFile !== undefined) { - throw new Error(`${command} --comment/--comment-file/--comment-stdin cannot be combined with --body/--body-file/--body-stdin`); - } - if (options.commentFile !== undefined) { - return readIssueCommentBody({ ...options, body: undefined, bodyFile: options.commentFile }); - } - return readIssueCommentBody({ ...options, body: options.comment, bodyFile: undefined }); -} - -function tokenFromEnvironment(): GitHubTokenProbe { - if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) { - return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false }; - } - if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) { - return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false }; - } - return { present: false, source: null, ghFallbackAttempted: false }; -} - -function ghBinaryPath(): string | null { - try { - const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); - return output.length > 0 ? output : null; - } catch { - return null; - } -} - -function ghAuthToken(): string | null { - try { - const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); - return output.length > 0 ? output : null; - } catch { - return null; - } -} - -function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } { - const envProbe = tokenFromEnvironment(); - if (envProbe.present) { - const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null; - return { token, probe: envProbe }; - } - if (!allowGhFallback) return { token: null, probe: envProbe }; - const ghPath = ghBinaryPath(); - if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } }; - const token = ghAuthToken(); - if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } }; - return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } }; -} - -function repoParts(repo: string): { owner: string; name: string } { - const [owner, name, extra] = repo.split("/"); - if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format"); - return { owner, name }; -} - -function preview(text: string): string { - return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text; -} - -function previewLines(text: string, maxLines = 12): string[] { - return text.split(/\r?\n/).slice(0, maxLines).map((line) => preview(line)); -} - -function bodySha(text: string): string { - return createHash("sha256").update(text, "utf8").digest("hex"); -} - -function binarySha(data: Buffer): string { - return createHash("sha256").update(data).digest("hex"); -} - -function normalizeExpectedSha(raw: string): string { - const value = raw.replace(/^sha256:/iu, "").toLowerCase(); - if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("--expect-body-sha must be a 64-character SHA-256 hex value, optionally prefixed with sha256:"); - return value; -} - -function shellPollutionEvidence(body: string): string[] { - const evidence: string[] = []; - if (body.includes("\\n")) evidence.push("literal-backslash-n"); - if (body.includes("\\t")) evidence.push("literal-backslash-t"); - if (body.includes("\\`")) evidence.push("escaped-backtick"); - if (/\\x1b|\\u001b|\u001b\[/u.test(body)) evidence.push("ansi-escape"); - if (/\r(?!\n)/u.test(body)) evidence.push("bare-carriage-return"); - return evidence; -} - -function bodySafetySignals(body: string): Record { - const evidence = shellPollutionEvidence(body); - return { - bodyChars: body.length, - bodyTrimmedChars: body.trim().length, - bodyLines: body.length === 0 ? 0 : body.split(/\r?\n/).length, - bodySha: bodySha(body), - preservesRawNewlines: body.includes("\n"), - containsLiteralBackslashN: body.includes("\\n"), - containsBackticks: body.includes("`"), - containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body), - shellPollution: { - suspected: evidence.length > 0, - evidence, - }, - }; -} - -function dryRunBody(repo: string, title: string | undefined, body: string): Record { - return { - repo, - ...(title === undefined ? {} : { title }), - bodyPreview: preview(body), - bodyPreviewLines: previewLines(body), - ...bodySafetySignals(body), - }; -} - -function normalizeNewlines(text: string): string { - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); -} - -function isTimelineHeading(line: string): boolean { - return /^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u.test(line.trimEnd()); -} - -function isCommanderBriefUpdateHeading(line: string): boolean { - const trimmed = line.trimEnd(); - return COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS.some((pattern) => pattern.test(trimmed)); -} - -function commanderBriefUpdateHeadings(body: string): string[] { - return normalizeNewlines(body) - .split("\n") - .map((line) => line.trimEnd()) - .filter((line) => isCommanderBriefUpdateHeading(line)); -} - -function codeQueueBoardHwlabProductRoutingFindings(body: string | undefined): Array> { - if (body === undefined) return []; - const findings: Array> = []; - const directIssuePatterns = [ - { kind: "hwlab-repo-issue-url", pattern: /(?:https?:\/\/github\.com\/)?pikasTech\/HWLAB\/issues\/\d+/iu }, - { kind: "hwlab-repo-issue-short-ref", pattern: /\bpikasTech\/HWLAB#\d+\b/iu }, - { kind: "hwlab-short-issue-ref", pattern: /\bHWLAB#\d+\b/iu }, - ]; - const hwlabContextPattern = /\bHWLAB\b|pikasTech\/HWLAB|hwlab-/iu; - const strongProductPattern = /hwlab-cloud-web|Cloud Workbench|DEV-LIVE|res_boxsimu|hwlab-patch-panel|patch panel|M3\s*(?:虚拟硬件|可信闭环)/iu; - const genericProductPattern = /用户反馈|user feedback/iu; - const governanceContextPattern = /\b(?:commander|Code Queue|CLI|infra|governance|guard|guardrail|routing|misfile|board)\b|治理|调度|基础设施|守卫|边界|分流|看板/iu; - const lines = normalizeNewlines(body).split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - const isTableRow = line.trim().startsWith("|") && !isMarkdownTableSeparator(line); - const strongProductSignal = strongProductPattern.test(line); - const genericProductSignal = genericProductPattern.test(line); - const governanceContext = governanceContextPattern.test(line); - for (const definition of directIssuePatterns) { - const match = definition.pattern.exec(line); - if (match !== null && !governanceContext && (isTableRow || strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) { - findings.push({ - kind: definition.kind, - lineNumber: index + 1, - match: match[0], - snippet: preview(line.trim()), - }); - } - } - if (isTableRow && !governanceContext && (strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) { - const match = strongProductSignal ? strongProductPattern.exec(line) : genericProductPattern.exec(line); - findings.push({ - kind: "hwlab-product-signal", - lineNumber: index + 1, - match: match?.[0] ?? "HWLAB product signal", - snippet: preview(line.trim()), - }); - } - if (findings.length >= 12) break; - } - return findings; -} - -function codeQueueBoardHwlabProductRoutingHint(boardIssueNumber: number, body?: string): Record | null { - if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null; - const findings = codeQueueBoardHwlabProductRoutingFindings(body); - return { - warning: "#20 is only for UniDesk commander/Code Queue/CLI/infra governance; HWLAB user/product issues belong in pikasTech/HWLAB.", - route: "Create or update the corresponding issue in pikasTech/HWLAB; keep #20 rows limited to UniDesk governance or infrastructure support work.", - forbiddenPatterns: [ - "pikasTech/HWLAB#", - "github.com/pikasTech/HWLAB/issues/", - "HWLAB#", - "HWLAB product/live validation rows", - ], - allowedScope: ["commander governance", "Code Queue supervision", "UniDesk CLI guardrails", "UniDesk infrastructure"], - detected: findings.length > 0, - findings, - }; -} - -function codeQueueBoardHintHasHwlabProductRouting(hint: Record | null): boolean { - if (hint === null) return false; - const routing = hint.hwlabProductRouting; - return typeof routing === "object" && routing !== null && (routing as { detected?: unknown }).detected === true; -} - -function codeQueueBoardHintHasCommanderBriefUpdates(hint: Record | null): boolean { - if (hint === null) return false; - const commanderBrief = hint.commanderBrief; - return typeof commanderBrief === "object" && commanderBrief !== null && (commanderBrief as { detected?: unknown }).detected === true; -} - -function codeQueueBoardCommanderBriefHint(boardIssueNumber: number, body?: string): Record | null { - if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null; - const headings = body === undefined ? [] : commanderBriefUpdateHeadings(body); - const hwlabProductRouting = codeQueueBoardHwlabProductRoutingHint(boardIssueNumber, body); - const commanderBriefDetected = headings.length > 0; - const hwlabProductDetected = codeQueueBoardHintHasHwlabProductRouting({ hwlabProductRouting }); - return { - warning: "#20 is the long-term UniDesk commander/Code Queue/CLI/infra governance board only; do not write daily commander brief updates or HWLAB product/user issue tracking into #20.", - route: commanderBriefDetected - ? "Move daily progress notes to the daily rolling commander brief issue referenced in #20's 指挥简报索引, using --body-profile commander-brief." - : hwlabProductDetected - ? "Move HWLAB product/user issue tracking to pikasTech/HWLAB; keep #20 limited to UniDesk commander/Code Queue/CLI/infra governance." - : "Use #20 only for UniDesk commander/Code Queue/CLI/infra governance rows.", - forbiddenHeadings: ["## 更新 YYYY-MM-DD HH:mm 北京时间", "## YYYY-MM-DD HH:mm 北京时间指挥更新", "### YYYY-MM-DD HH:mm CST:..."], - suggestedCommand: "bun scripts/cli.ts gh issue update --mode replace --body-stdin --body-profile commander-brief --expect-body-sha <<'EOF'\n\nEOF", - detected: commanderBriefDetected || hwlabProductDetected, - detectedHeadings: headings, - commanderBrief: { - detected: commanderBriefDetected, - detectedHeadings: headings, - }, - hwlabProductRouting, - }; -} - -function extractTimelineSections(markdown: string): CommanderBriefSection[] { - const normalized = normalizeNewlines(markdown); - const lines = normalized.split("\n"); - const sections: CommanderBriefSection[] = []; - let currentStart = -1; - for (let index = 0; index < lines.length; index += 1) { - if (!isTimelineHeading(lines[index])) continue; - if (currentStart !== -1) { - sections.push({ - heading: lines[currentStart].trimEnd(), - text: lines.slice(currentStart, index).join("\n").trimEnd(), - startLine: currentStart + 1, - }); - } - currentStart = index; - } - if (currentStart !== -1) { - sections.push({ - heading: lines[currentStart].trimEnd(), - text: lines.slice(currentStart).join("\n").trimEnd(), - startLine: currentStart + 1, - }); - } - return sections.filter((section) => section.text.length > 0); -} - -export function commanderBriefDiff(oldBodyRaw: string, newBodyRaw: string): CommanderBriefDiff { - const oldBody = normalizeNewlines(oldBodyRaw); - const newBody = normalizeNewlines(newBodyRaw); - if (oldBody === newBody) { - return { - ok: false, - mode: "identical", - message: "", - chars: 0, - sections: [], - sectionCount: 0, - skippedReason: "issue body is unchanged", - }; - } - - if (newBody.startsWith(oldBody)) { - const suffix = newBody.slice(oldBody.length); - const sections = extractTimelineSections(suffix).map((section) => section.text); - const message = sections.join("\n\n").trim(); - if (message.length === 0) { - return { - ok: false, - mode: "append-only", - message: "", - chars: 0, - sections: [], - sectionCount: 0, - skippedReason: "append-only suffix contains no new commander brief update section", - }; - } - return { ok: true, mode: "append-only", message, chars: message.length, sections, sectionCount: sections.length }; - } - - const oldSections = new Set(extractTimelineSections(oldBody).map((section) => section.text)); - const allNewSections = extractTimelineSections(newBody); - const newSections = allNewSections.map((section) => section.text).filter((section) => !oldSections.has(section)); - if (newSections.length === 0) { - return { - ok: false, - mode: "heading-diff", - message: "", - chars: 0, - sections: [], - sectionCount: 0, - skippedReason: "no new commander brief update section found; non-timeline edits are not notified", - }; - } - const duplicateNewHeadings = allNewSections.some((section, index) => allNewSections.findIndex((candidate) => candidate.heading === section.heading) !== index); - const oldHeadings = new Set(extractTimelineSections(oldBody).map((section) => section.heading)); - const ambiguousChangedHeadings = allNewSections.some((section) => oldHeadings.has(section.heading) && !oldSections.has(section.text)); - if (duplicateNewHeadings || ambiguousChangedHeadings) { - return { - ok: false, - mode: "unreliable", - message: "", - chars: 0, - sections: [], - sectionCount: 0, - skippedReason: duplicateNewHeadings - ? "duplicate update headings make commander brief diff unreliable" - : "an existing update section changed; cannot reliably isolate only new timeline content", - }; - } - const message = newSections.join("\n\n").trim(); - return { ok: true, mode: "heading-diff", message, chars: message.length, sections: newSections, sectionCount: newSections.length }; -} - -function envEnabled(name: string, defaultValue: boolean): boolean { - const raw = process.env[name]; - if (raw === undefined || raw.length === 0) return defaultValue; - return !["0", "false", "no", "off", "disabled"].includes(raw.toLowerCase()); -} - -function positiveEnvInteger(name: string, defaultValue: number): number { - const raw = process.env[name]; - if (raw === undefined || raw.length === 0) return defaultValue; - const value = Number(raw); - return Number.isInteger(value) && value > 0 ? value : defaultValue; -} - -function commanderBriefClaudeQqConfig(): ClaudeQqConfig { - const targetTypeRaw = (process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE ?? "private").toLowerCase(); - const targetType: ClaudeQqTargetType = targetTypeRaw === "group" ? "group" : "private"; - return { - enabled: envEnabled("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED", true), - baseUrl: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL, - targetType, - userId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, - groupId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID, - timeoutMs: positiveEnvInteger("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS", 15_000), - }; -} - -function maskedTarget(config: ClaudeQqConfig): Record { - if (config.targetType === "group") return { targetType: "group", groupId: config.groupId === undefined ? null : maskId(config.groupId) }; - return { targetType: "private", userId: config.userId === undefined ? null : maskId(config.userId) }; -} - -function maskId(value: string): string { - if (value.length <= 4) return "*".repeat(value.length); - return `${value.slice(0, 2)}***${value.slice(-2)}`; -} - -function sanitizeUrlForOutput(value: string): string { - try { - const parsed = new URL(value); - parsed.username = parsed.username.length > 0 ? "***" : ""; - parsed.password = parsed.password.length > 0 ? "***" : ""; - parsed.search = parsed.search.length > 0 ? "?..." : ""; - parsed.hash = ""; - return parsed.toString(); - } catch { - return value.includes("?") ? `${value.split("?")[0]}?...` : value; - } -} - -function claudeQqPayload(config: ClaudeQqConfig, message: string): Record { - if (config.targetType === "group") return { targetType: "group", groupId: config.groupId ?? "", message }; - return { targetType: "private", userId: config.userId ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, message }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function proxiedClaudeQqBasePath(baseUrl: string): string | null { - try { - const parsed = new URL(baseUrl); - const path = parsed.pathname.replace(/\/$/u, ""); - if (parsed.hostname === "backend-core" && path === "/api/microservices/claudeqq/proxy") return path; - if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.port === "8080" && path === "/api/microservices/claudeqq/proxy") return path; - return null; - } catch { - return baseUrl === "/api/microservices/claudeqq/proxy" ? baseUrl : null; - } -} - -function normalizeClaudeQqEndpoint(basePath: string, endpoint: string): string { - return `${basePath.replace(/\/$/u, "")}${endpoint}`; -} - -function claudeQqResponseOk(response: unknown): boolean { - if (!isRecord(response)) return false; - if (response.ok === false) return false; - if (typeof response.status === "number" && (response.status < 200 || response.status >= 300)) return false; - const body = response.body; - if (isRecord(body) && (body.ok === false || body.success === false)) return false; - return response.ok === true || typeof response.status === "number"; -} - -function summarizeClaudeQqProxyFailure(response: unknown, endpoint: string): ClaudeQqEndpointResult { - if (!isRecord(response)) return { ok: false, endpoint, degradedReason: "invalid-response", message: "ClaudeQQ proxy returned a non-object response", response }; - const status = typeof response.status === "number" ? response.status : undefined; - const stdoutTail = typeof response.stdoutTail === "string" ? response.stdoutTail : ""; - let parsedTail: unknown = null; - if (stdoutTail.length > 0) { - try { - parsedTail = JSON.parse(stdoutTail.trim()) as unknown; - } catch { - parsedTail = null; - } - } - return { - ok: false, - endpoint, - status, - degradedReason: response.ok === false ? "microservice-proxy-failed" : "invalid-response", - message: typeof response.error === "string" - ? response.error - : typeof response.stderrTail === "string" && response.stderrTail.length > 0 - ? response.stderrTail.slice(-500) - : "ClaudeQQ proxy request failed", - response: parsedTail ?? response, - }; -} - -async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, payload: Record): Promise { - const basePath = proxiedClaudeQqBasePath(config.baseUrl); - if (basePath !== null) { - const response = coreInternalFetch(normalizeClaudeQqEndpoint(basePath, endpoint), { - method: "POST", - body: payload, - maxResponseBytes: 240_000, - timeoutMs: config.timeoutMs, - }); - if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response }; - return summarizeClaudeQqProxyFailure(response, endpoint); - } - - return { - ok: false, - endpoint, - degradedReason: "notification-path-unavailable", - message: "ClaudeQQ notifications must use backend-core /api/microservices/claudeqq/proxy; local skill, powershell, direct host, and ad-hoc ClaudeQQ URLs are not supported.", - response: { - baseUrl: sanitizeUrlForOutput(config.baseUrl), - requiredBaseUrl: DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL, - recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '' --raw", - }, - }; -} - -async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise { - const target = maskedTarget(config); - if (!config.enabled) { - return { ok: true, attempted: false, skipped: true, skippedReason: "UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED disabled", target }; - } - if (config.targetType === "group" && (config.groupId === undefined || config.groupId.length === 0)) { - return { ok: false, attempted: false, skipped: true, skippedReason: "group target requires UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID", target }; - } - const payload = claudeQqPayload(config, message); - const first = await sendClaudeQqEndpoint(config, "/api/push/text", payload); - if (first.ok) return { ok: true, attempted: true, endpoint: first.endpoint, status: first.status, response: first.response, target }; - const second = await sendClaudeQqEndpoint(config, "/api/send/text", payload); - if (second.ok) return { ok: true, attempted: true, endpoint: second.endpoint, status: second.status, response: second.response, target }; - return { - ok: false, - attempted: true, - endpoint: second.endpoint, - status: second.status ?? first.status, - degradedReason: second.degradedReason ?? first.degradedReason ?? "claudeqq-send-failed", - message: second.message ?? first.message ?? "ClaudeQQ send failed", - response: { attempts: [first, second] }, - target, - }; -} - -function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: CommanderBriefDiff, config: ClaudeQqConfig): Record { - const proxyBasePath = proxiedClaudeQqBasePath(config.baseUrl); - return { - enabled: config.enabled, - issueNumber, - bodyChars: body.length, - diff: { - ok: diff.ok, - mode: diff.mode, - chars: diff.chars, - sectionCount: diff.sectionCount, - skippedReason: diff.skippedReason ?? null, - preview: diff.ok ? preview(diff.message) : "", - previewLines: diff.ok ? previewLines(diff.message) : [], - containsLiteralBackslashN: diff.message.includes("\\n"), - containsBackticks: diff.message.includes("`"), - containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message), - }, - claudeqq: { - dryRun: true, - wouldSend: config.enabled && diff.ok && proxyBasePath !== null, - baseUrl: sanitizeUrlForOutput(config.baseUrl), - proxyBasePath, - servicePath: proxyBasePath === null ? null : normalizeClaudeQqEndpoint(proxyBasePath, "/api/push/text"), - target: maskedTarget(config), - timeoutMs: config.timeoutMs, - ...(proxyBasePath === null - ? { - blockedReason: "notification-path-unavailable", - recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '' --raw", - } - : {}), - }, - }; -} - -function writeBodyPlan(command: "issue create" | "issue comment create" | "issue comment update" | "pr create" | "pr comment" | "pr comment update", repo: string, body: string, bodySource: Record, extra: Record = {}): Record { - const isIssueWrite = command === "issue create" || command === "issue comment create" || command === "issue comment update"; - const isCommentUpdate = command === "issue comment update" || command === "pr comment update"; - const source = String(bodySource.kind ?? "unknown"); - const requestBody: Record = { - bodyChars: body.length, - bodySource, - }; - if (command === "issue create" && typeof extra.title === "string") requestBody.title = extra.title; - if (command === "issue create" && Array.isArray(extra.labels)) requestBody.labels = extra.labels; - return { - repo, - bodySource, - source, - bodyPreview: preview(body), - bodyPreviewLines: previewLines(body), - ...bodySafetySignals(body), - request: { - method: isCommentUpdate ? "PATCH" : "POST", - path: isIssueWrite - ? (command === "issue create" - ? "/repos/{owner}/{repo}/issues" - : isCommentUpdate - ? "/repos/{owner}/{repo}/issues/comments/{comment_id}" - : "/repos/{owner}/{repo}/issues/{issue_number}/comments") - : (command === "pr create" - ? "/repos/{owner}/{repo}/pulls" - : isCommentUpdate - ? "/repos/{owner}/{repo}/issues/comments/{comment_id}" - : "/repos/{owner}/{repo}/issues/{issue_number}/comments"), - body: requestBody, - }, - validation: { - source, - rawText: source === "body-file" - ? "read from file bytes without shell interpolation" - : source === "stdin" - ? "read from stdin bytes" - : "short inline argument accepted only by command-specific safety rules", - }, - ...extra, - }; -} - -function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition { - if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed"; - return "infra-blocked"; -} - -function sanitizedErrorDetails(parsed: unknown): unknown { - if (typeof parsed !== "object" || parsed === null) return parsed; - const source = parsed as Record; - const details: Record = {}; - if ("message" in source) details.message = source.message; - if ("documentation_url" in source) details.documentationUrl = source.documentation_url; - if ("status" in source) details.status = source.status; - if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5); - if ("raw" in source) details.raw = source.raw; - return details; -} - -function errorProperty(value: unknown, key: string): string | null { - if (!isRecord(value)) return null; - const item = value[key]; - return typeof item === "string" && item.length > 0 ? item : null; -} - -function errorChainText(error: unknown): string { - const parts: string[] = []; - let current: unknown = error; - const seen = new Set(); - while (current !== null && current !== undefined && !seen.has(current)) { - seen.add(current); - if (current instanceof Error) { - parts.push(current.name, current.message); - current = (current as Error & { cause?: unknown }).cause; - continue; - } - if (isRecord(current)) { - for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) { - const value = errorProperty(current, key); - if (value !== null) parts.push(value); - } - current = current.cause; - continue; - } - parts.push(String(current)); - break; - } - return parts.join(" ").replace(/\s+/gu, " ").trim(); -} - -function firstErrorProperty(error: unknown, key: string): string | null { - let current: unknown = error; - const seen = new Set(); - while (current !== null && current !== undefined && !seen.has(current)) { - seen.add(current); - const value = errorProperty(current, key); - if (value !== null) return value; - current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null; - } - return null; -} - -function classifyGitHubFetchFailure(error: unknown): { - reason: GitHubDegradedReason; - message: string; - retryable?: boolean; - commanderAction?: string; - details: Record; -} { - const text = errorChainText(error); - const lower = text.toLowerCase(); - const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text); - const isGitHubTransient = !isProxyFailure && ( - /temporary failure in name resolution/iu.test(text) - || /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text) - || /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text) - ); - if (!isGitHubTransient) { - return { - reason: "network-proxy-failed", - message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response", - details: { - scope: "github-api", - transient: false, - errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"), - errorCode: firstErrorProperty(error, "code"), - host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"), - }, - }; - } - return { - reason: "github-transient", - message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response", - retryable: true, - commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh", - details: { - scope: "github-api", - transient: true, - retryable: true, - authFailure: false, - semanticFailure: false, - errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"), - errorCode: firstErrorProperty(error, "code"), - host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"), - commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work", - valuesPrinted: false, - }, - }; -} - -function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit = {}): GitHubErrorPayload { - return { - ok: false, - degradedReason: reason, - runnerDisposition: runnerDisposition(reason), - message, - ...extra, - }; -} - -function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record = {}): GitHubCommandResult { - return { - ok: false, - command, - repo, - degradedReason: error.degradedReason, - runnerDisposition: error.runnerDisposition, - details: error, - ...extra, - }; -} - -function validationError(command: string, repo: string, message: string, extra: Record = {}): GitHubCommandResult { - const error = errorPayload("validation-failed", message, { details: extra }); - return commandError(command, repo, error, extra); -} - -function unsupportedCommand(command: string, repo: string, message: string, extra: Record = {}): GitHubCommandResult { - const error = errorPayload("unsupported-command", message, { details: extra }); - return commandError(command, repo, error, { message, ...extra }); -} - -async function parseGitHubResponse(response: Response): Promise { - const text = await response.text(); - if (text.length === 0) return null; - try { - return JSON.parse(text) as unknown; - } catch { - return { raw: preview(text) }; - } -} - -function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason { - const lower = message.toLowerCase(); - const acceptedScopes = response.headers.get("x-accepted-oauth-scopes"); - if (status === 401) return "auth-failed"; - if (status === 403) { - if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient"; - if (path.startsWith("/repos/")) return "repo-forbidden"; - return "permission-denied"; - } - if (status === 404) { - if (/\/pulls\/\d+/u.test(path)) return "pr-not-found"; - if (/\/issues\/\d+/u.test(path)) return "issue-not-found"; - if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed"; - return "repo-not-found"; - } - if (status === 422) return "validation-failed"; - return "invalid-response"; -} - -async function githubRequest( - token: string, - method: string, - path: string, - body?: Record, -): Promise { - let response: Response; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - response = await fetch(`${GITHUB_API}${path}`, { - method, - signal: controller.signal, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": USER_AGENT, - "X-GitHub-Api-Version": "2022-11-28", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - } catch (error) { - const classified = classifyGitHubFetchFailure(error); - return errorPayload(classified.reason, classified.message, { - request: { method, path }, - details: classified.details, - retryable: classified.retryable, - commanderAction: classified.commanderAction, - }); - } finally { - clearTimeout(timeout); - } - const parsed = await parseGitHubResponse(response); - if (!response.ok) { - const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText; - return errorPayload(classifyHttpStatus(response.status, message, path, response), message, { - status: response.status, - details: sanitizedErrorDetails(parsed), - scopes: { - accepted: response.headers.get("x-accepted-oauth-scopes"), - token: response.headers.get("x-oauth-scopes"), - }, - request: { method, path }, - }); - } - return parsed as T; -} - -async function githubGraphqlRequest( - token: string, - query: string, - variables: Record, -): Promise { - let response: Response; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - response = await fetch(`${GITHUB_API}/graphql`, { - method: "POST", - signal: controller.signal, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": USER_AGENT, - "X-GitHub-Api-Version": "2022-11-28", - }, - body: JSON.stringify({ query, variables }), - }); - } catch (error) { - const classified = classifyGitHubFetchFailure(error); - return errorPayload(classified.reason, classified.message, { - request: { method: "POST", path: "/graphql" }, - details: classified.details, - retryable: classified.retryable, - commanderAction: classified.commanderAction, - }); - } finally { - clearTimeout(timeout); - } - const parsed = await parseGitHubResponse(response); - if (!response.ok) { - const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText; - return errorPayload(classifyHttpStatus(response.status, message, "/graphql", response), message, { - status: response.status, - details: sanitizedErrorDetails(parsed), - scopes: { - accepted: response.headers.get("x-accepted-oauth-scopes"), - token: response.headers.get("x-oauth-scopes"), - }, - request: { method: "POST", path: "/graphql" }, - }); - } - if (!isRecord(parsed)) { - return errorPayload("invalid-response", "GitHub GraphQL response was not an object", { details: parsed }); - } - if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { - const firstError = parsed.errors[0]; - const message = isRecord(firstError) && typeof firstError.message === "string" ? firstError.message : "GitHub GraphQL query failed"; - return errorPayload("validation-failed", message, { - details: sanitizedErrorDetails(parsed), - request: { method: "POST", path: "/graphql" }, - }); - } - const data = parsed.data; - if (!isRecord(data)) { - return errorPayload("invalid-response", "GitHub GraphQL response did not include an object data payload", { details: sanitizedErrorDetails(parsed) }); - } - return data as T; -} - -function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null { - if (!tokenProbe.present) { - if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) { - return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe })); - } - if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) { - return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe })); - } - return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), { - degraded: ["missing-token"], - token: tokenProbe, - }); - } - return null; -} - -function isGitHubError(value: unknown): value is GitHubErrorPayload { - return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value; -} - -function issueBodyReadCommands(repo: string, issueNumber: number): Record { - return { - body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`, - full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`, - raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`, - }; -} - -function issueCommentReadCommands(repo: string, issueNumber: number): Record { - return { - comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`, - full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`, - raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`, - }; -} - -function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record { - const explicitFullDisclosure = options.raw || options.full; - return { - defaultCompact: dryRun || !explicitFullDisclosure, - explicitFullDisclosure, - fullBodyIncluded: explicitFullDisclosure && !dryRun, - bodyOmitted: dryRun || !explicitFullDisclosure, - dryRunBoundedPreview: dryRun, - note: explicitFullDisclosure && !dryRun - ? "The returned issue object includes the full body because --full or --raw was explicitly requested." - : "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.", - readCommands: issueBodyReadCommands(repo, issueNumber), - }; -} - -function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record { - const body = issue.body ?? ""; - const includeBody = options.includeBody ?? true; - const includePreview = options.includePreview ?? true; - const summary: Record = { - id: issue.id, - 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, - updatedAt: issue.updated_at ?? null, - commentCount: issue.comments ?? null, - bodyChars: body.length, - bodySha: bodySha(body), - }; - if (includeBody) { - summary.body = body; - } else { - summary.bodyOmitted = true; - summary.fullBodyIncluded = false; - if (includePreview) { - summary.bodyPreview = preview(body); - summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8); - } - } - return summary; -} - -function repoSummary(repo: GitHubRepository): Record { - return { - id: repo.id ?? null, - name: repo.name ?? null, - fullName: repo.full_name ?? null, - owner: repo.owner?.login ?? null, - private: repo.private ?? null, - visibility: repo.private === true ? "private" : repo.private === false ? "public" : "unknown", - description: repo.description ?? null, - defaultBranch: repo.default_branch ?? null, - url: repo.html_url ?? null, - cloneUrl: repo.clone_url ?? null, - sshUrl: repo.ssh_url ?? null, - createdAt: repo.created_at ?? null, - updatedAt: repo.updated_at ?? null, - permissions: repo.permissions ?? null, - }; -} - -async function repoView(repo: string, token: string): Promise { - const { owner, name } = repoParts(repo); - const result = await githubRequest(token, "GET", `/repos/${owner}/${name}`); - if (isGitHubError(result)) return commandError("repo view", repo, result); - return { - ok: true, - command: "repo view", - repo, - repository: repoSummary(result), - request: { - method: "GET", - path: `/repos/${owner}/${name}`, - }, - rest: true, - }; -} - -async function repoCreate(repo: string, token: string, options: GitHubOptions): Promise { - const { owner, name } = repoParts(repo); - const visibility = options.repoVisibility ?? "private"; - const viewer = options.dryRun ? null : await githubRequest(token, "GET", "/user"); - if (isGitHubError(viewer)) return commandError("repo create", repo, viewer, { phase: "viewer" }); - const viewerLogin = viewer?.login ?? null; - const createPath = viewerLogin !== null && viewerLogin.toLowerCase() === owner.toLowerCase() - ? "/user/repos" - : `/orgs/${owner}/repos`; - const payload: Record = { - name, - private: visibility === "private", - auto_init: options.repoAutoInit, - }; - if (options.repoDescription !== undefined) payload.description = options.repoDescription; - const planned = { - repo, - owner, - name, - visibility, - description: options.repoDescription ?? null, - autoInit: options.repoAutoInit, - request: { - method: "POST", - path: createPath, - body: { - name, - private: visibility === "private", - descriptionChars: options.repoDescription?.length ?? 0, - auto_init: options.repoAutoInit, - }, - }, - }; - if (options.dryRun) { - return { - ok: true, - command: "repo create", - repo, - dryRun: true, - planned, - note: "Dry-run only; no GitHub repository was created.", - }; - } - const existing = await githubRequest(token, "GET", `/repos/${owner}/${name}`); - if (!isGitHubError(existing)) { - return validationError("repo create", repo, "repository already exists; refusing duplicate create", { - repository: repoSummary(existing), - planned, - next: { - command: `bun scripts/cli.ts gh repo view ${repo}`, - }, - }); - } - if (existing.degradedReason !== "repo-not-found") { - return commandError("repo create", repo, existing, { phase: "preflight-existing-repo", planned }); - } - const created = await githubRequest(token, "POST", createPath, payload); - if (isGitHubError(created)) return commandError("repo create", repo, created, { planned }); - return { - ok: true, - command: "repo create", - repo, - repository: repoSummary(created), - planned, - rest: true, - }; -} - -function issueLifecycleSummary(issue: GitHubIssue): Record { - return { - id: issue.id, - 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, - 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 { - 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 view --json body when full text is needed.", - readCommands: issueBodyReadCommands(repo, issueNumber), - }; -} - -function issueLifecycleBatchSummary(issues: GitHubIssue[]): Record { - 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 { - if (typeof label === "string") return { name: label, color: null, description: null }; - return { - name: label.name ?? "", - color: label.color ?? null, - description: label.description ?? null, - }; -} - -function issueLabelNames(issue: GitHubIssue): string[] { - return (issue.labels ?? []).map((label) => typeof label === "string" ? label : label.name ?? "").filter((label) => label.length > 0); -} - -function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Record { - const summary: Record = { - 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, - author: issue.user?.login ?? null, - labels: (issue.labels ?? []).map(labelSummary), - }; - const selected: Record = {}; - for (const field of fields) selected[field] = summary[field]; - return selected; -} - -function boardIssueEntry(issue: GitHubIssue): BoardIssueEntry { - return { - number: issue.number, - title: issue.title, - state: issue.state, - url: issue.html_url, - }; -} - -function sortedIssueEntries(issues: BoardIssueEntry[]): BoardIssueEntry[] { - return issues.slice().sort((a, b) => a.number - b.number); -} - -function mergedKnownMetaIssues(options: GitHubOptions): number[] { - const seen = new Set(); - const values: number[] = []; - for (const issueNumber of [...DEFAULT_BOARD_KNOWN_META_ISSUES, ...options.knownMetaIssues]) { - if (seen.has(issueNumber)) continue; - seen.add(issueNumber); - values.push(issueNumber); - } - return values; -} - -function boardIgnoreMap(options: GitHubOptions, issues: BoardIssueEntry[] = []): Map { - const ignored = new Map(); - for (const issueNumber of mergedKnownMetaIssues(options)) ignored.set(issueNumber, "known-meta"); - for (const issueNumber of options.ignoredIssues) ignored.set(issueNumber, "ignored"); - if (options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE) { - for (const issue of issues) { - if (!ignored.has(issue.number) && isDailyCommanderBriefTitle(issue.title)) ignored.set(issue.number, "brief-index-managed"); - } - } - return ignored; -} - -function stripMarkdownInline(text: string): string { - return text - .replace(/\[[^\]]*]\(([^)]*)\)/g, "$1") - .replace(/<[^>]+>/g, "") - .replace(/[*_`~]/g, "") - .trim(); -} - -function normalizeBoardHeader(header: string): string { - return stripMarkdownInline(header).toLowerCase().replace(/\s+/g, ""); -} - -function boardHeaderColumnKind(header: string): BoardColumnKind | null { - const normalized = normalizeBoardHeader(header); - if (["category", "类别", "分类", "类型", "任务类型"].includes(normalized)) return "category"; - if (["branch", "分支", "目标分支", "工作分支"].includes(normalized)) return "branch"; - if (["acceptance", "验收", "验收状态", "验收结果", "验收标准"].includes(normalized)) return "acceptance"; - if (["relatedtask", "task", "codequeue", "codequeuetask", "相关任务", "任务", "codequeue任务", "cq任务", "相关codequeue任务", "相关codequeuetask"].includes(normalized)) return "relatedTask"; - if (["githubstatus", "githubstate", "github状态", "githubissue状态", "issuestate", "issue状态", "issuegithub状态", "开闭状态", "openclosed", "openclosed状态"].includes(normalized)) return "githubStatus"; - if (["summary", "摘要", "概述", "任务摘要", "任务概述", "标题"].includes(normalized)) return "summary"; - if (["focus", "currentfocus", "关注点", "当前关注点", "当前focus"].includes(normalized)) return "focus"; - if (["progress", "进度", "状态", "当前进度"].includes(normalized)) return "progress"; - return null; -} - -function boardColumnIndexMap(headers: string[]): Map { - const map = new Map(); - headers.forEach((header, headerIndex) => { - const kind = boardHeaderColumnKind(header); - if (kind !== null && !map.has(kind)) map.set(kind, headerIndex); - }); - return map; -} - -function boardRequiredColumnIndexMap(headers: string[]): Map { - const map = new Map(); - const allColumns = boardColumnIndexMap(headers); - for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) { - const index = allColumns.get(column); - if (index !== undefined) map.set(column, index); - } - return map; -} - -function boardColumnKindForField(field: BoardRowField): BoardColumnKind { - if (field === "branch") return "branch"; - if (field === "tasks") return "relatedTask"; - if (field === "focus") return "focus"; - if (field === "progress") return "progress"; - return "acceptance"; -} - -function boardIssueColumnIndex(headers: string[]): number | null { - for (let index = 0; index < headers.length; index += 1) { - const normalized = normalizeBoardHeader(headers[index]); - if (["issue", "issue#", "issuenumber", "issueid", "issue编号", "issue号", "编号"].includes(normalized)) return index; - } - return null; -} - -function markdownCells(line: string): string[] { - const trimmed = line.trim(); - const withoutOuterPipes = trimmed.replace(/^\|/u, "").replace(/\|$/u, ""); - const cells: string[] = []; - let current = ""; - for (let index = 0; index < withoutOuterPipes.length; index += 1) { - const char = withoutOuterPipes[index]; - const previous = withoutOuterPipes[index - 1]; - if (char === "|" && previous !== "\\") { - cells.push(current.trim().replace(/\\\|/g, "|")); - current = ""; - continue; - } - current += char; - } - cells.push(current.trim().replace(/\\\|/g, "|")); - return cells; -} - -function isMarkdownTableSeparator(line: string): boolean { - const cells = markdownCells(line); - return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/u.test(cell.trim())); -} - -function boardSectionKindFromHeading(line: string): BoardSectionKind | null { - const normalized = line.toLowerCase(); - if (!line.trimStart().startsWith("#")) return null; - if (normalized.includes("open") || normalized.includes("看板(open)") || normalized.includes("看板(open)") || normalized.includes("开放")) return "open"; - if (normalized.includes("closed") || normalized.includes("看板(closed)") || normalized.includes("看板(closed)") || normalized.includes("关闭") || normalized.includes("已关闭")) return "closed"; - return null; -} - -function extractIssueNumbers(text: string): number[] { - const numbers: number[] = []; - const seen = new Set(); - const patterns = [/#(\d+)/g, /\/issues\/(\d+)/g]; - for (const pattern of patterns) { - pattern.lastIndex = 0; - let match = pattern.exec(text); - while (match !== null) { - const value = Number(match[1]); - if (Number.isInteger(value) && value > 0 && !seen.has(value)) { - seen.add(value); - numbers.push(value); - } - match = pattern.exec(text); - } - } - return numbers; -} - -function primaryBoardIssueNumberFromIssueCell(issueCell: string | undefined): number | null { - if (issueCell === undefined) return null; - const markdownLinkPattern = /\[[^\]]*]\(([^)]*)\)/g; - let match = markdownLinkPattern.exec(issueCell); - while (match !== null) { - const issueNumber = /\/issues\/(\d+)/u.exec(match[1] ?? "")?.[1]; - if (issueNumber !== undefined) { - const value = Number(issueNumber); - if (Number.isInteger(value) && value > 0) return value; - } - match = markdownLinkPattern.exec(issueCell); - } - const leadingIssueReference = /^\s*#(\d+)\b/u.exec(issueCell)?.[1]; - if (leadingIssueReference !== undefined) { - const value = Number(leadingIssueReference); - if (Number.isInteger(value) && value > 0) return value; - } - return null; -} - -function boardRowFromCells(section: BoardTableSection, raw: string, cells: string[], lineNumber: number): BoardTableRow { - const columnMap = boardRequiredColumnIndexMap(section.headers); - const issueColumnIndex = boardIssueColumnIndex(section.headers); - const issueCell = issueColumnIndex === null ? undefined : cells[issueColumnIndex]; - const primaryIssueNumber = issueColumnIndex === null ? null : primaryBoardIssueNumberFromIssueCell(issueCell); - const issueNumbers = issueColumnIndex === null - ? extractIssueNumbers(raw) - : (primaryIssueNumber === null ? extractIssueNumbers(issueCell ?? "") : [primaryIssueNumber]); - const columns: Partial> = {}; - for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) { - const cellIndex = columnMap.get(column); - if (cellIndex !== undefined) columns[column] = cells[cellIndex] ?? ""; - } - return { - section: section.kind, - lineNumber, - raw, - cells, - issueNumbers, - issueNumber: issueNumbers[0] ?? null, - title: issueColumnIndex === null - ? (cells.find((cell) => extractIssueNumbers(cell).length > 0) ?? cells[0] ?? null) - : (issueCell ?? cells[0] ?? null), - columns, - }; -} - -function normalizedBoardCellPlaceholder(value: string): { spaced: string; compact: string } { - const spaced = stripMarkdownInline(value).toLowerCase().replace(/\s+/g, " ").trim(); - return { spaced, compact: spaced.replace(/\s+/g, "") }; -} - -function isRelatedTaskNoTaskPlaceholder(value: string): boolean { - const normalized = normalizedBoardCellPlaceholder(value); - return [ - "-", - "—", - "–", - "n/a", - "na", - "none", - "no task", - "no-task", - "no code queue task", - "not applicable", - ].includes(normalized.spaced) || [ - "无", - "无任务", - "无关联任务", - "无相关任务", - "无codequeue任务", - "无codequeuetask", - "不适用", - ].includes(normalized.compact); -} - -function cellHasMeaningfulValue(value: string | undefined, column: BoardRequiredColumn): boolean { - if (value === undefined) return false; - const stripped = stripMarkdownInline(value); - if (stripped.length === 0) return false; - if (column === "relatedTask" && isRelatedTaskNoTaskPlaceholder(value)) return true; - return !["-", "—", "n/a", "na", "todo", "tbd", "待补", "未填", "无"].includes(stripped.toLowerCase()); -} - -function parseBoardTables(body: string): { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] } { - const lines = normalizeNewlines(body).split("\n"); - const sections: BoardTableSection[] = []; - const warnings: BoardRowValidationWarning[] = []; - let currentKind: BoardSectionKind | null = null; - let currentHeading = ""; - let currentHeadingLine = 0; - for (let index = 0; index < lines.length; index += 1) { - const headingKind = boardSectionKindFromHeading(lines[index]); - if (headingKind !== null) { - currentKind = headingKind; - currentHeading = lines[index].trim(); - currentHeadingLine = index + 1; - continue; - } - if (currentKind === null) continue; - if (!lines[index].trim().startsWith("|")) continue; - const separator = lines[index + 1]; - if (separator === undefined || !separator.trim().startsWith("|") || !isMarkdownTableSeparator(separator)) continue; - const headers = markdownCells(lines[index]); - const rows: BoardTableRow[] = []; - let rowIndex = index + 2; - while (rowIndex < lines.length && lines[rowIndex].trim().startsWith("|")) { - if (!isMarkdownTableSeparator(lines[rowIndex])) { - const cells = markdownCells(lines[rowIndex]); - const row = boardRowFromCells( - { kind: currentKind, heading: currentHeading, headingLine: currentHeadingLine, headerLine: index + 1, headers, rows: [] }, - lines[rowIndex], - cells, - rowIndex + 1, - ); - rows.push(row); - if (row.issueNumbers.length === 0) { - warnings.push({ - issueNumber: null, - section: currentKind, - lineNumber: row.lineNumber, - kind: "missing-issue-reference", - message: "Board table row does not contain a GitHub issue reference such as #36.", - rowPreview: preview(row.raw), - }); - } - if (row.issueNumbers.length > 1) { - warnings.push({ - issueNumber: row.issueNumber, - section: currentKind, - lineNumber: row.lineNumber, - kind: "multiple-issue-references", - message: boardIssueColumnIndex(headers) === null - ? "Board table row contains multiple issue references; audit uses the first number as the row key." - : "Board table row contains multiple issue references in the Issue column; audit uses the first number as the row key.", - rowPreview: preview(row.raw), - }); - } - const missingColumns = BOARD_AUDIT_REQUIRED_COLUMNS.filter((column) => !cellHasMeaningfulValue(row.columns[column], column)); - if (missingColumns.length > 0) { - warnings.push({ - issueNumber: row.issueNumber, - section: currentKind, - lineNumber: row.lineNumber, - kind: "missing-required-columns", - message: "Board table row is missing branch, acceptance, related task, or progress information.", - missingColumns, - columns: row.columns, - rowPreview: preview(row.raw), - }); - } - } - rowIndex += 1; - } - sections.push({ - kind: currentKind, - heading: currentHeading, - headingLine: currentHeadingLine, - headerLine: index + 1, - headers, - rows, - }); - index = rowIndex - 1; - } - return { sections, warnings }; -} - -function boardRowsByIssue(rows: BoardTableRow[]): Map { - const map = new Map(); - for (const row of rows) { - if (row.issueNumber === null) continue; - const list = map.get(row.issueNumber); - if (list === undefined) map.set(row.issueNumber, [row]); - else list.push(row); - } - return map; -} - -function boardRowFieldValues(section: BoardTableSection, row: BoardTableRow): Record { - const columnMap = boardColumnIndexMap(section.headers); - const valueFor = (kind: BoardColumnKind): string | null => { - const index = columnMap.get(kind); - return index === undefined ? null : row.cells[index] ?? ""; - }; - const acceptance = valueFor("acceptance"); - return { - progress: valueFor("progress"), - status: acceptance, - validation: acceptance, - branch: valueFor("branch"), - tasks: valueFor("relatedTask"), - focus: valueFor("focus"), - }; -} - -function boardRowUpsertFieldValues(section: BoardTableSection, row: BoardTableRow): Record { - const columnMap = boardColumnIndexMap(section.headers); - const valueFor = (kind: BoardColumnKind): string | null => { - const index = columnMap.get(kind); - return index === undefined ? null : row.cells[index] ?? ""; - }; - const acceptance = valueFor("acceptance"); - return { - category: valueFor("category"), - branch: valueFor("branch"), - tasks: valueFor("relatedTask"), - summary: valueFor("summary"), - focus: valueFor("focus"), - validation: acceptance, - progress: valueFor("progress"), - status: valueFor("githubStatus"), - }; -} - -function boardRowSummary(section: BoardTableSection, row: BoardTableRow): Record { - return { - issueNumber: row.issueNumber, - section: row.section, - lineNumber: row.lineNumber, - heading: section.heading, - headers: section.headers, - title: row.title, - cells: row.cells, - fields: boardRowFieldValues(section, row), - raw: row.raw, - rowPreview: preview(row.raw), - }; -} - -function boardRowsExactMatch( - sections: BoardTableSection[], - issueNumber: number, -): Array<{ section: BoardTableSection; row: BoardTableRow }> { - return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber); -} - -function boardRowsForState(sections: BoardTableSection[], state: IssueListState): Array<{ section: BoardTableSection; row: BoardTableRow }> { - return sections - .filter((section) => state === "all" || section.kind === state) - .flatMap((section) => section.rows.map((row) => ({ section, row }))); -} - -function boardDuplicateRows( - sections: BoardTableSection[], - issueNumber: number, -): Array<{ section: BoardTableSection; row: BoardTableRow }> { - return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber); -} - -function targetBoardSection( - repo: string, - sections: BoardTableSection[], - kind: BoardMutationSection, - commandName: string, -): { ok: true; section: BoardTableSection } | GitHubCommandFailure { - const matches = sections.filter((section) => section.kind === kind); - if (matches.length === 0) { - return validationError(commandName, repo, `board ${kind.toUpperCase()} table was not found`, { - section: kind, - availableSections: sections.map((section) => ({ kind: section.kind, heading: section.heading, headerLine: section.headerLine })), - }) as GitHubCommandFailure; - } - if (matches.length > 1) { - return validationError(commandName, repo, `board ${kind.toUpperCase()} table is ambiguous`, { - section: kind, - matches: matches.map((section) => ({ heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine })), - }) as GitHubCommandFailure; - } - return { ok: true, section: matches[0] }; -} - -function findBoardRow( - repo: string, - sections: BoardTableSection[], - issueNumber: number, - state: IssueListState = "all", -): BoardRowLookup | GitHubCommandFailure { - const matches = boardRowsForState(sections, state).filter(({ row }) => row.issueNumber === issueNumber); - if (matches.length === 0) { - return validationError("issue board-row", repo, `board row for issue #${issueNumber} was not found`, { - issueNumber, - state, - availableIssueNumbers: boardRowsForState(sections, state) - .map(({ row }) => row.issueNumber) - .filter((value): value is number => value !== null) - .sort((a, b) => a - b), - }) as GitHubCommandFailure; - } - if (matches.length > 1) { - return validationError("issue board-row", repo, `board row for issue #${issueNumber} is ambiguous`, { - issueNumber, - state, - matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), - }) as GitHubCommandFailure; - } - return { ok: true, ...matches[0], matches }; -} - -function escapeMarkdownTableCell(value: string): string { - return value - .replace(/\r\n/g, " ") - .replace(/[\r\n]+/g, " ") - .replace(/\|/g, "\\|") - .trim(); -} - -function boardRowValuePollutionEvidence(value: string): string[] { - return shellPollutionEvidence(value).filter((item) => item !== "bare-carriage-return"); -} - -function renderMarkdownTableRow(cells: string[]): string { - return `| ${cells.map(escapeMarkdownTableCell).join(" | ")} |`; -} - -function validateMarkdownTableRowLine(line: string): string | null { - if (!line.trim().startsWith("|") || !line.trim().endsWith("|")) return "row-file must contain a Markdown table row starting and ending with |"; - if (isMarkdownTableSeparator(line)) return "row-file must contain a data row, not a Markdown table separator"; - return null; -} - -function readBoardRowFile(path: string | undefined, command: string): { path: string; raw: string; cells: string[] } { - if (path === undefined) throw new Error(`${command} requires --row-file `); - if (!existsSync(path)) throw new Error(`row file not found: ${path}`); - const normalized = normalizeNewlines(readFileSync(path, "utf8")).trim(); - if (normalized.length === 0) throw new Error("row-file must contain exactly one non-empty Markdown table row"); - const lines = normalized.split("\n"); - if (lines.length !== 1) throw new Error("row-file must contain exactly one Markdown table row"); - const line = lines[0]; - const lineError = validateMarkdownTableRowLine(line); - if (lineError !== null) throw new Error(lineError); - return { path, raw: line, cells: markdownCells(line) }; -} - -function replaceBoardBodyLine(body: string, lineNumber: number, newLine: string): string { - const normalized = normalizeNewlines(body); - const lines = normalized.split("\n"); - if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`); - lines[lineNumber - 1] = newLine; - return lines.join("\n"); -} - -function removeBoardBodyLine(body: string, lineNumber: number): string { - const normalized = normalizeNewlines(body); - const lines = normalized.split("\n"); - if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`); - lines.splice(lineNumber - 1, 1); - return lines.join("\n"); -} - -function insertBoardBodyLineAfter(body: string, lineNumber: number, newLine: string): string { - const normalized = normalizeNewlines(body); - const lines = normalized.split("\n"); - if (lineNumber < 0 || lineNumber > lines.length) throw new Error(`insert line ${lineNumber} is outside the board body`); - lines.splice(lineNumber, 0, newLine); - return lines.join("\n"); -} - -function boardSectionInsertAfterLine(section: BoardTableSection): number { - if (section.rows.length === 0) return section.headerLine + 1; - return section.rows[section.rows.length - 1].lineNumber; -} - -function boardBodySafetyIssue(repo: string, boardIssueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null { - const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile }; - return validateIssueBodyGuard(repo, boardIssueNumber, body, guardOptions); -} - -function boardBodyGuardSummary(boardIssueNumber: number, body: string, options: GitHubOptions): Record { - const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile }; - return issueEditGuardSummary(boardIssueNumber, body, guardOptions); -} - -function boardRowUpdatePlan( - repo: string, - boardIssue: GitHubIssue, - issueNumber: number, - field: BoardRowField, - value: string, - parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, -): BoardRowUpdatePlanResult | GitHubCommandFailure { - const found = findBoardRow(repo, parsed.sections, issueNumber); - if (found.ok === false) return found; - const { section, row } = found; - const targetColumn = boardColumnKindForField(field); - const columnMap = boardColumnIndexMap(section.headers); - const columnIndex = columnMap.get(targetColumn); - if (columnIndex === undefined) { - return validationError("issue board-row update", repo, `board table does not contain a column for --field ${field}`, { - issueNumber, - field, - targetColumn, - section: section.kind, - headers: section.headers, - supportedFields: BOARD_ROW_FIELDS.slice(), - }) as GitHubCommandFailure; - } - const oldCells = row.cells.slice(); - const newCells = oldCells.slice(); - while (newCells.length < section.headers.length) newCells.push(""); - newCells[columnIndex] = value; - const newRow = renderMarkdownTableRow(newCells); - const oldBody = boardIssue.body ?? ""; - let newBody: string; - try { - newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow); - } catch (error) { - return validationError("issue board-row update", repo, error instanceof Error ? error.message : String(error), { - issueNumber, - field, - lineNumber: row.lineNumber, - }) as GitHubCommandFailure; - } - return { - ok: true, - newBody, - plan: { - repo, - boardIssue: boardIssue.number, - issueNumber, - field, - targetColumn, - targetColumnIndex: columnIndex, - section: section.kind, - lineNumber: row.lineNumber, - oldRow: row.raw, - newRow, - oldCells, - newCells, - oldValue: oldCells[columnIndex] ?? "", - newValue: value, - row: { - old: boardRowSummary(section, row), - new: { - ...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }), - updatedField: field, - }, - }, - body: { - oldBodyChars: oldBody.length, - oldBodySha: bodySha(oldBody), - newBodyChars: newBody.length, - newBodySha: bodySha(newBody), - changed: oldBody !== newBody, - }, - tableParser: { - reused: "parseBoardTables", - rowValidationWarnings: parsed.warnings.length, - }, - request: { - method: "PATCH", - path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, - body: { bodyChars: newBody.length }, - }, - }, - }; -} - -function expectedBoardGithubStatus(section: BoardMutationSection): BoardGithubStatus { - return section === "open" ? "OPEN" : "CLOSED"; -} - -function normalizeBoardGithubStatusCell(value: string | undefined): BoardGithubStatus | null { - const normalized = stripMarkdownInline(value ?? "").toUpperCase().replace(/\s+/g, ""); - if (normalized === "OPEN") return "OPEN"; - if (normalized === "CLOSED") return "CLOSED"; - return null; -} - -function boardHeaderShape(headers: string[]): string[] { - return headers.map((header) => normalizeBoardHeader(header)); -} - -function sameBoardHeaderShape(a: string[], b: string[]): boolean { - const left = boardHeaderShape(a); - const right = boardHeaderShape(b); - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -function boardRowDuplicateFailure( - repo: string, - commandName: string, - sections: BoardTableSection[], - issueNumber: number, -): GitHubCommandFailure | null { - const duplicates = boardDuplicateRows(sections, issueNumber); - if (duplicates.length === 0) return null; - return validationError(commandName, repo, `board row for issue #${issueNumber} already exists; refusing duplicate row`, { - issueNumber, - matches: duplicates.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), - }) as GitHubCommandFailure; -} - -function boardStatusOptionFailure( - repo: string, - commandName: string, - targetSection: BoardMutationSection, - status: BoardGithubStatus | undefined, -): GitHubCommandFailure | null { - if (status === undefined) return null; - const expected = expectedBoardGithubStatus(targetSection); - if (status === expected) return null; - return validationError(commandName, repo, `--status ${status} conflicts with --to ${targetSection}; expected ${expected}`, { - targetSection, - status, - expectedStatus: expected, - }) as GitHubCommandFailure; -} - -function boardRowStatusConsistency( - repo: string, - commandName: string, - section: BoardTableSection, - row: BoardTableRow, - role: "source" | "target", -): { ok: true; githubStatusColumnIndex: number; actualStatus: BoardGithubStatus | null; expectedStatus: BoardGithubStatus } | GitHubCommandFailure { - const columnMap = boardColumnIndexMap(section.headers); - const githubStatusColumnIndex = columnMap.get("githubStatus"); - if (githubStatusColumnIndex === undefined) { - return validationError(commandName, repo, `${role} board table does not contain a GitHub 状态 column`, { - section: section.kind, - headers: section.headers, - lineNumber: row.lineNumber, - rowPreview: preview(row.raw), - }) as GitHubCommandFailure; - } - const expectedStatus = expectedBoardGithubStatus(section.kind); - const actualStatus = normalizeBoardGithubStatusCell(row.cells[githubStatusColumnIndex]); - if (actualStatus !== expectedStatus) { - return validationError(commandName, repo, `${role} row GitHub 状态 ${actualStatus ?? "missing"} conflicts with ${section.kind.toUpperCase()} section; expected ${expectedStatus}`, { - section: section.kind, - lineNumber: row.lineNumber, - rowPreview: preview(row.raw), - actualStatus, - expectedStatus, - githubStatusColumnIndex, - headers: section.headers, - }) as GitHubCommandFailure; - } - return { ok: true, githubStatusColumnIndex, actualStatus, expectedStatus }; -} - -function boardRowUpsertValueEntries(values: BoardRowUpsertValues): Array<{ field: BoardRowUpsertField; value: string }> { - const entries: Array<{ field: BoardRowUpsertField; value: string }> = []; - for (const field of BOARD_ROW_UPSERT_TEXT_FIELDS) { - const value = values[field]; - if (value !== undefined) entries.push({ field, value }); - } - if (values.status !== undefined) entries.push({ field: "status", value: values.status }); - return entries; -} - -function boardColumnKindForUpsertField(field: BoardRowUpsertField): BoardColumnKind { - if (field === "category") return "category"; - if (field === "branch") return "branch"; - if (field === "tasks") return "relatedTask"; - if (field === "summary") return "summary"; - if (field === "focus") return "focus"; - if (field === "validation") return "acceptance"; - if (field === "status") return "githubStatus"; - return "progress"; -} - -function renderBoardIssueCell(repo: string, issueNumber: number, summary: string | undefined): string { - const trimmed = (summary ?? "").trim(); - if (trimmed.length === 0) return `#${issueNumber}`; - const targetHref = `https://github.com/${repo}/issues/${issueNumber}`; - const firstMarkdownIssue = /^\s*\[[^\]]*\]\(([^)]*)\)/u.exec(trimmed)?.[1] ?? ""; - if (new RegExp(`/issues/${issueNumber}(?:\\b|$)`, "u").test(firstMarkdownIssue)) return trimmed; - return `[#${issueNumber}](${targetHref}) ${trimmed}`; -} - -function boardRowUpsertTargetColumn( - repo: string, - commandName: string, - section: BoardTableSection, - issueNumber: number, - field: BoardRowUpsertField, -): { ok: true; columnIndex: number; targetColumn: BoardColumnKind | "issue"; usesIssueCell: boolean } | GitHubCommandFailure { - const columnMap = boardColumnIndexMap(section.headers); - if (field === "summary") { - const summaryIndex = columnMap.get("summary"); - if (summaryIndex !== undefined) return { ok: true, columnIndex: summaryIndex, targetColumn: "summary", usesIssueCell: false }; - const issueColumnIndex = boardIssueColumnIndex(section.headers); - if (issueColumnIndex !== null) return { ok: true, columnIndex: issueColumnIndex, targetColumn: "issue", usesIssueCell: true }; - } - const targetColumn = boardColumnKindForUpsertField(field); - const columnIndex = columnMap.get(targetColumn); - if (columnIndex === undefined) { - return validationError(commandName, repo, `board table does not contain a column for --${field}`, { - issueNumber, - field, - targetColumn, - section: section.kind, - headers: section.headers, - }) as GitHubCommandFailure; - } - return { ok: true, columnIndex, targetColumn, usesIssueCell: false }; -} - -function boardRowUpsertRenderedValue( - repo: string, - issueNumber: number, - field: BoardRowUpsertField, - value: string, - targetColumn: BoardColumnKind | "issue", -): string { - if (field === "summary" && targetColumn === "issue") return renderBoardIssueCell(repo, issueNumber, value); - return value; -} - -function boardRowUpsertPollutionFailure( - repo: string, - commandName: string, - issueNumber: number, - values: BoardRowUpsertValues, -): GitHubCommandFailure | null { - const findings = BOARD_ROW_UPSERT_TEXT_FIELDS - .map((field) => { - const value = values[field]; - if (value === undefined) return null; - const evidence = boardRowValuePollutionEvidence(value); - return evidence.length === 0 ? null : { field, valuePreview: preview(value), evidence }; - }) - .filter((item): item is { field: BoardRowUpsertTextField; valuePreview: string; evidence: string[] } => item !== null); - if (findings.length === 0) return null; - return validationError(commandName, repo, "issue board-row upsert field value contains literal shell escape text that would pollute a board cell; pass real newlines or plain text instead", { - issueNumber, - shellPollution: { suspected: true, findings }, - }) as GitHubCommandFailure; -} - -function boardRowUpsertUpdatePlan( - repo: string, - boardIssue: GitHubIssue, - issueNumber: number, - found: { section: BoardTableSection; row: BoardTableRow }, - values: BoardRowUpsertValues, - requestedSection: BoardMutationSection | undefined, - parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, -): BoardRowMutationPlanResult | GitHubCommandFailure { - const commandName = "issue board-row upsert"; - const { section, row } = found; - if (requestedSection !== undefined && requestedSection !== section.kind) { - return validationError(commandName, repo, `--section ${requestedSection} conflicts with existing row in ${section.kind}; use gh issue board-row move for section migration`, { - issueNumber, - requestedSection, - existingSection: section.kind, - moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${requestedSection}`, - }) as GitHubCommandFailure; - } - if (values.status !== undefined) { - const expectedStatus = expectedBoardGithubStatus(section.kind); - if (values.status !== expectedStatus) { - return validationError(commandName, repo, `--status ${values.status} conflicts with existing ${section.kind.toUpperCase()} section; use gh issue board-row move for section migration`, { - issueNumber, - existingSection: section.kind, - requestedStatus: values.status, - expectedStatus, - moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${values.status === "OPEN" ? "open" : "closed"} --status ${values.status}`, - }) as GitHubCommandFailure; - } - } - const entries = boardRowUpsertValueEntries(values); - if (entries.length === 0) { - return validationError(commandName, repo, "issue board-row upsert found an existing row but no upsert fields were provided", { - issueNumber, - existingSection: section.kind, - supportedFields: ["--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--status"], - }) as GitHubCommandFailure; - } - if (row.cells.length !== section.headers.length) { - return validationError(commandName, repo, "existing row column count does not match the board table header", { - issueNumber, - section: section.kind, - expectedColumns: section.headers.length, - actualColumns: row.cells.length, - headers: section.headers, - lineNumber: row.lineNumber, - rowPreview: preview(row.raw), - }) as GitHubCommandFailure; - } - const oldCells = row.cells.slice(); - const newCells = oldCells.slice(); - const changedFields: Record[] = []; - for (const entry of entries) { - const target = boardRowUpsertTargetColumn(repo, commandName, section, issueNumber, entry.field); - if (target.ok === false) return target; - const newValue = boardRowUpsertRenderedValue(repo, issueNumber, entry.field, entry.value, target.targetColumn); - const oldValue = oldCells[target.columnIndex] ?? ""; - newCells[target.columnIndex] = newValue; - changedFields.push({ - field: entry.field, - targetColumn: target.targetColumn, - targetColumnIndex: target.columnIndex, - requestedValue: entry.value, - oldValue, - newValue, - changed: oldValue !== newValue, - }); - } - const newRow = renderMarkdownTableRow(newCells); - const oldBody = boardIssue.body ?? ""; - let newBody: string; - try { - newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { - issueNumber, - lineNumber: row.lineNumber, - }) as GitHubCommandFailure; - } - return { - ok: true, - newBody, - plan: { - repo, - boardIssue: boardIssue.number, - issueNumber, - operation: "update", - action: "update", - section: section.kind, - lineNumber: row.lineNumber, - requestedSection: requestedSection ?? null, - providedFields: entries.map((entry) => entry.field), - changedFields, - oldRow: row.raw, - newRow, - oldCells, - newCells, - row: { - old: boardRowSummary(section, row), - new: { - ...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }), - upsertFields: boardRowUpsertFieldValues(section, { ...row, raw: newRow, cells: newCells }), - }, - }, - linePlan: { - action: "replace", - section: section.kind, - lineNumber: row.lineNumber, - }, - sectionPlan: { - kind: section.kind, - heading: section.heading, - headerLine: section.headerLine, - rowCount: section.rows.length, - }, - body: { - oldBodyChars: oldBody.length, - oldBodySha: bodySha(oldBody), - newBodyChars: newBody.length, - newBodySha: bodySha(newBody), - changed: oldBody !== newBody, - }, - tableParser: { - reused: "parseBoardTables", - rowValidationWarnings: parsed.warnings.length, - }, - request: { - method: "PATCH", - path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, - body: { bodyChars: newBody.length }, - }, - }, - }; -} - -function boardRowUpsertAddPlan( - repo: string, - boardIssue: GitHubIssue, - issueNumber: number, - sectionKind: BoardMutationSection, - values: BoardRowUpsertValues, - parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, -): BoardRowMutationPlanResult | GitHubCommandFailure { - const commandName = "issue board-row upsert"; - const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName); - if (targetLookup.ok === false) return targetLookup; - const target = targetLookup.section; - const issueColumnIndex = boardIssueColumnIndex(target.headers); - if (issueColumnIndex === null) { - return validationError(commandName, repo, "board table does not contain an Issue column required for generated upsert rows", { - boardIssue: boardIssue.number, - section: sectionKind, - headers: target.headers, - }) as GitHubCommandFailure; - } - const columnMap = boardColumnIndexMap(target.headers); - const githubStatusColumnIndex = columnMap.get("githubStatus"); - if (githubStatusColumnIndex === undefined) { - return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for generated upsert rows", { - boardIssue: boardIssue.number, - section: sectionKind, - headers: target.headers, - }) as GitHubCommandFailure; - } - const expectedStatus = expectedBoardGithubStatus(sectionKind); - const requestedStatus = values.status ?? expectedStatus; - if (requestedStatus !== expectedStatus) { - return validationError(commandName, repo, `--status ${requestedStatus} conflicts with --section ${sectionKind}; expected ${expectedStatus}`, { - issueNumber, - section: sectionKind, - requestedStatus, - expectedStatus, - }) as GitHubCommandFailure; - } - const appliedFields = new Set(); - const missingFields: BoardRowUpsertTextField[] = []; - const unsupportedColumns: Array<{ index: number; header: string }> = []; - const summaryColumnIndex = columnMap.get("summary"); - const cells = target.headers.map((header, index) => { - if (index === issueColumnIndex) { - if (summaryColumnIndex === undefined && values.summary !== undefined) appliedFields.add("summary"); - return summaryColumnIndex === undefined ? renderBoardIssueCell(repo, issueNumber, values.summary) : `#${issueNumber}`; - } - const kind = boardHeaderColumnKind(header); - if (kind === "githubStatus") { - if (values.status !== undefined) appliedFields.add("status"); - return requestedStatus; - } - const valueFor = (field: BoardRowUpsertTextField, value: string | undefined): string => { - if (value === undefined) { - missingFields.push(field); - return ""; - } - appliedFields.add(field); - return value; - }; - if (kind === "category") return valueFor("category", values.category); - if (kind === "branch") return valueFor("branch", values.branch); - if (kind === "relatedTask") return valueFor("tasks", values.tasks); - if (kind === "summary") return valueFor("summary", values.summary); - if (kind === "focus") return valueFor("focus", values.focus); - if (kind === "acceptance") return valueFor("validation", values.validation); - if (kind === "progress") return valueFor("progress", values.progress); - unsupportedColumns.push({ index, header }); - return ""; - }); - if (unsupportedColumns.length > 0) { - return validationError(commandName, repo, "target board table contains unsupported column(s) for generated upsert rows", { - issueNumber, - section: sectionKind, - unsupportedColumns, - headers: target.headers, - }) as GitHubCommandFailure; - } - const unappliedFields = boardRowUpsertValueEntries(values) - .map((entry) => entry.field) - .filter((field) => !appliedFields.has(field)); - if (unappliedFields.length > 0) { - return validationError(commandName, repo, "target board table does not contain column(s) for supplied upsert field(s)", { - issueNumber, - section: sectionKind, - unappliedFields, - headers: target.headers, - }) as GitHubCommandFailure; - } - const uniqueMissingFields = Array.from(new Set(missingFields)); - if (uniqueMissingFields.length > 0) { - return validationError(commandName, repo, "issue board-row upsert add requires values for every generated row column", { - issueNumber, - section: sectionKind, - missingFields: uniqueMissingFields, - requiredOptions: uniqueMissingFields.map((field) => `--${field}`), - headers: target.headers, - }) as GitHubCommandFailure; - } - const newRow = renderMarkdownTableRow(cells); - const row = boardRowFromCells(target, newRow, cells, 0); - if (row.issueNumber !== issueNumber) { - return validationError(commandName, repo, "generated Issue column does not match the requested issue number", { - issueNumber, - issueColumnIndex, - issueCell: cells[issueColumnIndex] ?? "", - detectedIssueNumber: row.issueNumber, - rowPreview: preview(newRow), - }) as GitHubCommandFailure; - } - const actualStatus = normalizeBoardGithubStatusCell(cells[githubStatusColumnIndex]); - if (actualStatus !== expectedStatus) { - return validationError(commandName, repo, "generated GitHub 状态 column does not match the target section", { - issueNumber, - section: sectionKind, - expectedStatus, - actualStatus, - statusColumnIndex: githubStatusColumnIndex, - statusCell: cells[githubStatusColumnIndex] ?? "", - rowPreview: preview(newRow), - }) as GitHubCommandFailure; - } - const oldBody = boardIssue.body ?? ""; - const insertAfterLine = boardSectionInsertAfterLine(target); - let newBody: string; - try { - newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, newRow); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { - issueNumber, - section: sectionKind, - insertAfterLine, - }) as GitHubCommandFailure; - } - return { - ok: true, - newBody, - plan: { - repo, - boardIssue: boardIssue.number, - issueNumber, - operation: "add", - action: "add", - section: sectionKind, - insertAfterLine, - providedFields: boardRowUpsertValueEntries(values).map((entry) => entry.field), - newRow, - newCells: cells, - row: { - old: null, - new: { - ...boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }), - upsertFields: boardRowUpsertFieldValues(target, { ...row, lineNumber: insertAfterLine + 1 }), - }, - }, - linePlan: { - action: "insert", - section: sectionKind, - insertAfterLine, - newLineNumber: insertAfterLine + 1, - }, - sectionPlan: { - kind: sectionKind, - heading: target.heading, - headerLine: target.headerLine, - rowCount: target.rows.length, - }, - validation: { - issueColumnIndex, - githubStatusColumnIndex, - expectedStatus, - actualStatus, - columnCount: cells.length, - }, - body: { - oldBodyChars: oldBody.length, - oldBodySha: bodySha(oldBody), - newBodyChars: newBody.length, - newBodySha: bodySha(newBody), - changed: oldBody !== newBody, - }, - tableParser: { - reused: "parseBoardTables", - rowValidationWarnings: parsed.warnings.length, - }, - request: { - method: "PATCH", - path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, - body: { bodyChars: newBody.length }, - }, - }, - }; -} - -function boardRowUpsertPlan( - repo: string, - boardIssue: GitHubIssue, - issueNumber: number, - options: GitHubOptions, - parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, -): BoardRowMutationPlanResult | GitHubCommandFailure { - const commandName = "issue board-row upsert"; - const pollutionFailure = boardRowUpsertPollutionFailure(repo, commandName, issueNumber, options.boardRowUpsertValues); - if (pollutionFailure !== null) return pollutionFailure; - const matches = boardRowsExactMatch(parsed.sections, issueNumber); - if (matches.length > 1) { - return validationError(commandName, repo, `board row for issue #${issueNumber} is ambiguous; refusing upsert`, { - issueNumber, - matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), - }) as GitHubCommandFailure; - } - if (matches.length === 1) { - return boardRowUpsertUpdatePlan(repo, boardIssue, issueNumber, matches[0], options.boardRowUpsertValues, options.boardSection, parsed); - } - if (options.boardSection === undefined) { - return validationError(commandName, repo, "issue board-row upsert add requires --section open|closed when the row does not already exist", { - issueNumber, - supportedSections: BOARD_MUTATION_SECTIONS.slice(), - }) as GitHubCommandFailure; - } - return boardRowUpsertAddPlan(repo, boardIssue, issueNumber, options.boardSection, options.boardRowUpsertValues, parsed); -} - -function boardRowAddPlan( - repo: string, - boardIssue: GitHubIssue, - issueNumber: number, - sectionKind: BoardMutationSection, - rowFile: { path: string; raw: string; cells: string[] }, - parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, -): BoardRowMutationPlanResult | GitHubCommandFailure { - const commandName = "issue board-row add"; - const duplicate = boardRowDuplicateFailure(repo, commandName, parsed.sections, issueNumber); - if (duplicate !== null) return duplicate; - const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName); - if (targetLookup.ok === false) return targetLookup; - const target = targetLookup.section; - const issueColumnIndex = boardIssueColumnIndex(target.headers); - if (issueColumnIndex === null) { - return validationError(commandName, repo, "board table does not contain an Issue column required for row-file validation", { - boardIssue: boardIssue.number, - section: sectionKind, - headers: target.headers, - }) as GitHubCommandFailure; - } - const columnMap = boardColumnIndexMap(target.headers); - const githubStatusColumnIndex = columnMap.get("githubStatus"); - if (githubStatusColumnIndex === undefined) { - return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for add section validation", { - boardIssue: boardIssue.number, - section: sectionKind, - headers: target.headers, - }) as GitHubCommandFailure; - } - if (rowFile.cells.length !== target.headers.length) { - return validationError(commandName, repo, "row-file column count does not match the target board table header", { - boardIssue: boardIssue.number, - section: sectionKind, - expectedColumns: target.headers.length, - actualColumns: rowFile.cells.length, - headers: target.headers, - rowFile: rowFile.path, - rowPreview: preview(rowFile.raw), - }) as GitHubCommandFailure; - } - const row = boardRowFromCells(target, rowFile.raw, rowFile.cells, 0); - if (row.issueNumber !== issueNumber) { - return validationError(commandName, repo, "row-file Issue column does not match the requested issue number", { - issueNumber, - issueColumnIndex, - issueCell: rowFile.cells[issueColumnIndex] ?? "", - detectedIssueNumber: row.issueNumber, - rowPreview: preview(rowFile.raw), - }) as GitHubCommandFailure; - } - const expectedStatus = expectedBoardGithubStatus(sectionKind); - const actualStatus = normalizeBoardGithubStatusCell(rowFile.cells[githubStatusColumnIndex]); - if (actualStatus !== expectedStatus) { - return validationError(commandName, repo, "row-file GitHub 状态 column does not match the target section", { - issueNumber, - section: sectionKind, - expectedStatus, - actualStatus, - statusColumnIndex: githubStatusColumnIndex, - statusCell: rowFile.cells[githubStatusColumnIndex] ?? "", - rowPreview: preview(rowFile.raw), - }) as GitHubCommandFailure; - } - const oldBody = boardIssue.body ?? ""; - const insertAfterLine = boardSectionInsertAfterLine(target); - let newBody: string; - try { - newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, rowFile.raw); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { - issueNumber, - section: sectionKind, - insertAfterLine, - }) as GitHubCommandFailure; - } - return { - ok: true, - newBody, - plan: { - repo, - boardIssue: boardIssue.number, - issueNumber, - action: "add", - section: sectionKind, - insertAfterLine, - rowFile: rowFile.path, - newRow: rowFile.raw, - newCells: rowFile.cells, - row: { - old: null, - new: boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }), - }, - linePlan: { - action: "insert", - section: sectionKind, - insertAfterLine, - newLineNumber: insertAfterLine + 1, - }, - sectionPlan: { - kind: sectionKind, - heading: target.heading, - headerLine: target.headerLine, - rowCount: target.rows.length, - }, - validation: { - issueColumnIndex, - githubStatusColumnIndex, - expectedStatus, - actualStatus, - columnCount: rowFile.cells.length, - }, - body: { - oldBodyChars: oldBody.length, - oldBodySha: bodySha(oldBody), - newBodyChars: newBody.length, - newBodySha: bodySha(newBody), - changed: oldBody !== newBody, - }, - tableParser: { - reused: "parseBoardTables", - rowValidationWarnings: parsed.warnings.length, - }, - request: { - method: "PATCH", - path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, - body: { bodyChars: newBody.length }, - }, - }, - }; -} - -function moveBoardBodyRow(body: string, sourceLineNumber: number, insertAfterLine: number, newLine: string): string { - const normalized = normalizeNewlines(body); - const lines = normalized.split("\n"); - if (sourceLineNumber <= 0 || sourceLineNumber > lines.length) throw new Error(`line ${sourceLineNumber} is outside the board body`); - if (insertAfterLine < 0 || insertAfterLine > lines.length) throw new Error(`insert line ${insertAfterLine} is outside the board body`); - lines.splice(sourceLineNumber - 1, 1); - const adjustedInsertAfterLine = sourceLineNumber <= insertAfterLine ? insertAfterLine - 1 : insertAfterLine; - lines.splice(adjustedInsertAfterLine, 0, newLine); - return lines.join("\n"); -} - -function boardRowMovePlan( - repo: string, - boardIssue: GitHubIssue, - issueNumber: number, - targetKind: BoardMutationSection, - status: BoardGithubStatus | undefined, - parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, -): BoardRowMutationPlanResult | GitHubCommandFailure { - const commandName = "issue board-row move"; - const statusFailure = boardStatusOptionFailure(repo, commandName, targetKind, status); - if (statusFailure !== null) return statusFailure; - const found = findBoardRow(repo, parsed.sections, issueNumber); - if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure; - const sourceConsistency = boardRowStatusConsistency(repo, commandName, found.section, found.row, "source"); - if (sourceConsistency.ok === false) return sourceConsistency; - const targetLookup = targetBoardSection(repo, parsed.sections, targetKind, commandName); - if (targetLookup.ok === false) return targetLookup; - const target = targetLookup.section; - const { section: source, row } = found; - if (!sameBoardHeaderShape(source.headers, target.headers)) { - return validationError(commandName, repo, "source and target board tables have different headers; refusing to move a raw row across mismatched columns", { - issueNumber, - source: { section: source.kind, headers: source.headers, headerLine: source.headerLine }, - target: { section: target.kind, headers: target.headers, headerLine: target.headerLine }, - }) as GitHubCommandFailure; - } - if (row.cells.length !== target.headers.length) { - return validationError(commandName, repo, "source row column count does not match the target board table header", { - issueNumber, - source: { section: source.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw), cells: row.cells.length }, - target: { section: target.kind, headers: target.headers, columns: target.headers.length }, - }) as GitHubCommandFailure; - } - const columnMap = boardColumnIndexMap(target.headers); - const githubStatusColumnIndex = columnMap.get("githubStatus"); - if (githubStatusColumnIndex === undefined) { - return validationError(commandName, repo, "target board table does not contain a GitHub 状态 column required for section migration", { - issueNumber, - targetSection: targetKind, - headers: target.headers, - }) as GitHubCommandFailure; - } - const oldCells = row.cells.slice(); - const newCells = oldCells.slice(); - const expectedStatus = expectedBoardGithubStatus(targetKind); - if (githubStatusColumnIndex !== undefined) newCells[githubStatusColumnIndex] = status ?? expectedStatus; - const newRow = renderMarkdownTableRow(newCells); - const oldBody = boardIssue.body ?? ""; - const insertAfterLine = boardSectionInsertAfterLine(target); - let newBody: string; - try { - newBody = source.kind === target.kind - ? replaceBoardBodyLine(oldBody, row.lineNumber, newRow) - : moveBoardBodyRow(oldBody, row.lineNumber, insertAfterLine, newRow); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { - issueNumber, - sourceLineNumber: row.lineNumber, - insertAfterLine, - }) as GitHubCommandFailure; - } - return { - ok: true, - newBody, - plan: { - repo, - boardIssue: boardIssue.number, - issueNumber, - action: source.kind === target.kind ? "move-within-section" : "move", - from: source.kind, - to: target.kind, - sourceLineNumber: row.lineNumber, - insertAfterLine, - oldRow: row.raw, - newRow, - oldCells, - newCells, - row: { - old: boardRowSummary(source, row), - new: boardRowSummary(target, { ...row, section: target.kind, raw: newRow, cells: newCells, lineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1 }), - }, - linePlan: { - action: source.kind === target.kind ? "replace" : "move", - sourceLineNumber: row.lineNumber, - insertAfterLine, - newLineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1, - }, - sectionPlan: { - from: source.kind, - to: target.kind, - sourceHeading: source.heading, - targetHeading: target.heading, - }, - status: { - requested: status ?? null, - targetDefault: expectedStatus, - githubStatusColumnIndex: githubStatusColumnIndex ?? null, - old: oldCells[githubStatusColumnIndex] ?? "", - new: newCells[githubStatusColumnIndex] ?? "", - }, - body: { - oldBodyChars: oldBody.length, - oldBodySha: bodySha(oldBody), - newBodyChars: newBody.length, - newBodySha: bodySha(newBody), - changed: oldBody !== newBody, - }, - tableParser: { - reused: "parseBoardTables", - rowValidationWarnings: parsed.warnings.length, - }, - request: { - method: "PATCH", - path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, - body: { bodyChars: newBody.length }, - }, - }, - }; -} - -function boardRowDeletePlan( - repo: string, - boardIssue: GitHubIssue, - issueNumber: number, - parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, -): BoardRowMutationPlanResult | GitHubCommandFailure { - const commandName = "issue board-row delete"; - const found = findBoardRow(repo, parsed.sections, issueNumber); - if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure; - const { section, row } = found; - const sourceConsistency = boardRowStatusConsistency(repo, commandName, section, row, "source"); - if (sourceConsistency.ok === false) return sourceConsistency; - const oldBody = boardIssue.body ?? ""; - let newBody: string; - try { - newBody = removeBoardBodyLine(oldBody, row.lineNumber); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { - issueNumber, - lineNumber: row.lineNumber, - }) as GitHubCommandFailure; - } - return { - ok: true, - newBody, - plan: { - repo, - boardIssue: boardIssue.number, - issueNumber, - action: "delete", - section: section.kind, - lineNumber: row.lineNumber, - oldRow: row.raw, - oldCells: row.cells, - row: { - old: boardRowSummary(section, row), - new: null, - }, - linePlan: { - action: "remove", - section: section.kind, - lineNumber: row.lineNumber, - }, - sectionPlan: { - kind: section.kind, - heading: section.heading, - headerLine: section.headerLine, - rowCount: section.rows.length, - }, - body: { - oldBodyChars: oldBody.length, - oldBodySha: bodySha(oldBody), - newBodyChars: newBody.length, - newBodySha: bodySha(newBody), - changed: oldBody !== newBody, - }, - tableParser: { - reused: "parseBoardTables", - rowValidationWarnings: parsed.warnings.length, - }, - request: { - method: "PATCH", - path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, - body: { bodyChars: newBody.length }, - }, - }, - }; -} - -async function issueBoardRowMutation( - repo: string, - token: string, - issueNumber: number, - options: GitHubOptions, - commandName: "issue board-row add" | "issue board-row move" | "issue board-row delete" | "issue board-row upsert", - mutationKey: "add" | "move" | "delete" | "upsert", - buildPlan: (boardIssue: GitHubIssue, parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }) => BoardRowMutationPlanResult | GitHubCommandFailure, -): Promise { - const concurrencyOptionError = assertConcurrencyOptions(options, commandName); - if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo }; - const boardIssue = await getIssue(token, repo, options.boardIssue); - if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); - const parsed = parseBoardTables(boardIssue.body ?? ""); - const planned = buildPlan(boardIssue, parsed); - if (planned.ok === false) return planned; - const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options); - const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options); - const hasConcurrencyGuard = options.expectBodySha !== undefined || options.expectUpdatedAt !== undefined; - const effectiveDryRun = options.dryRun || !hasConcurrencyGuard; - const base = { - ok: true, - command: commandName, - repo, - boardIssue: { - number: boardIssue.number, - title: boardIssue.title, - state: boardIssue.state, - url: boardIssue.html_url, - bodyChars: (boardIssue.body ?? "").length, - bodySha: bodySha(boardIssue.body ?? ""), - updatedAt: boardIssue.updated_at ?? null, - }, - codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody), - issueNumber, - operation: typeof planned.plan.operation === "string" ? planned.plan.operation : mutationKey, - dryRun: effectiveDryRun, - planned: true, - [mutationKey]: planned.plan, - guard, - bodyOnlySafety: { - oldBody: { - fetched: true, - bodyChars: (boardIssue.body ?? "").length, - bodySha: bodySha(boardIssue.body ?? ""), - updatedAt: boardIssue.updated_at ?? null, - }, - newBody: { - bodyChars: planned.newBody.length, - bodySha: bodySha(planned.newBody), - ...bodySafetySignals(planned.newBody), - }, - }, - concurrency: { - expectUpdatedAt: options.expectUpdatedAt ?? null, - expectBodySha: options.expectBodySha ?? null, - note: `non-dry-run board-row ${mutationKey} requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH`, - }, - }; - if (guardError !== null) return { ...guardError, command: commandName, [mutationKey]: planned.plan, guard }; - if (effectiveDryRun) { - return { - ...base, - dryRun: true, - disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true), - readCommands: issueBodyReadCommands(repo, options.boardIssue), - wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length }, - note: !hasConcurrencyGuard - ? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified." - : "Dry-run only; no GitHub issue body was modified.", - }; - } - const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options); - if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName }; - const { owner, name } = repoParts(repo); - const issue = await githubRequest(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody }); - if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, [mutationKey]: planned.plan }); - return { - ...base, - dryRun: false, - planned: false, - issue: issueSummary(issue, { includeBody: options.raw || options.full }), - disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false), - readCommands: issueBodyReadCommands(repo, options.boardIssue), - concurrency: { - checked: true, - oldIssueUpdatedAt: boardIssue.updated_at ?? null, - oldBodySha: bodySha(boardIssue.body ?? ""), - expectUpdatedAt: options.expectUpdatedAt ?? null, - expectBodySha: options.expectBodySha ?? null, - }, - rest: true, - }; -} - -async function issueBoardRowList(repo: string, token: string, options: GitHubOptions): Promise { - const commandName = "issue board-row list"; - const boardIssue = await getIssue(token, repo, options.boardIssue); - if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue }); - const parsed = parseBoardTables(boardIssue.body ?? ""); - const rows = boardRowsForState(parsed.sections, options.listState); - return { - ok: true, - command: commandName, - repo, - dryRun: true, - readOnly: true, - codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""), - boardIssue: { - number: boardIssue.number, - title: boardIssue.title, - state: boardIssue.state, - url: boardIssue.html_url, - bodyChars: (boardIssue.body ?? "").length, - bodySha: bodySha(boardIssue.body ?? ""), - updatedAt: boardIssue.updated_at ?? null, - }, - state: options.listState, - count: rows.length, - rows: rows.map(({ section, row }) => boardRowSummary(section, row)), - sections: parsed.sections.map((section) => ({ kind: section.kind, heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine, headers: section.headers, rows: section.rows.length })), - rowValidationWarnings: parsed.warnings, - note: "Read-only board row list; no issue body was edited.", - }; -} - -async function issueBoardRowGet(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { - const commandName = "issue board-row get"; - const boardIssue = await getIssue(token, repo, options.boardIssue); - if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); - const parsed = parseBoardTables(boardIssue.body ?? ""); - const found = findBoardRow(repo, parsed.sections, issueNumber); - if (found.ok === false) return { ...found, command: commandName, repo, boardIssue: options.boardIssue }; - return { - ok: true, - command: commandName, - repo, - dryRun: true, - readOnly: true, - codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""), - boardIssue: { - number: boardIssue.number, - title: boardIssue.title, - state: boardIssue.state, - url: boardIssue.html_url, - bodyChars: (boardIssue.body ?? "").length, - bodySha: bodySha(boardIssue.body ?? ""), - updatedAt: boardIssue.updated_at ?? null, - }, - issueNumber, - row: boardRowSummary(found.section, found.row), - rowValidationWarnings: parsed.warnings.filter((warning) => warning.issueNumber === issueNumber), - note: "Read-only board row get; no issue body was edited.", - }; -} - -async function issueBoardRowUpdate(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { - const commandName = "issue board-row update"; - if (options.boardRowField === undefined) return validationError(commandName, repo, "issue board-row update requires --field progress|status|validation|branch|tasks|focus", { supportedFields: BOARD_ROW_FIELDS.slice() }); - if (options.boardRowValue === undefined) return validationError(commandName, repo, "issue board-row update requires --value ", { issueNumber, field: options.boardRowField }); - const valuePollutionEvidence = boardRowValuePollutionEvidence(options.boardRowValue); - if (valuePollutionEvidence.length > 0) { - return validationError(commandName, repo, "issue board-row update --value contains literal shell escape text that would pollute the board cell; pass real newlines or plain text instead", { - issueNumber, - field: options.boardRowField, - valuePreview: preview(options.boardRowValue), - shellPollution: { suspected: true, evidence: valuePollutionEvidence }, - }); - } - const concurrencyOptionError = assertConcurrencyOptions(options, commandName); - if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo }; - - const boardIssue = await getIssue(token, repo, options.boardIssue); - if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); - const parsed = parseBoardTables(boardIssue.body ?? ""); - const planned = boardRowUpdatePlan(repo, boardIssue, issueNumber, options.boardRowField, options.boardRowValue, parsed); - if (planned.ok === false) return planned; - const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options); - const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options); - const effectiveDryRun = options.dryRun || (options.expectBodySha === undefined && options.expectUpdatedAt === undefined); - const base = { - ok: true, - command: commandName, - repo, - boardIssue: { - number: boardIssue.number, - title: boardIssue.title, - state: boardIssue.state, - url: boardIssue.html_url, - bodyChars: (boardIssue.body ?? "").length, - bodySha: bodySha(boardIssue.body ?? ""), - updatedAt: boardIssue.updated_at ?? null, - }, - codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody), - issueNumber, - dryRun: effectiveDryRun, - planned: true, - update: planned.plan, - guard, - bodyOnlySafety: { - oldBody: { - fetched: true, - bodyChars: (boardIssue.body ?? "").length, - bodySha: bodySha(boardIssue.body ?? ""), - updatedAt: boardIssue.updated_at ?? null, - }, - newBody: { - bodyChars: planned.newBody.length, - bodySha: bodySha(planned.newBody), - ...bodySafetySignals(planned.newBody), - }, - }, - concurrency: { - expectUpdatedAt: options.expectUpdatedAt ?? null, - expectBodySha: options.expectBodySha ?? null, - note: "non-dry-run board-row update requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH", - }, - }; - if (guardError !== null) return { ...guardError, command: commandName, update: planned.plan, guard }; - if (effectiveDryRun) { - return { - ...base, - dryRun: true, - disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true), - readCommands: issueBodyReadCommands(repo, options.boardIssue), - wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length }, - note: options.expectBodySha === undefined && options.expectUpdatedAt === undefined - ? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified." - : "Dry-run only; no GitHub issue body was modified.", - }; - } - const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options); - if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName }; - const { owner, name } = repoParts(repo); - const issue = await githubRequest(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody }); - if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, update: planned.plan }); - return { - ...base, - dryRun: false, - planned: false, - issue: issueSummary(issue, { includeBody: options.raw || options.full }), - disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false), - readCommands: issueBodyReadCommands(repo, options.boardIssue), - concurrency: { - checked: true, - oldIssueUpdatedAt: boardIssue.updated_at ?? null, - oldBodySha: bodySha(boardIssue.body ?? ""), - expectUpdatedAt: options.expectUpdatedAt ?? null, - expectBodySha: options.expectBodySha ?? null, - }, - rest: true, - }; -} - -async function issueBoardRowAdd(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { - const commandName = "issue board-row add"; - if (options.boardSection === undefined) return validationError(commandName, repo, "issue board-row add requires --section open|closed", { issueNumber, supportedSections: BOARD_MUTATION_SECTIONS.slice() }); - let rowFile: { path: string; raw: string; cells: string[] }; - try { - rowFile = readBoardRowFile(options.boardRowFile, commandName); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { - issueNumber, - rowFile: options.boardRowFile ?? null, - }); - } - return issueBoardRowMutation( - repo, - token, - issueNumber, - options, - commandName, - "add", - (boardIssue, parsed) => boardRowAddPlan(repo, boardIssue, issueNumber, options.boardSection as BoardMutationSection, rowFile, parsed), - ); -} - -async function issueBoardRowUpsert(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { - const commandName = "issue board-row upsert"; - return issueBoardRowMutation( - repo, - token, - issueNumber, - options, - commandName, - "upsert", - (boardIssue, parsed) => boardRowUpsertPlan(repo, boardIssue, issueNumber, options, parsed), - ); -} - -async function issueBoardRowMove(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { - const commandName = "issue board-row move"; - if (options.boardMoveTo === undefined) return validationError(commandName, repo, "issue board-row move requires --to open|closed", { issueNumber, supportedTargets: BOARD_MUTATION_SECTIONS.slice() }); - return issueBoardRowMutation( - repo, - token, - issueNumber, - options, - commandName, - "move", - (boardIssue, parsed) => boardRowMovePlan(repo, boardIssue, issueNumber, options.boardMoveTo as BoardMutationSection, options.boardGithubStatus, parsed), - ); -} - -async function issueBoardRowDelete(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { - const commandName = "issue board-row delete"; - return issueBoardRowMutation( - repo, - token, - issueNumber, - options, - commandName, - "delete", - (boardIssue, parsed) => boardRowDeletePlan(repo, boardIssue, issueNumber, parsed), - ); -} - -function ignoredIssueList(issues: BoardIssueEntry[], ignoreMap: Map): BoardIgnoredIssue[] { - return sortedIssueEntries(issues.filter((issue) => ignoreMap.has(issue.number))).map((issue) => ({ - number: issue.number, - reason: ignoreMap.get(issue.number) ?? "ignored", - title: issue.title, - state: issue.state, - url: issue.url, - })); -} - -function ignoredBoardOnlyIssues(issueNumbers: number[], issueMap: Map, ignoreMap: Map): BoardIgnoredIssue[] { - return issueNumbers - .filter((issueNumber) => ignoreMap.has(issueNumber) && !issueMap.has(issueNumber)) - .sort((a, b) => a - b) - .map((issueNumber) => ({ number: issueNumber, reason: ignoreMap.get(issueNumber) ?? "ignored" })); -} - -function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null { - if (requested !== "auto") return requested; - if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board"; - if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief"; - return null; -} - -function firstMarkdownLine(body: string | null | undefined): string { - return normalizeNewlines(body ?? "").split("\n").find((line) => line.trim().length > 0)?.trim() ?? ""; -} - -function isDailyCommanderBriefTitle(title: string | null | undefined): boolean { - return DAILY_COMMANDER_BRIEF_TITLE_PATTERN.test((title ?? "").trim()); -} - -function isDailyCommanderBriefBodyHeading(line: string): boolean { - return DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim()); -} - -function isLegacyCommanderBriefBodyHeading(line: string): boolean { - return LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim()); -} - -function currentBodyLooksLikeCommanderBrief(body: string | null | undefined): boolean { - const normalized = normalizeNewlines(body ?? ""); - const firstLine = firstMarkdownLine(normalized); - if (isDailyCommanderBriefBodyHeading(firstLine)) return true; - return isLegacyCommanderBriefBodyHeading(firstLine) - && normalized.includes("## 常驻观察与长期建议") - && extractTimelineSections(normalized).length > 0; -} - -function commanderBriefIssueProfileMatch(issueNumber: number, context: IssueProfileValidationContext): { ok: boolean; reason: string } { - if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return { ok: true, reason: "legacy-issue-number" }; - if (isDailyCommanderBriefTitle(context.issueTitle)) return { ok: true, reason: "daily-title" }; - if (currentBodyLooksLikeCommanderBrief(context.issueBody)) return { ok: true, reason: "current-body-heading" }; - return { - ok: false, - reason: context.issueMetadataFetched === true ? "not-commander-brief" : "issue-metadata-unavailable", - }; -} - -function issueProfileNeedsMetadata(issueNumber: number, requested: IssueBodyProfileOption): boolean { - return requested === "commander-brief" && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE; -} - -function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption, context: IssueProfileValidationContext = {}): Record { - const profileName = issueProfileFor(issueNumber, requested); - if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true }; - const profile = ISSUE_BODY_PROFILES[profileName]; - const match = profileName === "commander-brief" - ? commanderBriefIssueProfileMatch(issueNumber, context) - : { ok: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber, reason: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber ? "issue-number" : "wrong-issue-number" }; - const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading)); - return { - profile: profileName, - label: profile.label, - applied: true, - ...(profileName === "code-queue-board" - ? { expectedIssueNumber: ISSUE_BODY_PROFILES["code-queue-board"].issueNumber } - : { legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE }), - issueMatchesProfile: match.ok, - issueMatchReason: match.reason, - issueMetadataFetched: context.issueMetadataFetched === true, - issueTitle: context.issueTitle ?? null, - requiredHeadings: profile.requiredHeadings, - missingHeadings, - ok: match.ok && missingHeadings.length === 0, - }; -} - -function validationBoolean(record: Record, key: string): boolean { - return record[key] === true; -} - -function validationStringArray(record: Record, key: string): string[] { - const value = record[key]; - return Array.isArray(value) ? value.map((item) => String(item)) : []; -} - -function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}, commandName = "issue edit", bodySource: Record = { kind: "body-file", path: options.bodyFile ?? null }): GitHubCommandResult | null { - const trimmed = body.trim(); - const isLiteralNull = trimmed.toLowerCase() === "null"; - const isBlank = trimmed.length === 0; - const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS; - const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext); - const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body); - const boardContainsCommanderBriefUpdates = codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint); - const profileOk = validationBoolean(profileValidation, "ok"); - const failures: string[] = []; - if (isLiteralNull) failures.push("literal-null-body"); - if (isBlank) failures.push("blank-body"); - if (isShort && !options.allowShortBody) failures.push("short-body"); - if (boardContainsCommanderBriefUpdates) failures.push("code-queue-board-contains-commander-brief-updates"); - if (!profileOk) { - if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch"); - const missingHeadings = validationStringArray(profileValidation, "missingHeadings"); - if (missingHeadings.length > 0) failures.push("profile-heading-missing"); - } - if (failures.length === 0) return null; - const overrideAvailable = failures.every((failure) => failure === "short-body"); - const message = overrideAvailable - ? `${commandName} body is shorter than the safe default; pass --allow-short-body only for intentional short writes` - : `${commandName} body failed safety guard; refusing to write possible body-only issue corruption`; - return validationError(commandName, repo, message, { - issueNumber, - guard: { - ok: false, - failures, - minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS, - allowShortBody: options.allowShortBody, - shortBodyOverrideAvailable: overrideAvailable, - bodySource, - bodyPreview: preview(body), - bodyPreviewLines: previewLines(body), - codeQueueBoardHint: boardBriefHint, - ...bodySafetySignals(body), - isLiteralNull, - isBlank, - isShort, - profile: profileValidation, - }, - }); -} - -function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): Record { - const trimmed = body.trim(); - const profile = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext); - const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body); - const warnings: string[] = []; - if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) { - warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail"); - } - if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk"); - if (profile.ok === false) warnings.push("profile guard would fail"); - if (codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint)) warnings.push("code-queue-board-contains-commander-brief-updates"); - if (codeQueueBoardHintHasHwlabProductRouting(boardBriefHint)) warnings.push("code-queue-board-contains-hwlab-product-work"); - const signals = bodySafetySignals(body); - const shellPollution = signals.shellPollution as Record; - if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected"); - return { - ok: trimmed.length > 0 - && trimmed.toLowerCase() !== "null" - && (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody) - && profile.ok === true - && !codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint), - minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS, - allowShortBody: options.allowShortBody, - warnings, - profile, - codeQueueBoardHint: boardBriefHint, - ...signals, - }; -} - -function assertConcurrencyOptions(options: GitHubOptions, commandName: string): GitHubCommandResult | null { - if (options.expectBodySha === undefined) return null; - try { - normalizeExpectedSha(options.expectBodySha); - return null; - } catch (error) { - return validationError(commandName, options.repo, error instanceof Error ? error.message : String(error), { - expectBodySha: options.expectBodySha, - }); - } -} - -function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions, commandName = "issue edit"): GitHubCommandResult | null { - const checks: Record = { - issueNumber, - expectUpdatedAt: options.expectUpdatedAt ?? null, - actualUpdatedAt: oldIssue.updated_at ?? null, - expectBodySha: options.expectBodySha ?? null, - actualBodySha: bodySha(oldIssue.body ?? ""), - }; - if (options.expectUpdatedAt !== undefined && oldIssue.updated_at !== options.expectUpdatedAt) { - return validationError(commandName, repo, "--expect-updated-at did not match current GitHub issue updated_at; refusing stale body overwrite", checks); - } - if (options.expectBodySha !== undefined) { - const expected = normalizeExpectedSha(options.expectBodySha); - if (bodySha(oldIssue.body ?? "") !== expected) { - return validationError(commandName, repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks); - } - } - return null; -} - -function validateCommentConcurrency(repo: string, commentId: number, oldComment: GitHubComment, options: GitHubOptions, commandName: string): GitHubCommandResult | null { - const checks: Record = { - commentId, - expectUpdatedAt: options.expectUpdatedAt ?? null, - actualUpdatedAt: oldComment.updated_at ?? null, - expectBodySha: options.expectBodySha ?? null, - actualBodySha: bodySha(oldComment.body ?? ""), - }; - if (options.expectUpdatedAt !== undefined && oldComment.updated_at !== options.expectUpdatedAt) { - return validationError(commandName, repo, "--expect-updated-at did not match current GitHub comment updated_at; refusing stale body overwrite", checks); - } - if (options.expectBodySha !== undefined) { - const expected = normalizeExpectedSha(options.expectBodySha); - if (bodySha(oldComment.body ?? "") !== expected) { - return validationError(commandName, repo, "--expect-body-sha did not match current GitHub comment body; refusing stale body overwrite", checks); - } - } - return null; -} - -function commentSummary(comment: GitHubComment): Record { - return { - id: comment.id, - body: comment.body ?? "", - url: comment.html_url, - author: comment.user?.login ?? null, - createdAt: comment.created_at ?? null, - updatedAt: comment.updated_at ?? null, - }; -} - -function compactCommentSummary(comment: GitHubComment): Record { - const body = comment.body ?? ""; - return { - id: comment.id, - url: comment.html_url, - author: comment.user?.login ?? null, - createdAt: comment.created_at ?? null, - updatedAt: comment.updated_at ?? null, - bodyChars: body.length, - bodySha: bodySha(body), - bodyPreview: preview(body), - bodyPreviewLines: previewLines(body, 4), - bodyOmitted: true, - fullBodyIncluded: false, - }; -} - -function compactIssueViewCommentSummary(comment: GitHubComment): Record { - const body = comment.body ?? ""; - return { - id: comment.id, - url: comment.html_url, - author: comment.user?.login ?? null, - createdAt: comment.created_at ?? null, - updatedAt: comment.updated_at ?? null, - bodyChars: body.length, - bodySha: bodySha(body), - bodyPreview: ghShort(preview(body).replace(/\s+/gu, " "), 200), - bodyOmitted: true, - fullBodyIncluded: false, - }; -} - -function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" { - if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged"; - return pr.state === "closed" ? "closed" : "open"; -} - -function prSummary(pr: GitHubPullRequest, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record { - const stateDetail = prStateDetail(pr); - const closedAt = pr.closed_at ?? pr.merged_at ?? null; - const mergedAt = pr.merged_at ?? null; - const mergeCommitSha = pr.merge_commit_sha ?? null; - const body = pr.body ?? ""; - const includeBody = options.includeBody ?? true; - const includePreview = options.includePreview ?? true; - const summary: Record = { - id: pr.id, - number: pr.number, - title: pr.title, - state: pr.state, - stateDetail, - draft: pr.draft ?? false, - url: pr.html_url, - author: pr.user?.login ?? null, - head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null }, - base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null }, - headRefName: pr.head?.ref ?? null, - baseRefName: pr.base?.ref ?? null, - createdAt: pr.created_at ?? null, - updatedAt: pr.updated_at ?? null, - closed: stateDetail !== "open", - closedAt, - merged: stateDetail === "merged", - mergedAt, - mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null, - bodyChars: body.length, - bodySha: bodySha(body), - }; - if (includeBody) { - summary.body = body; - } else { - summary.bodyOmitted = true; - summary.fullBodyIncluded = false; - if (includePreview) { - summary.bodyPreview = preview(body); - summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8); - } - } - return summary; -} - -function numberOrNull(value: number | undefined): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -function prCompactSummary(pr: GitHubPullRequest): Record { - return { - id: pr.id, - number: pr.number, - title: pr.title, - state: pr.state, - draft: pr.draft ?? false, - url: pr.html_url, - author: pr.user?.login ?? null, - head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null }, - base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null }, - createdAt: pr.created_at ?? null, - updatedAt: pr.updated_at ?? null, - }; -} - -function prFileSummary(file: GitHubPullRequestFile): Record { - return { - filename: file.filename, - status: file.status ?? null, - additions: numberOrNull(file.additions), - deletions: numberOrNull(file.deletions), - changes: numberOrNull(file.changes), - previousFilename: file.previous_filename ?? null, - sha: file.sha ?? null, - blobUrl: file.blob_url ?? null, - contentsUrl: file.contents_url ?? null, - }; -} - -function sumPrFileStats(files: GitHubPullRequestFile[]): { additions: number; deletions: number; changes: number } { - return files.reduce((accumulator, file) => { - accumulator.additions += file.additions ?? 0; - accumulator.deletions += file.deletions ?? 0; - accumulator.changes += file.changes ?? ((file.additions ?? 0) + (file.deletions ?? 0)); - return accumulator; - }, { additions: 0, deletions: 0, changes: 0 }); -} - -function selectedPrJson(summary: Record, fields: readonly string[]): Record { - const selected: Record = {}; - for (const field of fields) selected[field] = summary[field]; - return selected; -} - -function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record { - return { - mergeable: metadata.mergeable ?? null, - mergeStateStatus: metadata.mergeStateStatus ?? null, - statusCheckRollup: metadata.statusCheckRollup ?? null, - headRefName: metadata.headRefName ?? null, - baseRefName: metadata.baseRefName ?? null, - }; -} - -function prMergeBoundary(): Record { - return { - runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization"], - ordinaryRunnerFinalActionAllowed: true, - commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"], - hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"], - unideskCliMergeSupported: true, - }; -} - -function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record { - const summary = prMetadataSummary(metadata); - const missingOrUnknownFields = PR_CLOSEOUT_JSON_FIELDS.filter((field) => { - const value = summary[field]; - if (value === null || value === undefined) return true; - return typeof value === "string" && value.toUpperCase() === "UNKNOWN"; - }); - return { - ok: missingOrUnknownFields.length === 0, - source: "github-graphql", - missingOrUnknownFields, - advice: missingOrUnknownFields.length === 0 - ? "Closeout GraphQL metadata is present; still review checks, branch scope, and task boundary before final action." - : "Some closeout GraphQL metadata is missing or unknown; retry or cross-check with system gh/GitHub UI before treating the PR as merge-ready.", - mergeBoundary: prMergeBoundary(), - }; -} - -function prCloseoutMetadataError(error: GitHubErrorPayload): Record { - return { - ok: false, - source: "github-graphql", - degradedReason: error.degradedReason, - runnerDisposition: error.runnerDisposition, - message: error.message, - advice: "Closeout GraphQL metadata was not available; retry or use a read-only system gh/GitHub UI cross-check before deciding merge readiness.", - mergeBoundary: prMergeBoundary(), - }; -} - -function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean { - if (fields === undefined) return false; - return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup"); -} - -async function prGraphqlMetadata(repo: string, token: string, number: number): Promise { - const { owner, name } = repoParts(repo); - const query = ` - query PullRequestCloseoutMetadata($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - mergeable - mergeStateStatus - headRefName - baseRefName - statusCheckRollup { - state - contexts(first: 100) { - nodes { - __typename - ... on CheckRun { - name - status - conclusion - } - ... on StatusContext { - context - state - targetUrl - description - } - } - } - } - } - } - } - `; - const response = await githubGraphqlRequest<{ repository?: { pullRequest?: GitHubPullRequestGraphqlMetadata | null } }>(token, query, { owner, name, number }); - if (isGitHubError(response)) return response; - const pullRequest = response.repository?.pullRequest; - if (pullRequest === null || pullRequest === undefined) { - return errorPayload("invalid-response", "GitHub GraphQL PR metadata response was missing repository.pullRequest", { details: response }); - } - return pullRequest; -} - -function statusContextName(context: GitHubPullRequestGraphqlStatusContext): string { - return context.name ?? context.context ?? "(unnamed status check)"; -} - -function statusContextBucket(context: GitHubPullRequestGraphqlStatusContext): string { - const type = context.__typename ?? "StatusContext"; - const status = (context.status ?? "").toUpperCase(); - const conclusion = (context.conclusion ?? "").toUpperCase(); - const state = (context.state ?? "").toUpperCase(); - if (type === "CheckRun") { - if (status.length > 0 && status !== "COMPLETED") return "pending"; - if (conclusion === "SUCCESS") return "success"; - if (conclusion === "NEUTRAL") return "neutral"; - if (conclusion === "SKIPPED") return "skipped"; - if (conclusion === "CANCELLED") return "cancelled"; - if (conclusion === "TIMED_OUT") return "timed-out"; - if (["FAILURE", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(conclusion)) return "failure"; - return "unknown"; - } - if (state === "SUCCESS") return "success"; - if (state === "PENDING" || state === "EXPECTED") return "pending"; - if (state === "FAILURE" || state === "ERROR") return "failure"; - return "unknown"; -} - -function compactStatusContext(context: GitHubPullRequestGraphqlStatusContext): Record { - const bucket = statusContextBucket(context); - return { - name: statusContextName(context), - type: context.__typename ?? "StatusContext", - bucket, - status: context.status ?? null, - conclusion: context.conclusion ?? null, - state: context.state ?? null, - targetUrl: context.targetUrl ?? null, - description: context.description === undefined || context.description === null ? null : preview(context.description), - }; -} - -function statusRollupSummary( - repo: string, - number: number, - rollup: GitHubPullRequestGraphqlStatusCheckRollup | null | undefined, - includeRaw: boolean, -): Record { - const contexts = rollup?.contexts?.nodes ?? []; - const compactContexts = contexts.map(compactStatusContext); - const counts: Record = { - success: 0, - failure: 0, - pending: 0, - neutral: 0, - skipped: 0, - cancelled: 0, - "timed-out": 0, - unknown: 0, - }; - for (const context of compactContexts) { - const bucket = String(context.bucket ?? "unknown"); - counts[bucket] = (counts[bucket] ?? 0) + 1; - } - const nonSuccess = compactContexts.filter((context) => context.bucket !== "success"); - const failing = compactContexts.filter((context) => context.bucket === "failure" || context.bucket === "cancelled" || context.bucket === "timed-out"); - const pending = compactContexts.filter((context) => context.bucket === "pending" || context.bucket === "unknown"); - const neutral = compactContexts.filter((context) => context.bucket === "neutral" || context.bucket === "skipped"); - return { - state: rollup?.state ?? null, - totalContexts: compactContexts.length, - counts, - failingContexts: failing.slice(0, 10), - pendingContexts: pending.slice(0, 10), - neutralContexts: neutral.slice(0, 10), - nonSuccessPreview: nonSuccess.slice(0, 12), - omittedNonSuccessContexts: Math.max(0, nonSuccess.length - 12), - rawOmitted: !includeRaw, - ...(includeRaw - ? { contexts: compactContexts, raw: rollup ?? null } - : { fullHint: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo} --full` }), - }; -} - -function compactProbeOk(value: unknown): boolean | null { - if (value === "ok") return true; - if (value === "skipped") return null; - if (isRecord(value) && value.ok === true) return true; - if (isRecord(value) && value.ok === false) return false; - return null; -} - -function compactAuthCapability(auth: GitHubCommandResult): Record { - const token = isRecord(auth.token) ? auth.token : {}; - const gh = isRecord(auth.gh) ? auth.gh : {}; - const probes = isRecord(auth.probes) ? auth.probes : {}; - return { - ok: auth.ok === true, - degradedReason: auth.ok === false ? auth.degradedReason ?? null : null, - runnerDisposition: auth.ok === false ? auth.runnerDisposition ?? null : null, - tokenPresent: token.present === true, - tokenSource: typeof token.source === "string" ? token.source : null, - ghFallbackAttempted: token.ghFallbackAttempted === true, - ghBinaryFound: gh.binaryFound === true, - restApi: compactProbeOk(probes.restApi), - repoVisible: compactProbeOk(probes.repo), - issueRead: compactProbeOk(probes.issueRead), - valuesPrinted: false, - }; -} - -function prPreflightPolicy(repo: string, number: number): Record { - return { - readOnly: true, - writesRemote: false, - createsPr: false, - comments: false, - mergesPr: false, - mergeCommandSupported: true, - mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`, - unideskCliMergeSupported: true, - ordinaryRunnerFinalActionAllowed: true, - note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks for diagnosis; gh pr merge performs its own guarded readiness check and does not require this preflight step.", - }; -} - -function preflightPullRequestSummary(summary: Record): Record { - return { - number: summary.number ?? null, - title: summary.title ?? null, - state: summary.state ?? null, - stateDetail: summary.stateDetail ?? null, - draft: summary.draft ?? null, - url: summary.url ?? null, - author: summary.author ?? null, - head: summary.head ?? null, - base: summary.base ?? null, - headRefName: summary.headRefName ?? null, - baseRefName: summary.baseRefName ?? null, - closed: summary.closed ?? null, - merged: summary.merged ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: summary.updatedAt ?? null, - bodyOmitted: true, - }; -} - -function prCloseoutSummary( - pr: Record, - metadata: Record, - statusChecks: Record, -): Record { - const blockers: string[] = []; - const pending: string[] = []; - const prState = typeof pr.state === "string" ? pr.state.toLowerCase() : ""; - if (prState !== "open") blockers.push(`state:${pr.state ?? "unknown"}`); - if (pr.draft === true) blockers.push("draft:true"); - const mergeable = typeof metadata.mergeable === "string" ? metadata.mergeable.toUpperCase() : null; - if (mergeable === "MERGEABLE") { - // Ready signal, combined with mergeStateStatus and checks below. - } else if (mergeable === "CONFLICTING" || mergeable === "NOT_MERGEABLE") { - blockers.push(`mergeable:${mergeable}`); - } else { - pending.push(`mergeable:${mergeable ?? "unknown"}`); - } - const mergeStateStatus = typeof metadata.mergeStateStatus === "string" ? metadata.mergeStateStatus.toUpperCase() : null; - if (mergeStateStatus === "CLEAN") { - // Ready signal. - } else if (mergeStateStatus === "UNKNOWN" || mergeStateStatus === null) { - pending.push(`mergeStateStatus:${mergeStateStatus ?? "unknown"}`); - } else { - blockers.push(`mergeStateStatus:${mergeStateStatus}`); - } - const rollupState = typeof statusChecks.state === "string" ? statusChecks.state.toUpperCase() : null; - const totalContexts = typeof statusChecks.totalContexts === "number" ? statusChecks.totalContexts : 0; - const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; - const failureCount = Number(counts.failure ?? 0) + Number(counts.cancelled ?? 0) + Number(counts["timed-out"] ?? 0); - const pendingCount = Number(counts.pending ?? 0) + Number(counts.unknown ?? 0); - if (failureCount > 0) blockers.push(`statusChecks:failure:${failureCount}`); - if (pendingCount > 0) pending.push(`statusChecks:pending:${pendingCount}`); - if (rollupState === "SUCCESS" || rollupState === null && totalContexts === 0) { - // Ready signal. - } else if (rollupState === "PENDING" || rollupState === "EXPECTED" || rollupState === null) { - pending.push(`statusCheckRollup:${rollupState ?? "unknown"}`); - } else if (rollupState !== null) { - blockers.push(`statusCheckRollup:${rollupState}`); - } - const readyForCommanderMerge = blockers.length === 0 && pending.length === 0; - return { - readyForCommanderMerge, - conclusion: readyForCommanderMerge ? "ready" : blockers.length > 0 ? "blocked" : "pending", - blockers, - pending, - commanderAction: readyForCommanderMerge - ? "review and merge through bun scripts/cli.ts gh pr merge when task boundaries allow" - : "resolve blockers or rerun after GitHub finishes computing mergeability/status checks", - }; -} - -function stripYamlComment(value: string): string { - const index = value.indexOf("#"); - return (index >= 0 ? value.slice(0, index) : value).trim(); -} - -function yamlScalarByPath(text: string, keyPath: string[]): string | null { - const stack: Array<{ indent: number; key: string }> = []; - for (const line of text.split(/\r?\n/u)) { - if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue; - const match = /^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$/u.exec(line); - if (match === null) continue; - const indent = match[1].length; - while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop(); - const key = match[2]; - const rawValue = stripYamlComment(match[3] ?? ""); - const currentPath = [...stack.map((item) => item.key), key]; - if (currentPath.length === keyPath.length && currentPath.every((item, index) => item === keyPath[index])) { - return rawValue.length === 0 ? null : rawValue; - } - stack.push({ indent, key }); - } - return null; -} - -function yamlNumberByPath(text: string, keyPath: string[], configPath: string): number { - const value = yamlScalarByPath(text, keyPath); - if (value === null) throw new Error(`${configPath} missing numeric config ${keyPath.join(".")}`); - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${configPath} config ${keyPath.join(".")} must be a positive number`); - return parsed; -} - -function loadPrMergeUnknownRetryConfig(): PrMergeUnknownRetryConfig { - const configPath = path.resolve(process.cwd(), UNIDESK_CLI_CONFIG_PATH); - if (!existsSync(configPath)) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} is required for gh pr merge retry policy`); - const text = readFileSync(configPath, "utf8"); - const prefix = ["github", "prMerge", "unknownRetry"]; - const config = { - maxAttempts: Math.trunc(yamlNumberByPath(text, [...prefix, "maxAttempts"], UNIDESK_CLI_CONFIG_PATH)), - initialDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "initialDelayMs"], UNIDESK_CLI_CONFIG_PATH)), - maxDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "maxDelayMs"], UNIDESK_CLI_CONFIG_PATH)), - factor: yamlNumberByPath(text, [...prefix, "factor"], UNIDESK_CLI_CONFIG_PATH), - }; - if (config.maxAttempts < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxAttempts must be >= 1`); - if (config.maxDelayMs < config.initialDelayMs) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxDelayMs must be >= initialDelayMs`); - if (config.factor < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.factor must be >= 1`); - return config; -} - -function sleepMs(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function mergeabilityHasUnknownPending(mergeability: Record): boolean { - const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; - if (blockers.length > 0) return false; - const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map((item) => item.toLowerCase()) : []; - return pending.some((item) => item.includes("unknown")); -} - -function nextPrMergeRetryDelayMs(config: PrMergeUnknownRetryConfig, previousDelayMs: number): number { - return Math.min(config.maxDelayMs, Math.max(1, Math.ceil(previousDelayMs * config.factor))); -} - -function prMergeMethodFlag(method: unknown): string { - if (method === "squash") return "--squash"; - if (method === "rebase") return "--rebase"; - return "--merge"; -} - -function prMergeRetryCommand(repo: string, number: unknown, method: unknown, deleteBranch: unknown): string { - return [ - "bun scripts/cli.ts gh pr merge", - ghText(number), - "--repo", - repo, - prMergeMethodFlag(method), - deleteBranch === true ? "--delete-branch" : "", - ].filter((item) => item.length > 0).join(" "); -} - -async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise> { - const { owner, name } = repoParts(repo); - const headRepo = pr.head?.repo?.full_name ?? null; - const headRef = pr.head?.ref ?? null; - if (headRepo !== repo || headRef === null || headRef.length === 0) { - return { - attempted: false, - skippedReason: "head-repo-differs-or-ref-missing", - headRepo, - headRef, - }; - } - const encodedRef = encodeURIComponent(`heads/${headRef}`); - const deleted = await githubRequest(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`); - if (isGitHubError(deleted)) { - return { - attempted: true, - ok: false, - headRepo, - headRef, - error: deleted, - }; - } - return { - attempted: true, - ok: true, - headRepo, - headRef, - }; -} - -async function prMerge(repo: string, token: string, number: number, options: GitHubOptions): Promise { - const { owner, name } = repoParts(repo); - let retryConfig: PrMergeUnknownRetryConfig; - try { - retryConfig = loadPrMergeUnknownRetryConfig(); - } catch (error) { - return validationError("pr merge", repo, error instanceof Error ? error.message : String(error), { number, configPath: UNIDESK_CLI_CONFIG_PATH }); - } - const pr = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); - if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" }); - const summary = prSummary(pr); - if (summary.merged === true) { - return withPrMergeRendered({ - ok: true, - command: "pr merge", - repo, - number, - method: options.mergeMethod, - alreadyMerged: true, - pullRequest: summary, - branchDeletion: { attempted: false, skippedReason: "already-merged" }, - rest: true, - }); - } - let metadata: GitHubPullRequestGraphqlMetadata | GitHubErrorPayload | null = null; - let statusChecks: Record = {}; - let mergeability: Record = {}; - let delayMs = retryConfig.initialDelayMs; - const retryAttempts: Array> = []; - for (let attempt = 1; attempt <= retryConfig.maxAttempts; attempt += 1) { - metadata = await prGraphqlMetadata(repo, token, number); - if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, retry: { attempt, maxAttempts: retryConfig.maxAttempts } }); - statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, false); - mergeability = prCloseoutSummary(summary, prMetadataSummary(metadata), statusChecks); - const shouldRetryUnknown = mergeability.readyForCommanderMerge !== true && mergeabilityHasUnknownPending(mergeability); - const waitMs = shouldRetryUnknown && attempt < retryConfig.maxAttempts ? delayMs : 0; - retryAttempts.push({ - attempt, - maxAttempts: retryConfig.maxAttempts, - mergeable: metadata.mergeable ?? null, - mergeStateStatus: metadata.mergeStateStatus ?? null, - statusCheckRollup: metadata.statusCheckRollup?.state ?? null, - conclusion: mergeability.conclusion ?? null, - blockers: Array.isArray(mergeability.blockers) ? mergeability.blockers : [], - pending: Array.isArray(mergeability.pending) ? mergeability.pending : [], - waitMs, - }); - if (mergeability.readyForCommanderMerge === true || !shouldRetryUnknown || attempt >= retryConfig.maxAttempts) break; - await sleepMs(delayMs); - delayMs = nextPrMergeRetryDelayMs(retryConfig, delayMs); - } - if (metadata === null || isGitHubError(metadata)) return commandError("pr merge", repo, metadata ?? errorPayload("invalid-response", "PR merge metadata was not collected"), { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary }); - const retry = { - reason: "github-mergeability-unknown", - maxAttempts: retryConfig.maxAttempts, - attempts: retryAttempts.length, - exhausted: mergeability.readyForCommanderMerge !== true && mergeabilityHasUnknownPending(mergeability) && retryAttempts.length >= retryConfig.maxAttempts, - config: retryConfig, - attemptsLog: retryAttempts, - }; - if (mergeability.readyForCommanderMerge !== true) { - const details = { - number, - method: options.mergeMethod, - deleteBranch: options.deleteBranch, - pullRequest: preflightPullRequestSummary(summary), - mergeability, - statusChecks, - closeoutMetadata: prCloseoutMetadata(metadata), - retry, - }; - return withPrMergeRendered(validationError("pr merge", repo, retry.exhausted ? "PR mergeability is still UNKNOWN after automatic retry; no merge performed" : "PR is not ready for merge; blockers or pending states remain", details), details); - } - if (options.dryRun) { - return withPrMergeRendered({ - ok: true, - command: "pr merge", - repo, - number, - dryRun: true, - wouldMerge: true, - method: options.mergeMethod, - deleteBranch: options.deleteBranch, - pullRequest: preflightPullRequestSummary(summary), - mergeability, - statusChecks, - retry, - }); - } - const merged = await githubRequest>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, { - merge_method: options.mergeMethod, - }); - if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary }); - const after = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); - if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged }); - const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "delete-branch-not-requested" }; - return withPrMergeRendered({ - ok: true, - command: "pr merge", - repo, - number, - method: options.mergeMethod, - mergeResult: merged, - pullRequest: prSummary(after), - mergeability, - statusChecks, - retry, - branchDeletion, - rest: true, - }); -} - -function withPrMergeRendered(result: GitHubCommandResult, fallbackDetails?: Record): GitHubCommandResult { - return { - ...result, - contentType: "text/plain", - renderedText: renderPrMergeTable(result, fallbackDetails), - }; -} - -function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?: Record): string { - const details = fallbackDetails ?? {}; - const pullRequest = isRecord(result.pullRequest) - ? result.pullRequest - : isRecord(details.pullRequest) - ? details.pullRequest - : {}; - const mergeability = isRecord(result.mergeability) - ? result.mergeability - : isRecord(details.mergeability) - ? details.mergeability - : {}; - const statusChecks = isRecord(result.statusChecks) - ? result.statusChecks - : isRecord(details.statusChecks) - ? details.statusChecks - : {}; - const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; - const branchDeletion = isRecord(result.branchDeletion) ? result.branchDeletion : {}; - const retry = isRecord(result.retry) - ? result.retry - : isRecord(details.retry) - ? details.retry - : {}; - const mergeMethod = result.method ?? result.mergeMethod ?? details.method; - const deleteBranch = result.deleteBranch ?? details.deleteBranch; - const status = result.ok === true - ? result.alreadyMerged === true - ? "already-merged" - : result.dryRun === true - ? "dry-run" - : "merged" - : ghText(mergeability.conclusion) === "pending" - ? "pending" - : "blocked"; - const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; - const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map(String) : []; - const rows = [[ - `#${ghText(result.number ?? details.number)}`, - status, - ghText(mergeMethod), - ghText(mergeability.mergeable), - ghText(mergeability.mergeStateStatus), - retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)}${retry.exhausted === true ? " exhausted" : ""}` : "-", - `${ghText(statusChecks.totalContexts)} total ${ghText(counts.success)} ok ${ghText(counts.failure)} fail ${ghText(counts.pending)} pending`, - ]]; - const lines = [ - `gh pr merge (${status})`, - "", - ghTable(["PR", "STATUS", "METHOD", "MERGEABLE", "MERGE_STATE", "RETRY", "CHECKS"], rows), - "", - "Summary:", - ` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`, - ` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`, - ` retry=${retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)} exhausted=${ghText(retry.exhausted)}` : "-"}`, - ` branchDeletion=${ghText(branchDeletion.ok ?? branchDeletion.skippedReason ?? branchDeletion.attempted)}`, - "", - "Next:", - ]; - if (result.ok === true && result.alreadyMerged !== true && result.dryRun !== true) { - lines.push(` bun scripts/cli.ts gh pr view ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)}`); - } else { - lines.push(` ${prMergeRetryCommand(ghText(result.repo), result.number ?? details.number, mergeMethod, deleteBranch)}`); - lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)} --full`); - } - lines.push("", "Disclosure:"); - lines.push(" default view is a bounded table; use --full or --raw on read/preflight commands for structured metadata."); - return lines.join("\n"); -} - -async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise { - const auth = await authStatus(repo); - const authCapability = compactAuthCapability(auth); - const policy = prPreflightPolicy(repo, number); - if (auth.ok === false) { - return { - ok: false, - command: commandName, - repo, - number, - degradedReason: auth.degradedReason, - runnerDisposition: auth.runnerDisposition, - authCapability, - policy, - details: auth, - }; - } - const { token, probe } = resolveToken(true); - const missing = authRequired(repo, commandName, probe); - if (missing !== null || token === null) { - const missingResult = missing ?? authRequired(repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - return { - ...(missingResult ?? commandError(commandName, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required"))), - command: commandName, - number, - authCapability, - policy, - } as GitHubCommandResult; - } - const { owner, name } = repoParts(repo); - const pr = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); - if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, authCapability, policy }); - const metadata = await prGraphqlMetadata(repo, token, number); - const summary = prSummary(pr); - const pullRequest = preflightPullRequestSummary(summary); - if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest, authCapability, policy }); - const metadataSummary = prMetadataSummary(metadata); - const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, includeRaw); - const closeout = prCloseoutSummary(summary, metadataSummary, statusChecks); - const result: GitHubCommandResult = { - ok: true, - command: commandName, - canonicalCommand: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo}`, - aliases: ["bun scripts/cli.ts gh preflight ", "bun scripts/cli.ts gh pr closeout "], - repo, - number, - readOnly: true, - writesRemote: false, - valuesPrinted: false, - authCapability, - pullRequest, - mergeability: { - mergeable: metadataSummary.mergeable, - mergeStateStatus: metadataSummary.mergeStateStatus, - ...closeout, - }, - statusChecks, - policy, - ...(includeRaw ? { raw: { authStatus: auth, pullRequest, closeoutMetadata: metadataSummary } } : {}), - }; - if (includeRaw) return result; - return { - ...result, - contentType: "text/plain", - renderedText: renderPrPreflightTable(result), - }; -} - -function renderPrPreflightTable(result: GitHubCommandResult): string { - const pullRequest = isRecord(result.pullRequest) ? result.pullRequest : {}; - const mergeability = isRecord(result.mergeability) ? result.mergeability : {}; - const statusChecks = isRecord(result.statusChecks) ? result.statusChecks : {}; - const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; - const policy = isRecord(result.policy) ? result.policy : {}; - const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; - const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map(String) : []; - const conclusion = ghText(mergeability.conclusion); - const rows = [[ - `#${ghText(result.number)}`, - ghText(pullRequest.stateDetail ?? pullRequest.state), - ghText(mergeability.mergeable), - ghText(mergeability.mergeStateStatus), - `${ghText(statusChecks.totalContexts)} total ${ghText(counts.success)} ok ${ghText(counts.failure)} fail ${ghText(counts.pending)} pending`, - conclusion, - ]]; - const lines = [ - `gh pr preflight (${conclusion})`, - "", - ghTable(["PR", "STATE", "MERGEABLE", "MERGE_STATE", "CHECKS", "CONCLUSION"], rows), - "", - "Summary:", - ` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`, - ` head=${ghText(isRecord(pullRequest.head) ? pullRequest.head.ref : null)} base=${ghText(isRecord(pullRequest.base) ? pullRequest.base.ref : null)}`, - ` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`, - "", - "Next:", - ]; - const mergeCommand = ghText(policy.mergeCommand); - if (mergeability.readyForCommanderMerge === true && mergeCommand !== "-") { - lines.push(` ${mergeCommand}`); - } - lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number)} --repo ${ghText(result.repo)} --full`); - lines.push("", "Disclosure:"); - lines.push(" default view is a bounded table; use --full or --raw for structured PR/check metadata."); - return lines.join("\n"); -} - -function ghTable(headers: string[], rows: string[][]): string { - const normalizedRows = rows.map((row) => headers.map((_, index) => row[index] ?? "")); - const widths = headers.map((header, index) => Math.max(header.length, ...normalizedRows.map((row) => row[index].length))); - return [ - headers.map((header, index) => header.padEnd(widths[index])).join(" ").trimEnd(), - ...normalizedRows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()), - ].join("\n"); -} - -function ghText(value: unknown): string { - if (value === null || value === undefined || value === "") return "-"; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") return String(value); - return JSON.stringify(value); -} - -function ghShort(value: string, maxLength: number): string { - if (value.length <= maxLength) return value; - if (maxLength <= 1) return value.slice(0, maxLength); - return `${value.slice(0, maxLength - 1)}~`; -} - -function repoSummary(repo: GitHubRepository): Record { - return { - id: repo.id ?? null, - fullName: repo.full_name ?? null, - private: repo.private ?? null, - defaultBranch: repo.default_branch ?? null, - permissions: repo.permissions ?? null, - }; -} - -function branchSummary(branch: GitHubBranch): Record { - return { - name: branch.name ?? null, - sha: branch.commit?.sha ?? null, - }; -} - -function compareSummary(compare: GitHubCompare): Record { - return { - status: compare.status ?? null, - aheadBy: compare.ahead_by ?? null, - behindBy: compare.behind_by ?? null, - totalCommits: compare.total_commits ?? null, - htmlUrl: compare.html_url ?? null, - baseSha: compare.base_commit?.sha ?? null, - mergeBaseSha: compare.merge_base_commit?.sha ?? null, - }; -} - -function prUrl(repo: string, number: number): string { - return `https://github.com/${repo}/pull/${number}`; -} - -function encodePathPart(value: string): string { - return encodeURIComponent(value); -} - -async function repoInfo(token: string, repo: string): Promise { - const { owner, name } = repoParts(repo); - return githubRequest(token, "GET", `/repos/${owner}/${name}`); -} - -async function branchInfo(token: string, repo: string, branch: string): Promise { - const { owner, name } = repoParts(repo); - return githubRequest(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`); -} - -async function compareBranches(token: string, repo: string, base: string, head: string): Promise { - const { owner, name } = repoParts(repo); - return githubRequest(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`); -} - -function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record): Record { - return { - repo, - title: options.title, - base: options.base, - head: options.head, - draft: options.draft, - bodyChars: body.length, - bodyPreview: preview(body), - bodyPreviewLines: body.split(/\r?\n/).slice(0, 12), - bodySource, - preservesRawNewlines: body.includes("\n"), - containsLiteralBackslashN: body.includes("\\n"), - containsBackticks: body.includes("`"), - request: { - method: "POST", - path: "/repos/{owner}/{repo}/pulls", - body: { - title: options.title, - base: options.base, - head: options.head, - draft: options.draft, - bodyChars: body.length, - }, - }, - validation: { - repo: "required", - base: "required", - head: "required", - title: "required", - body: body.length > 0 ? "optional" : "empty", - }, - }; -} - -function prCommentPlannedOperation(repo: string, issueNumber: number, body: string, bodySource: Record): Record { - return { - repo, - issueNumber, - bodyChars: body.length, - bodyPreview: preview(body), - bodyPreviewLines: body.split(/\r?\n/).slice(0, 12), - bodySource, - preservesRawNewlines: body.includes("\n"), - containsLiteralBackslashN: body.includes("\\n"), - containsBackticks: body.includes("`"), - request: { - method: "POST", - path: `/repos/{owner}/{repo}/issues/${issueNumber}/comments`, - body: { bodyChars: body.length }, - }, - validation: { - repo: "required", - issueNumber: "required", - body: "required", - }, - }; -} - -function bodyUpdatePlan(command: string, repo: string, number: number, mode: BodyUpdateMode, body: string, bodySource: Record, existingBody: string | null): Record { - const finalBody = mode === "append" ? `${existingBody ?? ""}${body}` : body; - return { - command, - repo, - number, - mode, - bodySource, - input: bodySafetySignals(body), - existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) }, - finalBody: { - bodyChars: finalBody.length, - bodyPreview: preview(finalBody), - bodyPreviewLines: previewLines(finalBody), - ...bodySafetySignals(finalBody), - }, - }; -} - -function prEditBodyPlan(mode: BodyUpdateMode, input: { body: string; bodySource: Record } | null, finalBody: string | undefined, existingBody: string | null): Record | null { - if (input === null || finalBody === undefined) return null; - return { - mode, - bodySource: input.bodySource, - input: bodySafetySignals(input.body), - existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) }, - finalBody: bodySafetySignals(finalBody), - }; -} - -async function prCreate(repo: string, token: string, options: GitHubOptions): Promise { - if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title "); - if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>"); - if (options.head === undefined) return validationError("pr create", repo, "pr create requires --head <branch>"); - let bodySource: { body: string; bodySource: Record<string, unknown> }; - try { - bodySource = readMarkdownBody(options, "pr create"); - } catch (error) { - return validationError("pr create", repo, error instanceof Error ? error.message : String(error)); - } - const body = bodySource.body; - const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource); - if (options.dryRun) { - return withPrCreateRendered({ - ok: true, - command: "pr create", - repo, - dryRun: true, - planned: true, - draft: options.draft, - ...planned, - }); - } - - const repoResult = await repoInfo(token, repo); - if (isGitHubError(repoResult)) return commandError("pr create", repo, repoResult, { planned }); - const baseBranch = await branchInfo(token, repo, options.base); - if (isGitHubError(baseBranch)) return commandError("pr create", repo, baseBranch, { base: options.base, planned }); - const headBranch = await branchInfo(token, repo, options.head); - if (isGitHubError(headBranch)) return commandError("pr create", repo, headBranch, { head: options.head, planned }); - const compare = await compareBranches(token, repo, options.base, options.head); - if (isGitHubError(compare)) return commandError("pr create", repo, compare, { base: options.base, head: options.head, planned }); - const aheadBy = typeof compare.ahead_by === "number" ? compare.ahead_by : null; - if (aheadBy === null || aheadBy <= 0) { - return validationError("pr create", repo, "head branch must be ahead of base branch before creating a PR", { - base: options.base, - head: options.head, - compare: compareSummary(compare), - }); - } - - const { owner, name } = repoParts(repo); - const payload: Record<string, unknown> = { - title: options.title, - base: options.base, - head: options.head, - draft: options.draft, - body, - }; - const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload); - if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned }); - return withPrCreateRendered({ - ok: true, - command: "pr create", - repo, - pr: prSummary(pr), - validation: { - repo: repoSummary(repoResult), - base: branchSummary(baseBranch), - head: branchSummary(headBranch), - compare: compareSummary(compare), - bodySource: bodySource.bodySource, - }, - request: { - method: "POST", - path: `/repos/${owner}/${name}/pulls`, - title: options.title, - base: options.base, - head: options.head, - draft: options.draft, - bodyChars: body.length, - }, - rest: true, - }); -} - -function withPrCreateRendered(result: GitHubCommandResult): GitHubCommandResult { - return { - ...result, - contentType: "text/plain", - renderedText: renderPrCreateTable(result), - }; -} - -function renderPrCreateTable(result: GitHubCommandResult): string { - const pr = isRecord(result.pr) ? result.pr : {}; - const request = isRecord(result.request) ? result.request : {}; - const number = ghText(pr.number ?? result.number ?? result.pr); - const title = ghText(pr.title ?? request.title ?? result.title); - const base = ghText(isRecord(pr.base) ? pr.base.ref : request.base ?? result.base); - const head = ghText(isRecord(pr.head) ? pr.head.ref : request.head ?? result.head); - const status = result.dryRun === true ? "dry-run" : "created"; - const rows = [[ - number === "-" ? "-" : `#${number}`, - status, - base, - head, - ghText(request.draft ?? result.draft), - ghShort(title, 80), - ]]; - const lines = [ - `gh pr create (${status})`, - "", - ghTable(["PR", "STATUS", "BASE", "HEAD", "DRAFT", "TITLE"], rows), - "", - "Summary:", - ` repo=${ghText(result.repo)} url=${ghText(pr.url ?? pr.html_url ?? result.url)}`, - "", - "Next:", - ]; - if (status === "created" && number !== "-") { - lines.push(` bun scripts/cli.ts gh pr merge ${number} --repo ${ghText(result.repo)} --squash --delete-branch`); - lines.push(` bun scripts/cli.ts gh pr preflight ${number} --repo ${ghText(result.repo)} --full`); - } else { - lines.push(" rerun without --dry-run to create the PR"); - } - lines.push("", "Disclosure:"); - lines.push(" default view is a bounded table; use gh pr view/preflight --full for structured metadata."); - return lines.join("\n"); -} - -async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { - let bodySource: { body: string; bodySource: Record<string, unknown> }; - try { - bodySource = readMarkdownBody(options, "pr comment"); - } catch (error) { - return validationError("pr comment", repo, error instanceof Error ? error.message : String(error), { issueNumber }); - } - const body = bodySource.body; - const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource); - if (options.dryRun) { - return { - ok: true, - command: "pr comment", - repo, - dryRun: true, - planned: true, - issueNumber, - ...planned, - }; - } - - const repoResult = await repoInfo(token, repo); - if (isGitHubError(repoResult)) return commandError("pr comment", repo, repoResult, { issueNumber, planned }); - const { owner, name } = repoParts(repo); - const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`); - if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned }); - const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); - if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned }); - return { - ok: true, - command: "pr comment create", - repo, - issueNumber, - pr: prSummary(prResult), - comment: commentSummary(comment), - bodySource: bodySource.bodySource, - source: String(bodySource.bodySource.kind ?? "unknown"), - bodyChars: body.length, - bodySha: bodySha(body), - bodyPreview: preview(body), - request: { - method: "POST", - path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`, - bodyChars: body.length, - }, - validation: { - repo: repoSummary(repoResult), - pr: prSummary(prResult), - bodySource: bodySource.bodySource, - }, - rest: true, - }; -} - -async function prUpdate(repo: string, token: string, number: number, options: GitHubOptions, commandName = "pr update"): Promise<GitHubCommandResult> { - let bodySource: { body: string; bodySource: Record<string, unknown> } | null; - try { - bodySource = readOptionalMarkdownBody(options, commandName); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { number }); - } - if (bodySource === null && options.title === undefined) { - return validationError(commandName, repo, `${commandName} requires --title <title>, --body-stdin, --body-file <file|->, or --body <text>`, { number }); - } - if (options.mode === "append" && bodySource === null) { - return validationError(commandName, repo, `${commandName} --mode append requires a body source`, { number }); - } - const { owner, name } = repoParts(repo); - let oldPr: GitHubPullRequest | null = null; - if (token.length > 0 && options.mode === "append") { - const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); - if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, phase: "read-before-update" }); - oldPr = pr; - } - const finalBody = bodySource === null ? undefined : (options.mode === "append" ? `${oldPr?.body ?? ""}${bodySource.body}` : bodySource.body); - const changedFields = [ - ...(options.title === undefined ? [] : ["title"]), - ...(bodySource === null ? [] : ["body"]), - ]; - const bodyPlan = prEditBodyPlan(options.mode, bodySource, finalBody, oldPr?.body ?? null); - const request = { - method: "PATCH", - path: `/repos/{owner}/{repo}/pulls/${number}`, - changedFields, - body: { - ...(options.title === undefined ? {} : { title: "provided" }), - ...(finalBody === undefined ? {} : { bodyChars: finalBody.length, bodySha: bodySha(finalBody) }), - }, - }; - if (options.dryRun) { - return { - ok: true, - command: commandName, - repo, - dryRun: true, - planned: true, - number, - url: prUrl(repo, number), - changedFields, - ...(bodyPlan === null ? {} : { body: bodyPlan }), - request, - graphQl: false, - projectsClassic: false, - }; - } - const payload: Record<string, unknown> = {}; - if (finalBody !== undefined) payload.body = finalBody; - if (options.title !== undefined) payload.title = options.title; - const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, payload); - if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, planned: { changedFields, body: bodyPlan, request } }); - return { - ok: true, - command: commandName, - repo, - pr: pr.number ?? number, - number: pr.number ?? number, - changedFields, - url: pr.html_url ?? prUrl(repo, number), - ...(bodyPlan === null ? {} : { body: bodyPlan }), - request: { ...request, path: `/repos/${owner}/${name}/pulls/${number}` }, - rest: true, - graphQl: false, - projectsClassic: false, - }; -} - -async function prState(repo: string, token: string, number: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> { - const command = state === "closed" ? "pr close" : "pr reopen"; - if (dryRun) return { ok: true, command, repo, dryRun: true, number, wouldPatch: { state } }; - const { owner, name } = repoParts(repo); - const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, { state }); - if (isGitHubError(pr)) return commandError(command, repo, pr, { number }); - return { ok: true, command, repo, number, pullRequest: prSummary(pr), rest: true }; -} - -async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> { - const { owner, name } = repoParts(repo); - return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`); -} - -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(); - 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 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> { - const { owner, name } = repoParts(repo); - return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`); -} - -async function getIssueComment(token: string, repo: string, commentId: number): Promise<GitHubComment | GitHubErrorPayload> { - const { owner, name } = repoParts(repo); - return githubRequest<GitHubComment>(token, "GET", `/repos/${owner}/${name}/issues/comments/${commentId}`); -} - -function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined, options: { includeFullCommentBodies?: boolean } = {}): Record<string, unknown> | null { - if (fields === undefined) return null; - const summary = issueSummary(issue); - const selected: Record<string, unknown> = {}; - for (const field of fields) { - if (field === "comments") { - selected.comments = (comments ?? []).map(options.includeFullCommentBodies === true ? commentSummary : compactIssueViewCommentSummary); - } else { - selected[field] = summary[field]; - } - } - return selected; -} - -async function issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read", disclosure: Record<string, unknown> | null = null, options: { includeFullCommentBodies?: boolean } = {}): Promise<GitHubCommandResult> { - const issue = await getIssue(token, repo, issueNumber); - if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); - const needsComments = jsonFields === undefined || jsonFields.includes("comments"); - const includeBody = jsonFields === undefined || jsonFields.includes("body"); - const includeFullCommentBodies = options.includeFullCommentBodies === true; - const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null; - if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue, { includeBody, includePreview: false }) }); - return { - ok: true, - command: commandName, - repo, - ...(disclosure === null ? {} : { disclosure }), - issue: issueSummary(issue, { includeBody, includePreview: false }), - codeQueueBoardHint: codeQueueBoardCommanderBriefHint(issueNumber, issue.body ?? ""), - ...(comments === null || jsonFields !== undefined ? {} : { comments: comments.map(includeFullCommentBodies ? commentSummary : compactCommentSummary) }), - ...(jsonFields === undefined ? {} : { - jsonFields, - json: selectedIssueJson(issue, comments, jsonFields, { includeFullCommentBodies }), - compatibility: { - legacyJsonBodyPath: includeBody ? ".data.issue.body" : null, - bodyOmitted: !includeBody, - commentsCompacted: comments !== null && !includeFullCommentBodies, - commentBodiesOmitted: comments !== null && !includeFullCommentBodies, - fullCommentBodiesIncluded: comments !== null && includeFullCommentBodies, - commentsPath: comments === null ? null : ".data.json.comments", - readCommands: { - ...(includeBody ? {} : issueBodyReadCommands(repo, issueNumber)), - ...(comments !== null && !includeFullCommentBodies ? issueCommentReadCommands(repo, issueNumber) : {}), - }, - }, - }), - }; -} - -async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, disclosure: Record<string, unknown> | null = null, options: { includeFullCommentBodies?: boolean } = {}): Promise<GitHubCommandResult> { - return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure, options); -} - -const GITHUB_USER_ATTACHMENT_URL_PATTERN = /https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s<>"')]+)?/giu; - -function normalizedAttachmentUrl(raw: string): string | null { - try { - const url = new URL(raw); - if (url.protocol !== "https:" || url.hostname !== "github.com" || !url.pathname.startsWith("/user-attachments/assets/")) return null; - return url.toString(); - } catch { - return null; - } -} - -function redactedUrl(raw: string): string { - try { - const url = new URL(raw); - url.search = ""; - url.hash = ""; - return url.toString(); - } catch { - return raw.split("?")[0] ?? raw; - } -} - -function shouldSendGitHubWebAuth(raw: string): boolean { - try { - return new URL(raw).hostname === "github.com"; - } catch { - return false; - } -} - -function decodeHtmlUrl(raw: string): string { - return raw - .replace(/&/giu, "&") - .replace(///giu, "/") - .replace(/=/giu, "="); -} - -function attachmentAssetId(url: string): string | null { - try { - const parsed = new URL(url); - return parsed.pathname.split("/").filter(Boolean).at(-1) ?? null; - } catch { - return null; - } -} - -function htmlAttribute(tag: string, name: string): string | null { - const match = new RegExp(`\\b${name}\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^\\s>]+))`, "iu").exec(tag); - return match?.[2] ?? match?.[3] ?? match?.[4] ?? null; -} - -function extractIssueAttachmentsFromBody(body: string | null | undefined, source: Omit<GitHubIssueAttachment, "index" | "url" | "assetId" | "marker">): Array<Omit<GitHubIssueAttachment, "index">> { - const text = body ?? ""; - const attachments: Array<Omit<GitHubIssueAttachment, "index">> = []; - const seen = new Set<string>(); - const add = (rawUrl: string, marker: GitHubIssueAttachment["marker"], details: Partial<GitHubIssueAttachment> = {}) => { - const url = normalizedAttachmentUrl(rawUrl); - if (url === null || seen.has(url)) return; - seen.add(url); - attachments.push({ - ...source, - marker, - url, - assetId: attachmentAssetId(url), - alt: details.alt ?? null, - width: details.width ?? null, - height: details.height ?? null, - }); - }; - - for (const match of text.matchAll(/!\[([^\]]*)\]\((https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s)"']+)?)\)/giu)) { - add(match[2] ?? "", "markdown-image", { alt: match[1] ?? null }); - } - for (const match of text.matchAll(/<img\b[^>]*>/giu)) { - const tag = match[0] ?? ""; - const src = htmlAttribute(tag, "src"); - if (src === null) continue; - add(src, "html-img", { - alt: htmlAttribute(tag, "alt"), - width: htmlAttribute(tag, "width"), - height: htmlAttribute(tag, "height"), - }); - } - for (const match of text.matchAll(GITHUB_USER_ATTACHMENT_URL_PATTERN)) add(match[0] ?? "", "plain-url"); - return attachments; -} - -async function collectIssueAttachments(repo: string, token: string, issueNumber: number, commandName: string): Promise<{ issue: GitHubIssue; comments: GitHubComment[]; attachments: GitHubIssueAttachment[] } | GitHubCommandFailure> { - const issue = await getIssue(token, repo, issueNumber); - if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); - const comments = await listIssueComments(token, repo, issueNumber); - if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) }); - const dedupe = new Set<string>(); - const attachments: GitHubIssueAttachment[] = []; - const append = (items: Array<Omit<GitHubIssueAttachment, "index">>) => { - for (const item of items) { - if (dedupe.has(item.url)) continue; - dedupe.add(item.url); - attachments.push({ ...item, index: attachments.length + 1 }); - } - }; - append(extractIssueAttachmentsFromBody(issue.body, { - source: "issue-body", - sourceUrl: issue.html_url, - author: issue.user?.login ?? null, - createdAt: issue.created_at ?? null, - updatedAt: issue.updated_at ?? null, - })); - for (const comment of comments) { - append(extractIssueAttachmentsFromBody(comment.body, { - source: "comment", - sourceUrl: comment.html_url, - commentId: comment.id, - author: comment.user?.login ?? null, - createdAt: comment.created_at ?? null, - updatedAt: comment.updated_at ?? null, - })); - } - return { issue, comments, attachments }; -} - -function issueAttachmentSummary(attachment: GitHubIssueAttachment): Record<string, unknown> { - return { - index: attachment.index, - url: attachment.url, - assetId: attachment.assetId, - source: attachment.source, - sourceUrl: attachment.sourceUrl, - commentId: attachment.commentId ?? null, - marker: attachment.marker, - alt: attachment.alt ?? null, - width: attachment.width ?? null, - height: attachment.height ?? null, - author: attachment.author ?? null, - createdAt: attachment.createdAt ?? null, - updatedAt: attachment.updatedAt ?? null, - }; -} - -async function issueAttachmentList(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> { - const commandName = "issue attachment list"; - const collected = await collectIssueAttachments(repo, token, issueNumber, commandName); - if (isGitHubCommandResult(collected)) return collected; - return { - ok: true, - command: commandName, - repo, - readOnly: true, - issue: issueSummary(collected.issue), - commentsScanned: collected.comments.length, - count: collected.attachments.length, - attachments: collected.attachments.map(issueAttachmentSummary), - next: collected.attachments.length > 0 - ? { - command: `bun scripts/cli.ts gh issue attachment download ${issueNumber} --repo ${repo} --attachment 1 --output /tmp/gh-issue-${issueNumber}-attachment-1`, - } - : null, - request: { - method: "GET", - paths: [ - `/repos/{owner}/{repo}/issues/${issueNumber}`, - `/repos/{owner}/{repo}/issues/${issueNumber}/comments`, - ], - }, - rest: true, - }; -} - -function selectIssueAttachment(attachments: GitHubIssueAttachment[], selector: string | undefined): GitHubIssueAttachment | null { - if (attachments.length === 0) return null; - if (selector === undefined) return attachments[0] ?? null; - const trimmed = selector.trim(); - if (/^\d+$/u.test(trimmed)) { - const index = Number(trimmed); - return attachments.find((attachment) => attachment.index === index) ?? null; - } - const url = normalizedAttachmentUrl(trimmed); - if (url !== null) return attachments.find((attachment) => attachment.url === url) ?? null; - return attachments.find((attachment) => attachment.assetId === trimmed) ?? null; -} - -function attachmentExtension(contentType: string | null): string { - const normalized = (contentType ?? "").split(";")[0]?.trim().toLowerCase(); - if (normalized === "image/png") return ".png"; - if (normalized === "image/jpeg" || normalized === "image/jpg") return ".jpg"; - if (normalized === "image/gif") return ".gif"; - if (normalized === "image/webp") return ".webp"; - if (normalized === "image/svg+xml") return ".svg"; - return ".bin"; -} - -function defaultAttachmentOutputPath(repo: string, issueNumber: number, attachment: GitHubIssueAttachment, contentType: string | null): string { - const safeRepo = repo.replace(/[^A-Za-z0-9._-]+/gu, "_"); - return path.join("/tmp/unidesk-gh-attachments", `${safeRepo}-issue-${issueNumber}-attachment-${attachment.index}${attachmentExtension(contentType)}`); -} - -function attachmentFetchError(url: string, status: number, message: string, details: Record<string, unknown> = {}): GitHubErrorPayload { - const reason: GitHubDegradedReason = status === 401 - ? "auth-failed" - : status === 403 - ? "permission-denied" - : status === 404 - ? "validation-failed" - : "invalid-response"; - return errorPayload(reason, message, { - status, - details: { - url: redactedUrl(url), - ...details, - }, - request: { method: "GET", path: redactedUrl(url) }, - }); -} - -async function fetchAttachmentResponse(url: string, token: string, useAuth: boolean, redirect: RequestRedirect): Promise<Response | GitHubErrorPayload> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const headers: Record<string, string> = { - Accept: "image/*,application/octet-stream,*/*", - "User-Agent": USER_AGENT, - }; - if (useAuth && token.length > 0 && shouldSendGitHubWebAuth(url)) headers.Authorization = `Bearer ${token}`; - return await fetch(url, { - method: "GET", - redirect, - signal: controller.signal, - headers, - }); - } catch (error) { - const classified = classifyGitHubFetchFailure(error); - return errorPayload(classified.reason, classified.message, { - request: { method: "GET", path: redactedUrl(url) }, - details: { ...classified.details, url: redactedUrl(url) }, - retryable: classified.retryable, - commanderAction: classified.commanderAction, - }); - } finally { - clearTimeout(timeout); - } -} - -async function readAttachmentResponse(response: Response, url: string): Promise<GitHubAttachmentDownload | GitHubErrorPayload> { - if (!response.ok) return attachmentFetchError(url, response.status, response.statusText || "GitHub attachment download failed"); - const contentLengthHeader = response.headers.get("content-length"); - const contentLength = contentLengthHeader === null ? null : Number(contentLengthHeader); - if (contentLength !== null && Number.isFinite(contentLength) && contentLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) { - return attachmentFetchError(url, 413, "GitHub attachment is larger than the UniDesk gh download safety limit", { - contentLength, - maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, - }); - } - let buffer: Buffer; - try { - buffer = Buffer.from(await response.arrayBuffer()); - } catch (error) { - const classified = classifyGitHubFetchFailure(error); - return errorPayload(classified.reason, classified.message, { - request: { method: "GET", path: redactedUrl(url) }, - details: { ...classified.details, url: redactedUrl(url), phase: "read-body" }, - retryable: classified.retryable, - commanderAction: classified.commanderAction, - }); - } - if (buffer.byteLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) { - return attachmentFetchError(url, 413, "GitHub attachment body exceeded the UniDesk gh download safety limit", { - bytes: buffer.byteLength, - maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, - }); - } - return { - buffer, - contentType: response.headers.get("content-type"), - contentLengthHeader, - finalUrl: response.url, - }; -} - -async function downloadGitHubAttachment(url: string, token: string): Promise<GitHubAttachmentDownload | GitHubErrorPayload> { - const direct = await fetchAttachmentResponse(url, "", false, "follow"); - if (!isGitHubError(direct)) { - if (direct.ok) return readAttachmentResponse(direct, url); - } else { - return direct; - } - - const authenticated = await fetchAttachmentResponse(url, token, true, "manual"); - if (isGitHubError(authenticated)) return authenticated; - if (authenticated.status >= 300 && authenticated.status < 400) { - const location = authenticated.headers.get("location"); - if (location === null || location.length === 0) return attachmentFetchError(url, authenticated.status, "GitHub attachment redirect did not include a Location header"); - const redirectedUrl = new URL(location, url).toString(); - const redirected = await fetchAttachmentResponse(redirectedUrl, "", false, "follow"); - if (isGitHubError(redirected)) return redirected; - return readAttachmentResponse(redirected, redirectedUrl); - } - return readAttachmentResponse(authenticated, url); -} - -function extractRenderedAttachmentUrlCandidates(html: string | null | undefined, assetId: string | null, source: Record<string, unknown>): Array<{ url: string; source: Record<string, unknown> }> { - if (assetId === null || assetId.length === 0) return []; - const text = html ?? ""; - const candidates: Array<{ url: string; source: Record<string, unknown> }> = []; - const seen = new Set<string>(); - for (const match of text.matchAll(/https?:\/\/[^"'<> ]+/giu)) { - const decoded = decodeHtmlUrl(match[0] ?? ""); - if (!decoded.includes(assetId)) continue; - if (!/private-user-images\.githubusercontent\.com|github-production-user-asset|github\.com\/user-attachments\/assets/iu.test(decoded)) continue; - if (seen.has(decoded)) continue; - seen.add(decoded); - candidates.push({ url: decoded, source }); - } - return candidates; -} - -async function resolveRenderedIssueAttachmentUrl(repo: string, token: string, issueNumber: number, attachment: GitHubIssueAttachment): Promise<{ url: string; source: Record<string, unknown> } | GitHubErrorPayload | null> { - const { owner, name } = repoParts(repo); - const query = ` - query UniDeskIssueAttachmentRenderedUrl($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - issue(number: $number) { - bodyHTML - comments(first: 100) { - nodes { - id - url - bodyHTML - } - } - } - } - } - `; - const response = await githubGraphqlRequest<{ - repository?: { - issue?: { - bodyHTML?: string | null; - comments?: { - nodes?: Array<{ id?: string; url?: string; bodyHTML?: string | null } | null> | null; - } | null; - } | null; - } | null; - }>(token, query, { owner, name, number: issueNumber }); - if (isGitHubError(response)) return response; - const issue = response.repository?.issue; - if (issue === null || issue === undefined) return null; - const candidates = [ - ...extractRenderedAttachmentUrlCandidates(issue.bodyHTML, attachment.assetId, { source: "issue-bodyHTML" }), - ...((issue.comments?.nodes ?? []).flatMap((comment) => extractRenderedAttachmentUrlCandidates(comment?.bodyHTML, attachment.assetId, { - source: "comment-bodyHTML", - commentNodeId: comment?.id ?? null, - commentUrl: comment?.url ?? null, - }))), - ]; - return candidates[0] ?? null; -} - -async function issueAttachmentDownload(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { - const commandName = "issue attachment download"; - const collected = await collectIssueAttachments(repo, token, issueNumber, commandName); - if (isGitHubCommandResult(collected)) return collected; - const attachment = selectIssueAttachment(collected.attachments, options.attachmentSelector); - if (attachment === null) { - return validationError(commandName, repo, "No matching GitHub user attachment found in the issue body or comments", { - issueNumber, - selector: options.attachmentSelector ?? null, - attachments: collected.attachments.map(issueAttachmentSummary), - }); - } - if (options.dryRun) { - return { - ok: true, - command: commandName, - repo, - dryRun: true, - planned: true, - issue: issueSummary(collected.issue), - attachment: issueAttachmentSummary(attachment), - outputPath: options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, null), - noWrite: true, - }; - } - let downloaded = await downloadGitHubAttachment(attachment.url, token); - let renderedFallback: Record<string, unknown> | null = null; - if (isGitHubError(downloaded)) { - const rendered = await resolveRenderedIssueAttachmentUrl(repo, token, issueNumber, attachment); - if (isGitHubError(rendered)) { - return commandError(commandName, repo, downloaded, { - issueNumber, - attachment: issueAttachmentSummary(attachment), - renderedFallback: { - attempted: true, - ok: false, - degradedReason: rendered.degradedReason, - message: rendered.message, - }, - }); - } - if (rendered !== null) { - renderedFallback = { - attempted: true, - ok: true, - source: rendered.source, - url: redactedUrl(rendered.url), - }; - downloaded = await downloadGitHubAttachment(rendered.url, token); - } else { - renderedFallback = { - attempted: true, - ok: false, - reason: "rendered-bodyHTML-url-not-found", - }; - } - } - if (isGitHubError(downloaded)) return commandError(commandName, repo, downloaded, { issueNumber, attachment: issueAttachmentSummary(attachment), renderedFallback }); - const outputPath = options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, downloaded.contentType); - mkdirSync(path.dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, downloaded.buffer); - return { - ok: true, - command: commandName, - repo, - issue: issueSummary(collected.issue), - attachment: issueAttachmentSummary(attachment), - output: { - path: outputPath, - bytes: downloaded.buffer.byteLength, - sha256: binarySha(downloaded.buffer), - contentType: downloaded.contentType, - contentLengthHeader: downloaded.contentLengthHeader, - finalUrl: redactedUrl(downloaded.finalUrl), - }, - request: { - method: "GET", - url: attachment.url, - maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, - }, - renderedFallback, - valuesPrinted: false, - }; -} - -function shellWord(value: string): string { - return JSON.stringify(value); -} - -function issueListNextCommand(repo: string, state: IssueListState, limit: number, search: string, labels: string[], titlePrefix: 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)); - if (titlePrefix.length > 0) parts.push("--title-prefix", shellWord(titlePrefix)); - for (const label of labels) parts.push("--label", shellWord(label)); - return parts.join(" "); -} - -function issueListLabelText(value: unknown): string { - if (!Array.isArray(value)) return ghText(value); - const labels = value - .map((item) => typeof item === "string" ? item : isRecord(item) ? ghText(item.name) : ghText(item)) - .filter((item) => item !== "-"); - return labels.length === 0 ? "-" : labels.join(","); -} - -function withIssueListRendered(result: GitHubCommandResult): GitHubCommandResult { - return { - ...result, - contentType: "text/plain", - renderedText: renderIssueListTable(result), - }; -} - -function renderIssueListTable(result: GitHubCommandResult): string { - const issues = Array.isArray(result.issues) ? result.issues.map((item) => isRecord(item) ? item : {}) : []; - const rows = issues.map((issue) => [ - issue.number === undefined ? "-" : `#${ghText(issue.number)}`, - ghText(issue.state), - ghShort(ghText(issue.updatedAt), 19), - ghShort(issueListLabelText(issue.labels), 32), - ghShort(ghText(issue.title), 96), - ]); - const next = isRecord(result.next) ? result.next : {}; - const lines = [ - "gh issue list (observed)", - "", - ghTable(["ISSUE", "STATE", "UPDATED", "LABELS", "TITLE"], rows), - "", - "Summary:", - ` repo=${ghText(result.repo)} state=${ghText(result.state)} count=${ghText(result.count)} raw=${ghText(result.rawCount)} hasMore=${ghText(result.hasMore)}`, - ` search=${ghText(result.search)} labels=${issueListLabelText(result.labels)} titlePrefix=${ghText(result.titlePrefix)}`, - ]; - if (result.searchTotalCount !== undefined) lines.push(` searchTotal=${ghText(result.searchTotalCount)} incomplete=${ghText(result.searchIncomplete)}`); - if (typeof next.command === "string" && next.command.length > 0) { - lines.push("", "Next:", ` ${next.command}`); - } - lines.push("", "Disclosure:"); - lines.push(" default view is a bounded table; use --json for selected fields or --full/--raw for structured pagination and request metadata."); - return lines.join("\n"); -} - -async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string, labels: string[] = [], noDump = false, titlePrefix?: string): Promise<GitHubCommandResult> { - const normalizedSearch = String(search ?? "").trim(); - const normalizedTitlePrefix = String(titlePrefix ?? "").trim(); - if (titlePrefix !== undefined && normalizedTitlePrefix.length === 0) return validationError("issue list", repo, "--title-prefix requires a non-empty value"); - const result = await listIssues(token, repo, state, limit, normalizedSearch, labels); - if (isGitHubError(result)) return commandError("issue list", repo, result, { state, limit, search: normalizedSearch || null, titlePrefix: normalizedTitlePrefix || null, labels }); - const listedIssues = result.items; - const issues = normalizedTitlePrefix.length > 0 - ? listedIssues.filter((issue) => issue.title.startsWith(normalizedTitlePrefix)) - : listedIssues; - const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice(); - const payload: GitHubCommandResult = { - ok: true, - command: "issue list", - repo, - ...(noDump ? { noDump: true } : {}), - state, - limit, - search: normalizedSearch || null, - titlePrefix: normalizedTitlePrefix || null, - ...(normalizedTitlePrefix.length > 0 - ? { - titleFilter: { - field: "title", - operator: "startsWith", - prefix: normalizedTitlePrefix, - scope: "bounded-listed-issues", - inputCount: listedIssues.length, - outputCount: issues.length, - filteredOut: listedIssues.length - issues.length, - }, - } - : {}), - labels, - count: issues.length, - 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, normalizedTitlePrefix), - 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}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE } - : { state, labels, per_page: GITHUB_REST_PAGE_SIZE }, - localTitlePrefixFilter: normalizedTitlePrefix || null, - }, - note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.", - }; - return jsonFields === undefined && !noDump ? withIssueListRendered(payload) : payload; -} - -async function issueBoardAudit(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { - const commandName = "issue board-audit"; - const boardIssue = await getIssue(token, repo, options.boardIssue); - if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue }); - const parsed = parseBoardTables(boardIssue.body ?? ""); - const openRows = parsed.sections.filter((section) => section.kind === "open").flatMap((section) => section.rows); - const closedRows = parsed.sections.filter((section) => section.kind === "closed").flatMap((section) => section.rows); - const body = boardIssue.body ?? ""; - - return { - ok: true, - command: commandName, - repo, - dryRun: true, - planned: true, - readOnly: true, - boardIssue: { - number: boardIssue.number, - title: boardIssue.title, - state: boardIssue.state, - url: boardIssue.html_url, - bodyChars: body.length, - bodyLines: body.length === 0 ? 0 : normalizeNewlines(body).split("\n").length, - bodySha: bodySha(body), - updatedAt: boardIssue.updated_at ?? null, - }, - config: { - limit: options.limit, - knownMetaIssues: mergedKnownMetaIssues(options), - ignoredIssues: options.ignoredIssues, - briefIndexManagedIssuePattern: options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE ? "YYYY-MM-DD 指挥简报(北京时间)" : null, - openClosedCoverageValidation: false, - requiredColumns: [], - }, - summary: { - openIssues: null, - closedIssues: null, - openRows: openRows.length, - closedRows: closedRows.length, - parsedSections: parsed.sections.length, - parsedRows: parsed.sections.reduce((sum, section) => sum + section.rows.length, 0), - parserWarnings: parsed.warnings.length, - missingOpenIssues: 0, - closedInOpenRows: 0, - missingClosedRows: 0, - openInClosedRows: 0, - rowValidationWarnings: 0, - ignoredIssues: 0, - boardOnlyRows: 0, - staleOpenRows: 0, - recommendedActions: 0, - }, - validation: { - openClosedCoverage: { - enabled: false, - reason: "OPEN/CLOSED table coverage validation has been disabled; this command no longer compares GitHub issue state against board rows.", - }, - rowRequiredColumns: { - enabled: false, - reason: "board-audit no longer reports missing required row columns; use board-row commands when intentionally maintaining a legacy OPEN/CLOSED table.", - }, - }, - sections: parsed.sections.map((section) => ({ - kind: section.kind, - heading: section.heading, - headingLine: section.headingLine, - headerLine: section.headerLine, - headers: section.headers, - rows: section.rows.length, - issueNumbers: section.rows.flatMap((row) => row.issueNumbers), - })), - parserWarnings: parsed.warnings, - missingOpenIssues: [], - closedInOpenRows: [], - missingClosedRows: [], - openInClosedRows: [], - staleOpenRows: [], - boardOnlyRows: [], - rowValidationWarnings: [], - ignoredIssues: [], - recommendedActions: [], - request: { - method: "GET", - paths: [ - `/repos/{owner}/{repo}/issues/${options.boardIssue}`, - ], - query: {}, - }, - note: "Read-only board structure audit; OPEN/CLOSED coverage validation is disabled, no issue body was edited, no issue was closed, and no comments were written.", - }; -} - -async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { - if (options.title === undefined) return withIssueCreateRendered(validationError("issue create", repo, "issue create requires --title <title>")); - if (options.body !== undefined) { - return withIssueCreateRendered(validationError("issue create", repo, "issue create does not support --body; use --body-stdin with a quoted heredoc for Markdown", { - title: options.title, - bodySource: "inline", - })); - } - const { body, bodySource } = readMarkdownBody({ - ...options, - body: undefined, - }, "issue create"); - const labels = options.labels; - if (options.dryRun) { - return withIssueCreateRendered({ - ok: true, - command: "issue create", - repo, - dryRun: true, - planned: true, - title: options.title, - labels, - ...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title, labels }), - }); - } - const { owner, name } = repoParts(repo); - const payload: Record<string, unknown> = { title: options.title, body }; - if (labels.length > 0) payload.labels = labels; - const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, payload); - if (isGitHubError(issue)) return withIssueCreateRendered(commandError("issue create", repo, issue, { title: options.title, labels })); - const appliedLabels = issueLabelNames(issue); - const missingLabels = labels.filter((label) => !appliedLabels.includes(label)); - if (missingLabels.length > 0) { - return withIssueCreateRendered(validationError("issue create", repo, "GitHub created the issue but did not return all requested labels; refusing to report silent label success", { - issue: issueSummary(issue), - requestedLabels: labels, - appliedLabels, - missingLabels, - })); - } - return withIssueCreateRendered({ ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true }); -} - -function withIssueCreateRendered(result: GitHubCommandResult): GitHubCommandResult { - return { - ...result, - contentType: "text/plain", - renderedText: renderIssueCreateTable(result), - }; -} - -function renderIssueCreateTable(result: GitHubCommandResult): string { - const issue = isRecord(result.issue) ? result.issue : {}; - const number = ghText(issue.number ?? result.number); - const title = ghText(issue.title ?? result.title); - const ok = result.ok !== false; - const details = isRecord(result.details) ? result.details : {}; - const status = ok ? (result.dryRun === true ? "dry-run" : "created") : "failed"; - const reason = ok ? "-" : ghText(result.degradedReason ?? details.degradedReason ?? "validation-failed"); - const message = ghText(result.message ?? details.message); - const labels = Array.isArray(result.labels) ? result.labels.map(String).join(",") : ghText(result.labels); - const lines = [ - `gh issue create (${status})`, - "", - ghTable(["ISSUE", "STATUS", "REASON", "LABELS", "TITLE"], [[ - number === "-" ? "-" : `#${number}`, - status, - ghShort(reason, 32), - ghShort(labels, 40), - ghShort(title, 96), - ]]), - "", - "Summary:", - ` repo=${ghText(result.repo)} url=${ghText(issue.url ?? result.url)}`, - "", - "Next:", - ]; - if (!ok) { - const nextIndex = lines.indexOf("Next:"); - if (nextIndex >= 0) lines.splice(nextIndex, 0, ` message=${ghShort(message, 160)}`, ""); - lines.push(" use --body-stdin with a quoted heredoc for Markdown issue bodies"); - } else if (status === "created" && number !== "-") { - lines.push(` bun scripts/cli.ts gh issue view ${number} --repo ${ghText(result.repo)}`); - } else { - lines.push(" rerun without --dry-run to create the issue"); - } - lines.push("", "Disclosure:"); - lines.push(" default view is a bounded table; use gh issue view --full for structured metadata."); - return lines.join("\n"); -} - -async function issuePatch(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { - const commandName = "issue patch"; - const concurrencyOptionError = assertConcurrencyOptions(options, commandName); - if (concurrencyOptionError !== null) return concurrencyOptionError; - let patchInput: { patch: string; patchSource: Record<string, unknown> }; - try { - patchInput = readBodyPatch(options, commandName); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber }); - } - const { patch, patchSource } = patchInput; - const oldIssue = await getIssue(token, repo, issueNumber); - if (isGitHubError(oldIssue)) return commandError(commandName, repo, oldIssue, { issueNumber, phase: "read-before-patch" }); - const concurrencyError = validateIssueConcurrency(repo, issueNumber, oldIssue, options, commandName); - if (concurrencyError !== null) return concurrencyError; - const oldBody = oldIssue.body ?? ""; - let patched: { newBody: string; patchSummary: Record<string, unknown> }; - try { - patched = applyVirtualBodyPatch("issue.md", oldBody, patch); - } catch (error) { - return validationError(commandName, repo, "body patch failed; no GitHub write performed", { - issueNumber, - patchSource, - patchError: bodyPatchErrorSummary(error), - oldBody: { - bodyChars: oldBody.length, - bodySha: bodySha(oldBody), - updatedAt: oldIssue.updated_at ?? null, - }, - noWrite: true, - }); - } - const profileContext: IssueProfileValidationContext = { issueTitle: oldIssue.title, issueBody: oldIssue.body, issueMetadataFetched: true }; - const guard = validateIssueBodyGuard(repo, issueNumber, patched.newBody, options, profileContext, commandName, patchSource); - if (guard !== null) return guard; - const guardSummary = issueEditGuardSummary(issueNumber, patched.newBody, options, profileContext); - const bodyChange = { - oldBodyChars: oldBody.length, - newBodyChars: patched.newBody.length, - deltaChars: patched.newBody.length - oldBody.length, - oldBodySha: bodySha(oldBody), - newBodySha: bodySha(patched.newBody), - oldUpdatedAt: oldIssue.updated_at ?? null, - }; - const concurrency = { - checked: true, - oldIssueUpdatedAt: oldIssue.updated_at ?? null, - oldBodySha: bodySha(oldBody), - expectUpdatedAt: options.expectUpdatedAt ?? null, - expectBodySha: options.expectBodySha ?? null, - }; - if (options.dryRun) { - return { - ok: true, - command: commandName, - repo, - dryRun: true, - planned: true, - issueNumber, - patchSource, - patch: patched.patchSummary, - ...dryRunBody(repo, undefined, patched.newBody), - disclosure: issueWriteDisclosure(options, repo, issueNumber, true), - readCommands: issueBodyReadCommands(repo, issueNumber), - guard: guardSummary, - concurrency: { ...concurrency, dryRunNoWrite: true }, - bodyChange, - wouldPatch: { - issueNumber, - bodyChars: patched.newBody.length, - bodySha: bodySha(patched.newBody), - method: "PATCH", - path: `/repos/{owner}/{repo}/issues/${issueNumber}`, - }, - }; - } - const { owner, name } = repoParts(repo); - const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { body: patched.newBody }); - if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); - return { - ok: true, - command: commandName, - repo, - issue: issueSummary(issue, { includeBody: options.raw || options.full }), - patchSource, - patch: patched.patchSummary, - bodyChange: { - ...bodyChange, - newUpdatedAt: issue.updated_at ?? null, - }, - guard: guardSummary, - concurrency, - disclosure: issueWriteDisclosure(options, repo, issueNumber, false), - readCommands: issueBodyReadCommands(repo, issueNumber), - request: { - method: "PATCH", - path: `/repos/${owner}/${name}/issues/${issueNumber}`, - bodyChars: patched.newBody.length, - }, - rest: true, - }; -} - -async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> { - if (options.bodyFile === undefined) { - return validationError(commandName, repo, `${commandName} requires --body-stdin or --body-file <file|->`, { issueNumber }); - } - let bodyInput: { body: string; bodySource: Record<string, unknown> }; - try { - bodyInput = readMarkdownBodyFileOrStdin(options.bodyFile); - } catch (error) { - return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber }); - } - const { body, bodySource } = bodyInput; - const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile); - if (options.mode === "replace" && !needsProfileMetadata) { - const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options); - if (bodyGuard !== null) return bodyGuard; - } - const concurrencyOptionError = assertConcurrencyOptions(options, commandName); - if (concurrencyOptionError !== null) return concurrencyOptionError; - let oldIssue: GitHubIssue | null = null; - let briefDiff: CommanderBriefDiff | null = null; - let profileContext: IssueProfileValidationContext = {}; - const claudeQqConfig = commanderBriefClaudeQqConfig(); - if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) { - return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber }); - } - const needsReadBeforeEdit = options.mode === "append" - || needsProfileMetadata && token.length > 0 - || !options.dryRun; - if (needsReadBeforeEdit) { - const issue = await getIssue(token, repo, issueNumber); - if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" }); - oldIssue = issue; - profileContext = { issueTitle: issue.title, issueBody: issue.body, issueMetadataFetched: true }; - const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options); - if (concurrencyError !== null) return concurrencyError; - if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body); - } - const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body; - if (options.mode === "append" || needsProfileMetadata) { - const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options, profileContext); - if (bodyGuard !== null) return bodyGuard; - } - if (options.dryRun) { - const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null; - const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext); - let dryRunOldBody: Record<string, unknown> = { - fetched: false, - bodyChars: null, - bodySha: null, - updatedAt: null, - reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run", - }; - if (oldIssue !== null) { - const oldBody = oldIssue.body ?? ""; - dryRunOldBody = { - fetched: true, - bodyChars: oldBody.length, - bodySha: bodySha(oldBody), - updatedAt: oldIssue.updated_at ?? null, - }; - } else if (token.length > 0) { - const issue = await getIssue(token, repo, issueNumber); - if (isGitHubError(issue)) { - dryRunOldBody = { - fetched: false, - bodyChars: null, - bodySha: null, - updatedAt: null, - readError: { - degradedReason: issue.degradedReason, - runnerDisposition: issue.runnerDisposition, - message: issue.message, - status: issue.status ?? null, - }, - }; - } else { - const oldBody = issue.body ?? ""; - dryRunOldBody = { - fetched: true, - bodyChars: oldBody.length, - bodySha: bodySha(oldBody), - updatedAt: issue.updated_at ?? null, - }; - } - } - return { - ok: true, - command: commandName, - repo, - dryRun: true, - issueNumber, - mode: options.mode, - ...dryRunBody(repo, options.title, finalBody), - disclosure: issueWriteDisclosure(options, repo, issueNumber, true), - readCommands: issueBodyReadCommands(repo, issueNumber), - guard, - update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, bodySource, oldIssue?.body ?? null), - bodyOnlySafety: { - oldBody: dryRunOldBody, - newBody: { - bodyChars: finalBody.length, - bodySha: bodySha(finalBody), - bodyPreview: preview(finalBody), - bodyPreviewLines: previewLines(finalBody), - }, - }, - concurrency: { - dryRunNoWrite: true, - expectUpdatedAt: options.expectUpdatedAt ?? null, - expectBodySha: options.expectBodySha ?? null, - note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH", - }, - wouldPatch: { - issueNumber, - title: options.title ?? null, - bodySource, - mode: options.mode, - bodyChars: finalBody.length, - bodySha: bodySha(finalBody), - }, - ...(options.notifyClaudeQqBriefDiff - ? { - commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig), - dryRunOldBodySource: "not-fetched", - dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body", - dryRunNoWrite: true, - dryRunNoClaudeQqSend: true, - } - : {}), - }; - } - const { owner, name } = repoParts(repo); - const payload: Record<string, unknown> = { body: finalBody }; - if (options.title !== undefined) payload.title = options.title; - const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload); - if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); - const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext); - const concurrency = oldIssue === null - ? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null } - : { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }; - if (!options.notifyClaudeQqBriefDiff) { - return { - ok: true, - command: commandName, - repo, - issue: issueSummary(issue, { includeBody: options.raw || options.full }), - mode: options.mode, - bodySource, - guard, - concurrency, - disclosure: issueWriteDisclosure(options, repo, issueNumber, false), - readCommands: issueBodyReadCommands(repo, issueNumber), - rest: true, - }; - } - - const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody); - const claudeqq = diff.ok - ? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message) - : { - ok: true, - attempted: false, - skipped: true, - skippedReason: diff.skippedReason ?? "no commander brief diff to send", - target: maskedTarget(claudeQqConfig), - } satisfies ClaudeQqSendResult; - return { - ok: true, - command: commandName, - repo, - issue: issueSummary(issue, { includeBody: options.raw || options.full }), - guard, - concurrency, - disclosure: issueWriteDisclosure(options, repo, issueNumber, false), - readCommands: issueBodyReadCommands(repo, issueNumber), - rest: true, - commanderBriefNotification: { - issueNumber, - oldIssueUpdatedAt: oldIssue?.updated_at ?? null, - diff: { - ok: diff.ok, - mode: diff.mode, - chars: diff.chars, - sectionCount: diff.sectionCount, - skippedReason: diff.skippedReason ?? null, - preview: diff.ok ? preview(diff.message) : "", - previewLines: diff.ok ? previewLines(diff.message) : [], - containsLiteralBackslashN: diff.message.includes("\\n"), - containsBackticks: diff.message.includes("`"), - containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message), - }, - }, - claudeqq, - }; -} - -function withIssueCommentCreateRendered(result: GitHubCommandResult): GitHubCommandResult { - return { - ...result, - contentType: "text/plain", - renderedText: renderIssueCommentCreateTable(result), - }; -} - -function renderIssueCommentCreateTable(result: GitHubCommandResult): string { - const comment = isRecord(result.comment) ? result.comment : {}; - const readCommands = isRecord(result.readCommands) ? result.readCommands : {}; - const status = result.dryRun === true ? "dry-run" : "created"; - const issueNumber = ghText(result.issueNumber); - const commentId = ghText(comment.id ?? result.commentId); - const sourceKind = isRecord(result.bodySource) ? result.bodySource.kind : result.source; - const lines = [ - `gh issue comment create (${status})`, - "", - ghTable(["ISSUE", "COMMENT", "STATUS", "BODY", "URL"], [[ - issueNumber === "-" ? "-" : `#${issueNumber}`, - commentId, - status, - `${ghText(result.bodyChars ?? comment.bodyChars)} chars`, - ghShort(ghText(comment.url ?? result.url), 96), - ]]), - "", - "Summary:", - ` repo=${ghText(result.repo)} source=${ghText(sourceKind)} bodySha=${ghText(result.bodySha ?? comment.bodySha)}`, - "", - "Next:", - ` ${ghText(readCommands.comments ?? readCommands.full ?? readCommands.raw)}`, - "", - "Disclosure:", - " default view is a bounded table; use --full/--raw or the read command for full comment metadata/body.", - ]; - return lines.join("\n"); -} - -async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { - let bodyInput: { body: string; bodySource: Record<string, unknown> }; - try { - bodyInput = readIssueCommentBody(options, "issue comment create"); - } catch (error) { - return validationError("issue comment create", repo, error instanceof Error ? error.message : String(error), { issueNumber }); - } - const { body, bodySource } = bodyInput; - if (options.dryRun) { - return withIssueCommentCreateRendered({ - ok: true, - command: "issue comment create", - repo, - dryRun: true, - planned: true, - issueNumber, - readCommands: issueCommentReadCommands(repo, issueNumber), - ...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }), - }); - } - const { owner, name } = repoParts(repo); - const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); - if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber }); - return withIssueCommentCreateRendered({ - ok: true, - command: "issue comment create", - repo, - issueNumber, - comment: compactCommentSummary(comment), - bodySource, - bodyChars: body.length, - bodySha: bodySha(body), - bodyPreview: preview(body), - source: String(bodySource.kind ?? "unknown"), - readCommands: issueCommentReadCommands(repo, issueNumber), - rest: true, - }); -} - -async function commentPatch(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, options: GitHubOptions, commandName?: string): Promise<GitHubCommandResult> { - const command = commandName ?? `${ownerKind} comment patch`; - const concurrencyOptionError = assertConcurrencyOptions(options, command); - if (concurrencyOptionError !== null) return concurrencyOptionError; - let patchInput: { patch: string; patchSource: Record<string, unknown> }; - try { - patchInput = readBodyPatch(options, command); - } catch (error) { - return validationError(command, repo, error instanceof Error ? error.message : String(error), { commentId }); - } - const { patch, patchSource } = patchInput; - const readCommands = ownerKind === "issue" - ? { - comments: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --json comments`, - full: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --full`, - note: "GitHub comment patch targets commentId directly; use the owning issue number to read the surrounding comments.", - } - : { - comments: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --json comments`, - full: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --full`, - note: "GitHub PR comments are issue comments; use the owning PR number through gh issue view to read the surrounding comments.", - }; - const oldComment = await getIssueComment(token, repo, commentId); - if (isGitHubError(oldComment)) return commandError(command, repo, oldComment, { commentId, phase: "read-before-patch" }); - const concurrencyError = validateCommentConcurrency(repo, commentId, oldComment, options, command); - if (concurrencyError !== null) return concurrencyError; - const oldBody = oldComment.body ?? ""; - let patched: { newBody: string; patchSummary: Record<string, unknown> }; - try { - patched = applyVirtualBodyPatch("comment.md", oldBody, patch); - } catch (error) { - return validationError(command, repo, "body patch failed; no GitHub write performed", { - commentId, - patchSource, - patchError: bodyPatchErrorSummary(error), - oldBody: { - bodyChars: oldBody.length, - bodySha: bodySha(oldBody), - updatedAt: oldComment.updated_at ?? null, - }, - noWrite: true, - }); - } - const bodyChange = { - oldBodyChars: oldBody.length, - newBodyChars: patched.newBody.length, - deltaChars: patched.newBody.length - oldBody.length, - oldBodySha: bodySha(oldBody), - newBodySha: bodySha(patched.newBody), - oldUpdatedAt: oldComment.updated_at ?? null, - }; - const concurrency = { - checked: true, - oldCommentUpdatedAt: oldComment.updated_at ?? null, - oldBodySha: bodySha(oldBody), - expectUpdatedAt: options.expectUpdatedAt ?? null, - expectBodySha: options.expectBodySha ?? null, - }; - if (options.dryRun) { - return { - ok: true, - command, - repo, - dryRun: true, - planned: true, - commentId, - patchSource, - patch: patched.patchSummary, - bodyPreview: preview(patched.newBody), - bodyPreviewLines: previewLines(patched.newBody, 4), - bodyChars: patched.newBody.length, - bodySha: bodySha(patched.newBody), - bodyChange, - concurrency: { ...concurrency, dryRunNoWrite: true }, - readCommands, - wouldPatch: { - commentId, - bodyChars: patched.newBody.length, - bodySha: bodySha(patched.newBody), - method: "PATCH", - path: `/repos/{owner}/{repo}/issues/comments/${commentId}`, - }, - }; - } - const { owner, name } = repoParts(repo); - const comment = await githubRequest<GitHubComment>(token, "PATCH", `/repos/${owner}/${name}/issues/comments/${commentId}`, { body: patched.newBody }); - if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId }); - return { - ok: true, - command, - repo, - commentId, - comment: compactCommentSummary(comment), - patchSource, - patch: patched.patchSummary, - bodyChange: { - ...bodyChange, - newUpdatedAt: comment.updated_at ?? null, - }, - concurrency, - readCommands, - request: { - method: "PATCH", - path: `/repos/${owner}/${name}/issues/comments/${commentId}`, - bodyChars: patched.newBody.length, - }, - rest: true, - }; -} - -async function commentUpdate(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, options: GitHubOptions, commandName?: string): Promise<GitHubCommandResult> { - const command = commandName ?? `${ownerKind} comment update`; - let bodyInput: { body: string; bodySource: Record<string, unknown> }; - try { - bodyInput = readIssueCommentBody(options, command); - } catch (error) { - return validationError(command, repo, error instanceof Error ? error.message : String(error), { commentId }); - } - const { body, bodySource } = bodyInput; - const readCommands = ownerKind === "issue" - ? { - comments: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --json comments`, - full: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --full`, - note: "GitHub comment update targets commentId directly; use the owning issue number to read the surrounding comments.", - } - : { - comments: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --json comments`, - full: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --full`, - note: "GitHub PR comments are issue comments; use the owning PR number through gh issue view to read the surrounding comments.", - }; - const writePlanCommand = ownerKind === "issue" ? "issue comment update" : "pr comment update"; - if (options.dryRun) { - return { - ok: true, - command, - repo, - dryRun: true, - planned: true, - commentId, - readCommands, - ...writeBodyPlan(writePlanCommand, repo, body, bodySource, { commentId }), - }; - } - const { owner, name } = repoParts(repo); - const comment = await githubRequest<GitHubComment>(token, "PATCH", `/repos/${owner}/${name}/issues/comments/${commentId}`, { body }); - if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId }); - return { - ok: true, - command, - repo, - commentId, - comment: compactCommentSummary(comment), - bodySource, - bodyChars: body.length, - bodySha: bodySha(body), - bodyPreview: preview(body), - source: String(bodySource.kind ?? "unknown"), - readCommands, - request: { - method: "PATCH", - path: `/repos/${owner}/${name}/issues/comments/${commentId}`, - bodyChars: body.length, - }, - rest: true, - }; -} - -async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> { - const command = `${ownerKind} comment delete`; - const { owner, name } = repoParts(repo); - if (dryRun) { - return { - ok: true, - command, - repo, - dryRun: true, - planned: true, - commentId, - request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` }, - }; - } - const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`); - if (isGitHubError(result)) return commandError(command, repo, result, { commentId }); - return { - ok: true, - command, - repo, - commentId, - deleted: true, - request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` }, - rest: true, - }; -} - -async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean, options?: GitHubOptions): Promise<GitHubCommandResult> { - const command = state === "closed" ? "issue close" : "issue reopen"; - let lifecycleComment: { body: string; bodySource: Record<string, unknown> } | null = null; - try { - lifecycleComment = options === undefined ? null : readIssueLifecycleCommentBody(options, command); - } catch (error) { - return validationError(command, repo, error instanceof Error ? error.message : String(error), { issueNumber }); - } - if (dryRun) { - return { - ok: true, - command, - dryRun: true, - repo, - issueNumber, - disclosure: issueLifecycleDisclosure(repo, issueNumber, true), - readCommands: issueBodyReadCommands(repo, issueNumber), - comment: lifecycleComment === null - ? null - : { - planned: true, - ...writeBodyPlan("issue comment create", repo, lifecycleComment.body, lifecycleComment.bodySource, { issueNumber }), - }, - wouldPatch: { state }, - }; - } - const { owner, name } = repoParts(repo); - let commentSummary: Record<string, unknown> | null = null; - if (lifecycleComment !== null) { - const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body: lifecycleComment.body }); - if (isGitHubError(comment)) { - return commandError(command, repo, comment, { - issueNumber, - phase: "comment", - commentBodySource: lifecycleComment.bodySource, - commentBodyChars: lifecycleComment.body.length, - commentBodySha: bodySha(lifecycleComment.body), - }); - } - commentSummary = compactCommentSummary(comment); - } - const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state }); - if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber, phase: "state", comment: commentSummary }); - return { - ok: true, - command, - repo, - comment: commentSummary, - 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 { - const start = Math.max(0, index - radius); - const end = Math.min(text.length, index + radius + 40); - return text.slice(start, end).replace(/\n/g, "\\n"); -} - -function lineColumnAt(text: string, index: number): { lineNumber: number; column: number } { - let lineNumber = 1; - let column = 1; - for (let cursor = 0; cursor < index && cursor < text.length; cursor += 1) { - if (text[cursor] === "\n") { - lineNumber += 1; - column = 1; - } else { - column += 1; - } - } - return { lineNumber, column }; -} - -function lineTextAt(text: string, lineNumber: number): string { - const lines = text.split(/\r?\n/); - return lines[lineNumber - 1] ?? ""; -} - -function isInsideFencedCodeBlock(text: string, index: number): boolean { - const lines = text.slice(0, index).split(/\r?\n/); - let openFence = false; - for (const line of lines) { - const trimmed = line.trimStart(); - if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) openFence = !openFence; - } - return openFence; -} - -function isInsideInlineCode(text: string, index: number): boolean { - const { lineNumber, column } = lineColumnAt(text, index); - const line = lineTextAt(text, lineNumber); - const before = line.slice(0, Math.max(0, column - 1)); - const after = line.slice(Math.max(0, column - 1)); - return (before.match(/`/g)?.length ?? 0) % 2 === 1 && (after.match(/`/g)?.length ?? 0) > 0; -} - -function isExplanatoryLiteralBackslashN(text: string, index: number): boolean { - if (isInsideFencedCodeBlock(text, index) || isInsideInlineCode(text, index)) return true; - const window = text.slice(Math.max(0, index - 90), Math.min(text.length, index + 90)).toLowerCase(); - return [ - "字面量", - "说明", - "示例", - "举例", - "提到", - "引用", - "文本", - "字符串", - "escape", - "literal", - "example", - "mention", - "quoted", - "反斜杠", - ].some((keyword) => window.includes(keyword)); -} - -function cleanupEscapedText(text: string): string { - return text - .replace(/\\r\\n/g, "\n") - .replace(/\\n/g, "\n") - .replace(/\\t/g, "\t") - .replace(/\\x0a/gi, "\n") - .replace(/\\x1b/gi, "") - .replace(/\\u001b/gi, "") - .replace(/\\033/g, "") - .replace(/\\`/g, "`"); -} - -function escapeBodyId(bodyKind: EscapeBodyKind, issueNumber: number, issueId: number, commentId?: number): string { - return bodyKind === "issue-body" - ? `issue:${issueNumber}:body:${issueId}` - : `issue:${issueNumber}:comment:${commentId ?? "unknown"}`; -} - -function textFindingPatterns(): EscapePatternDefinition[] { - return [ - { - kind: "literal-backslash-n", - pattern: /\\n/g, - description: "literal backslash-n", - }, - { - kind: "literal-backslash-t", - pattern: /\\t/g, - description: "literal backslash-t", - }, - { - kind: "escaped-backtick", - pattern: /\\`/g, - description: "escaped backtick", - }, - { - kind: "shell-escaped-newline", - pattern: /\\r\\n|\\012|\\x0a/gi, - description: "shell escaped newline", - }, - { - kind: "ansi-escape-literal", - pattern: /\\x1b|\\u001b|\\033/gi, - description: "ANSI escape literal", - }, - ]; -} - -function bodyRiskFindings(kind: EscapeBodyKind, issueNumber: number, issueId: number, body: string | null, url: string, commentId?: number): EscapeMatchFinding[] { - const bodyChars = body === null ? null : body.length; - const bodyTrimmedChars = body === null ? null : body.trim().length; - const location = { - type: kind === "issue-body" ? "issue" as const : "comment" as const, - bodyKind: kind, - bodyId: escapeBodyId(kind, issueNumber, issueId, commentId), - issueNumber, - issueId, - ...(commentId === undefined ? {} : { commentId }), - url, - bodyChars, - bodyTrimmedChars, - }; - if (body === null) { - return [{ - ...location, - kind: "null-body", - classification: "risk", - severity: "high", - reason: "GitHub returned a null body", - snippet: "", - lineNumber: 0, - column: 0, - match: "", - }]; - } - const trimmed = body.trim(); - if (trimmed.toLowerCase() === "null") { - return [{ - ...location, - kind: "literal-null-body", - classification: "risk", - severity: "high", - reason: "body text is the literal string null", - snippet: escapeSnippet(body, 0), - lineNumber: 1, - column: 1, - match: "null", - }]; - } - if (trimmed.length === 0) { - return [{ - ...location, - kind: "blank-body", - classification: "risk", - severity: "medium", - reason: "body is blank after trimming", - snippet: "", - lineNumber: 0, - column: 0, - match: "", - }]; - } - if (trimmed.length < MIN_SAFE_BODY_SCAN_CHARS) { - return [{ - ...location, - kind: "short-body", - classification: "risk", - severity: "medium", - reason: `body is shorter than the safe guard threshold (${MIN_SAFE_BODY_SCAN_CHARS} chars)`, - snippet: escapeSnippet(body, 0), - lineNumber: 1, - column: 1, - match: "", - }]; - } - return []; -} - -function scanText(text: string, patterns: EscapePatternDefinition[], issueNumber: number, issueId: number, bodyKind: EscapeBodyKind, url: string, commentId?: number): EscapeMatchFinding[] { - const findings: EscapeMatchFinding[] = []; - const counts = new Map<string, number>(); - for (const item of patterns) { - item.pattern.lastIndex = 0; - const matches = text.match(item.pattern); - counts.set(item.kind, matches === null ? 0 : matches.length); - } - for (const item of patterns) { - item.pattern.lastIndex = 0; - let match = item.pattern.exec(text); - while (match !== null) { - const { lineNumber, column } = lineColumnAt(text, match.index); - const explanatory = item.kind === "literal-backslash-n" && counts.get(item.kind) === 1 && isExplanatoryLiteralBackslashN(text, match.index); - const classification: EscapeFindingClassification = explanatory ? "explanatory-mention" : "suspected-pollution"; - const severity: EscapeFindingSeverity = explanatory ? "low" : item.kind === "ansi-escape-literal" ? "high" : "medium"; - findings.push({ - type: bodyKind === "issue-body" ? "issue" : "comment", - bodyKind, - bodyId: escapeBodyId(bodyKind, issueNumber, issueId, commentId), - issueNumber, - issueId, - ...(commentId === undefined ? {} : { commentId }), - url, - kind: item.kind, - classification, - severity, - reason: explanatory - ? "literal backslash-n appears in explanatory context" - : `matched ${item.description}`, - snippet: escapeSnippet(text, match.index), - lineNumber, - column, - match: match[0], - bodyChars: text.length, - bodyTrimmedChars: text.trim().length, - ...(classification === "suspected-pollution" - ? { cleanupPreview: { before: escapeSnippet(text, match.index), after: escapeSnippet(cleanupEscapedText(text), match.index) } } - : {}), - }); - if (findings.length >= ISSUE_SCAN_MAX_FINDINGS) return findings; - match = item.pattern.exec(text); - } - } - return findings; -} - -function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupSuggestion[] { - const groups = new Map<string, EscapeMatchFinding[]>(); - for (const finding of findings) { - if (finding.type === "comment-scan-error") continue; - if (finding.classification !== "suspected-pollution" && finding.kind !== "short-body" && finding.kind !== "null-body" && finding.kind !== "blank-body" && finding.kind !== "literal-null-body") continue; - const locatorKey = `${finding.bodyKind}:${finding.issueNumber}:${finding.issueId}:${finding.commentId ?? ""}`; - const list = groups.get(locatorKey); - if (list === undefined) groups.set(locatorKey, [finding]); - else list.push(finding); - } - - const suggestions: EscapeCleanupSuggestion[] = []; - for (const group of groups.values()) { - const first = group[0]; - const suspected = group.some((finding) => finding.classification === "suspected-pollution"); - const bodyRisk = group.some((finding) => finding.classification === "risk"); - suggestions.push({ - type: first.bodyKind, - bodyId: first.bodyId, - issueNumber: first.issueNumber, - issueId: first.issueId, - ...(first.commentId === undefined ? {} : { commentId: first.commentId }), - url: first.url, - classification: suspected ? "suspected-pollution" : "risk", - action: first.bodyKind === "issue-body" - ? (suspected ? "rewrite-issue-body-with-body-stdin" : "review-body-length") - : "review-comment-manually", - reason: suspected - ? "suspected shell escape pollution should be rewritten from heredoc/stdin" - : bodyRisk - ? "body length/null risk should be reviewed before any write" - : "no cleanup needed", - findings: group.map((finding) => ({ - kind: finding.kind, - classification: finding.classification, - severity: finding.severity, - lineNumber: finding.lineNumber, - column: finding.column, - snippet: finding.snippet, - match: finding.match, - })), - ...(first.bodyKind === "issue-body" - ? { - plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-stdin --dry-run <<'EOF'\n<cleaned body>\nEOF`, - bodyInputHint: "pass cleaned Markdown through --body-stdin with a quoted heredoc; --body-file is only for reusable files", - preview: { - before: first.cleanupPreview?.before ?? first.snippet, - after: first.cleanupPreview?.after ?? first.snippet, - }, - } - : { - preview: { - before: first.cleanupPreview?.before ?? first.snippet, - after: first.cleanupPreview?.after ?? first.snippet, - }, - }), - }); - } - return suggestions; -} - -async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise<GitHubCommandResult> { - const issues = await listIssues(token, repo, "all", limit); - if (isGitHubError(issues)) return commandError(commandName, repo, issues); - const issueOnly = issues.items; - - const patterns = textFindingPatterns(); - const findings: EscapeScanEntry[] = []; - let scannedComments = 0; - for (const issue of issueOnly) { - findings.push(...bodyRiskFindings("issue-body", issue.number, issue.id, issue.body, issue.html_url)); - findings.push(...scanText(issue.body ?? "", patterns, issue.number, issue.id, "issue-body", issue.html_url)); - const comments = await listIssueComments(token, repo, issue.number); - if (isGitHubError(comments)) { - findings.push({ - type: "comment-scan-error", - bodyKind: "comment-body", - bodyId: escapeBodyId("comment-body", issue.number, issue.id), - issueNumber: issue.number, - issueId: issue.id, - url: issue.html_url, - degradedReason: comments.degradedReason, - runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason), - details: comments, - }); - continue; - } - for (const comment of comments) { - scannedComments += 1; - findings.push(...bodyRiskFindings("comment-body", issue.number, issue.id, comment.body, comment.html_url, comment.id)); - findings.push(...scanText(comment.body ?? "", patterns, issue.number, issue.id, "comment-body", comment.html_url, comment.id)); - } - } - const cleanupSuggestions = summarizeCleanupSuggestion(findings); - const summary = findings.reduce((acc, finding) => { - if (finding.type === "comment-scan-error") { - acc.commentScanErrors += 1; - return acc; - } - if (finding.classification === "suspected-pollution") acc.suspectedPollution += 1; - if (finding.classification === "explanatory-mention") acc.explanatoryMention += 1; - if (finding.classification === "risk") acc.risk += 1; - if (finding.kind === "null-body" || finding.kind === "literal-null-body" || finding.kind === "blank-body" || finding.kind === "short-body") acc.bodyRisks += 1; - return acc; - }, { suspectedPollution: 0, explanatoryMention: 0, risk: 0, bodyRisks: 0, commentScanErrors: 0 }); - return { - ok: true, - command: commandName, - repo, - dryRun, - planned: true, - scannedIssues: issueOnly.length, - rawIssues: issues.rawCount, - pagination: issueListPaginationSummary(issues), - scannedComments, - findings, - cleanupSuggestions, - summary: { - findings: findings.length, - suggestions: cleanupSuggestions.length, - ...summary, - }, - note: dryRun - ? "Dry-run cleanup planning only; no issue or comment content was modified." - : "Read-only scan; no issue or comment content was modified.", - }; -} - -async function authStatus(repo: string): Promise<GitHubCommandResult> { - const ghPath = ghBinaryPath(); - const { token, probe } = resolveToken(ghPath !== null); - const degraded: GitHubDegradedReason[] = []; - if (ghPath === null) degraded.push("missing-binary"); - if (!probe.present || token === null) { - if (ghPath === null) { - return { - ok: false, - command: "auth status", - repo, - degradedReason: "missing-binary", - degraded, - runnerDisposition: runnerDisposition("missing-binary"), - gh: { binaryFound: false, path: null }, - token: probe, - probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" }, - }; - } - degraded.push("missing-token"); - return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), { - degraded, - gh: { binaryFound: ghPath !== null, path: ghPath }, - token: probe, - probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" }, - }); - } - - const { owner, name } = repoParts(repo); - const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit"); - if (isGitHubError(api)) { - return commandError("auth status", repo, api, { - degraded: [...degraded, api.degradedReason], - gh: { binaryFound: ghPath !== null, path: ghPath }, - token: probe, - probes: { restApi: api, repo: "skipped", issueRead: "skipped" }, - }); - } - - const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`); - if (isGitHubError(repoProbe)) { - return commandError("auth status", repo, repoProbe, { - degraded: [...degraded, repoProbe.degradedReason], - gh: { binaryFound: ghPath !== null, path: ghPath }, - token: probe, - probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" }, - }); - } - - const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`); - if (isGitHubError(issueProbe)) { - return commandError("auth status", repo, issueProbe, { - degraded: [...degraded, issueProbe.degradedReason], - gh: { binaryFound: ghPath !== null, path: ghPath }, - token: probe, - probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe }, - }); - } - - return { - ok: true, - command: "auth status", - repo, - degraded, - gh: { binaryFound: ghPath !== null, path: ghPath }, - token: probe, - probes: { - restApi: "ok", - repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, - issueRead: { ok: true, readable: true, sampleCount: issueProbe.length }, - }, - restFallback: true, - }; -} - -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 }); - const fields = jsonFields ?? PR_LIST_JSON_FIELDS.slice(); - return { - ok: true, - command: "pr list", - repo, - ...(noDump ? { noDump: true } : {}), - state, - limit, - count: prs.length, - jsonFields: fields, - pullRequests: prs.map((pr) => selectedPrJson(prSummary(pr), fields)), - }; -} - -async function prFiles(repo: string, token: string, number: number, limit: number, commandName = "pr files"): Promise<GitHubCommandResult> { - const { owner, name } = repoParts(repo); - const boundedLimit = Math.min(limit, MAX_PR_FILES_LIMIT); - const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); - if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number }); - const perPage = Math.max(1, Math.min(100, boundedLimit)); - const files: GitHubPullRequestFile[] = []; - let page = 1; - while (files.length < boundedLimit) { - const remaining = boundedLimit - files.length; - const pageSize = Math.min(perPage, remaining); - const path = `/repos/${owner}/${name}/pulls/${number}/files?per_page=${pageSize}&page=${page}`; - const pageFiles = await githubRequest<GitHubPullRequestFile[]>(token, "GET", path); - if (isGitHubError(pageFiles)) return commandError(commandName, repo, pageFiles, { number, phase: "fetch-pr-files", filesReturned: files.length }); - files.push(...pageFiles); - if (pageFiles.length < pageSize || pageFiles.length === 0) break; - page += 1; - } - const totalFiles = numberOrNull(pr.changed_files); - const listedStats = sumPrFileStats(files); - const totalAdditions = numberOrNull(pr.additions); - const totalDeletions = numberOrNull(pr.deletions); - const fullStatsAvailable = totalAdditions !== null && totalDeletions !== null; - const totalChanges = fullStatsAvailable ? totalAdditions + totalDeletions : listedStats.changes; - const truncated = totalFiles !== null ? files.length < totalFiles : files.length >= boundedLimit; - const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT); - const nextCommand = truncated - ? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}` - : `bun scripts/cli.ts gh pr view ${number} --repo ${repo} --json body,title,state,head,base`; - return { - ok: true, - command: commandName, - repo, - readOnly: true, - rawDiffIncluded: false, - pullRequest: prCompactSummary(pr), - summary: { - files: totalFiles ?? files.length, - additions: totalAdditions ?? listedStats.additions, - deletions: totalDeletions ?? listedStats.deletions, - changes: totalChanges, - commits: numberOrNull(pr.commits), - source: fullStatsAvailable && totalFiles !== null ? "pull-request-rest" : "listed-files-rest", - }, - files: files.map(prFileSummary), - filesReturned: files.length, - limit: boundedLimit, - truncation: { - truncated, - requestedLimit: limit, - appliedLimit: boundedLimit, - returned: files.length, - totalFiles, - maxLimit: MAX_PR_FILES_LIMIT, - }, - next: { - command: nextCommand, - purpose: truncated ? "Fetch a larger bounded file summary page." : "Fetch full PR metadata/body; raw diffs remain intentionally excluded.", - }, - request: { - method: "GET", - paths: [ - `/repos/${owner}/${name}/pulls/${number}`, - `/repos/${owner}/${name}/pulls/${number}/files`, - ], - }, - }; -} - -async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> { - const { owner, name } = repoParts(repo); - const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); - if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number }); - const fullSummary = prSummary(pr); - const includeBody = jsonFields === undefined || jsonFields.includes("body"); - const summary = includeBody ? fullSummary : prSummary(pr, { includeBody: false, includePreview: false }); - const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null; - if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) }); - const selectionSummary = metadata === null ? fullSummary : { ...fullSummary, ...prMetadataSummary(metadata) }; - return { - ok: true, - command: commandName, - repo, - ...(disclosure === null ? {} : { disclosure }), - pullRequest: summary, - ...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }), - ...(jsonFields === undefined ? {} : { - jsonFields, - json: selectedPrJson(selectionSummary, jsonFields), - compatibility: { - legacyJsonBodyPath: includeBody ? ".data.pullRequest.body" : null, - bodyOmitted: !includeBody, - }, - }), - }; -} - -async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> { - return prRead(repo, token, number, jsonFields, "pr view", disclosure); -} - -export function ghHelp(): unknown { - return { - command: "gh", - output: "json", - usage: [ - "bun scripts/cli.ts gh auth status [--repo owner/name]", - "bun scripts/cli.ts gh repo view <owner/repo>|--repo owner/name", - "bun scripts/cli.ts gh repo create <owner/repo>|--repo owner/name [--private|--public] [--description text] [--auto-init] [--dry-run]", - "bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--title-prefix 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 view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments,commentCount] [--raw|--full]", - "bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]", - "bun scripts/cli.ts gh issue attachment list <number|url|owner/repo#number> [--repo owner/name] [--number N compat]", - "bun scripts/cli.ts gh issue attachment download <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--attachment index|assetId|url] [--output path] [--dry-run]", - "bun scripts/cli.ts gh issue create --title <title> (--body-stdin|--body-file <file|->) [--label label[,label...]]... [--repo owner/name] [--dry-run]", - "bun scripts/cli.ts gh issue update <number> --mode replace|append (--body-stdin|--body-file <file|->) [--title title] [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", - "bun scripts/cli.ts gh issue patch <number|url|owner/repo#number> --body-patch-stdin [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", - "bun scripts/cli.ts gh issue edit <number> (--body-stdin|--body-file <file|->) [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]", - "bun scripts/cli.ts gh issue edit 24 --body-stdin --notify-claudeqq-brief-diff [--dry-run]", - "bun scripts/cli.ts gh issue comment create <number> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh issue comment update <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh issue comment patch <commentId> --body-patch-stdin [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue comment edit <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for issue comment update]", - "bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-stdin|--comment-file <file|->] [--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]", - "bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]", - "bun scripts/cli.ts gh issue board-row list [--repo owner/name] --board-issue 20 [--state open|closed|all] [--dry-run]", - "bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20", - "bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]", - "bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", - "bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [compatibility alias for gh pr preflight]", - "bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]", - "bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]", - "bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]", - "bun scripts/cli.ts gh pr view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]", - "bun scripts/cli.ts gh pr read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for pr view]", - "bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options]", - "bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]", - "bun scripts/cli.ts gh pr create --title <title> (--body-stdin|--body-file <file|->|--body <text>) --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]", - "bun scripts/cli.ts gh pr edit <number> [--title title] [--body-stdin|--body-file <file|->|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-stdin|--body-file <file|->|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh pr comment create <number> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh pr comment update <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh pr comment edit <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]", - "bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]", - "bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]", - "bun scripts/cli.ts gh pr delete <number> [unsupported: use close]", - ], - defaults: { repo: DEFAULT_REPO }, - notes: [ - "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.", - "repo view/create use GitHub REST through the same token path. repo create defaults to private repositories, preflights existing repos, supports --dry-run, and refuses duplicate creation.", - "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. --title-prefix filters the bounded listed issues locally by exact title startsWith, useful for [FEEDBACK] dedupe, and reports titleFilter input/output counts. 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 view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection; explicit --json fields limit output even with --raw/--full; full body is included only when requested with --json body or when --json is omitted and --full/--raw requests all fields. Unsupported fields fail structurally.", - "issue attachment list/download scan issue body and comments for GitHub user attachment URLs (`https://github.com/user-attachments/assets/...`). list is read-only and returns bounded attachment metadata. download writes the selected attachment to --output or /tmp/unidesk-gh-attachments, returns bytes/SHA-256/content-type/path, redacts redirected signed URL query parameters, and never prints binary bytes.", - "--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch and gh pr list/read/view. For read/view commands, an explicit --json field list narrows the disclosure and prevents accidental full body/comment expansion. 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.", - "CLI output larger than config/unidesk-cli.yaml output.maxStdoutBytes is automatically written to /tmp/unidesk-cli-output; stdout stays bounded with outputTruncated=true, warning text, dump file metadata, and drill-down read commands.", - "issue create accepts --body-stdin or --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-stdin is the first-class heredoc/stdin source for Markdown bodies. Use quoted heredoc syntax such as bun scripts/cli.ts gh issue comment create 1 --body-stdin <<'EOF' so real newlines, backticks, and tables are read as stdin bytes instead of shell arguments.", - "update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.", - "issue edit is a compatibility alias for issue update --mode replace.", - "issue update accepts --body-stdin or --body-file <file|->, 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 patch reads the current GitHub issue body, applies a Codex apply_patch envelope against virtual file issue.md from --body-patch-stdin or --body-patch-file <file|->, then runs the same issue body guard before PATCH. It returns old/new bodySha, updatedAt, patch summary, and bounded previews; context mismatch fails with redacted diagnostics and no GitHub write.", - "issue comment create/update/edit accept --body-stdin or --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. Use comment update/edit to correct existing wording in place; delete remains for intentional removal.", - "issue comment patch reads the current issue comment by commentId, applies a Codex apply_patch envelope against virtual file comment.md, then PATCHes only that comment. It returns comment id, old/new bodySha, updatedAt, patch summary, and redacted mismatch diagnostics without echoing the full comment body.", - "issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text>, --comment-stdin, or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-stdin is the first-class heredoc path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view 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, prefer quoted heredoc stdin: bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin <<'EOF' ... EOF or gh issue comment create <number> --body-stdin <<'EOF' ... EOF. --body-file <file|-> remains available for reusable files and pipes.", - "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-stdin for heredoc/stdin or --body-file <file|-> for reusable files.", - "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.", - "issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It reads only the board issue body, returns body size/SHA and parsed Markdown board sections, and no longer validates GitHub open/closed issue coverage against OPEN/CLOSED tables. The legacy coverage fields remain present as empty arrays/zero counts for compatibility.", - "issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.", - "issue board-row upsert updates an existing row when the issue is already present, or generates a complete row in --section open|closed when missing. It returns operation=update or operation=add, defaults to dry-run, requires --expect-body-sha or --expect-updated-at before PATCH, and refuses section migration; use board-row move for OPEN/CLOSED migration.", - "issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation, and the shared #20 guard warns about HWLAB product/user issue routing in favor of pikasTech/HWLAB without refusing the write.", - "issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.", - "Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.", - "comment update/edit PATCHes /repos/{owner}/{repo}/issues/comments/{comment_id} and preserves the comment id/timeline; comment delete is supported because GitHub supports deleting issue comments, but routine wording fixes should use update/edit. issue/pr hard delete is unsupported and close is the lifecycle alternative.", - "PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.", - "PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.", - "PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment update/edit/delete treat --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; explicit --json fields limit output even with --raw/--full. Mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --json is omitted and --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.", - "PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.", - "PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.", - "PR preflight is a low-noise read-only closeout helper for explicit diagnosis only. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. It is not a required step before gh pr merge. Use --full or --raw to include all fetched status contexts.", - "PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Use --dry-run to see the exact merge plan without writing.", - ], - }; -} - -function isGhHelpRequest(args: string[]): boolean { - if (args.length === 0) return true; - if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") return true; - if (args.includes("--help") || args.includes("-h")) return true; - const cleaned = args.filter((arg) => arg !== "--help" && arg !== "-h"); - return positionalArgs(cleaned).includes("help"); -} - -function ghHelpCommandTokens(args: string[]): string[] { - const cleaned = args.filter((arg) => arg !== "--help" && arg !== "-h"); - const positionals = positionalArgs(cleaned); - if (positionals[0] === "help") positionals.shift(); - if (positionals[positionals.length - 1] === "help") positionals.pop(); - return positionals; -} - -function ghHelpUsageLines(): string[] { - const help = ghHelp() as { usage?: unknown }; - if (!Array.isArray(help.usage)) return []; - return help.usage.filter((line): line is string => typeof line === "string"); -} - -function ghUsageCommandTokens(usageLine: string): string[] { - const prefix = "bun scripts/cli.ts gh "; - if (!usageLine.startsWith(prefix)) return []; - const tokens: string[] = []; - for (const token of usageLine.slice(prefix.length).split(/\s+/u)) { - if (token.length === 0) continue; - if (token.startsWith("[") || token.startsWith("<") || token.startsWith("--")) break; - tokens.push(token); - } - return tokens; -} - -function ghUsageMatchesCommandTokens(usageLine: string, requestedTokens: string[]): boolean { - const usageTokens = ghUsageCommandTokens(usageLine); - if (requestedTokens.length === 0 || requestedTokens.length > usageTokens.length) return false; - return requestedTokens.every((requested, index) => usageTokens[index]?.split("|").includes(requested) === true); -} - -function ghScopedHelpNotes(tokens: string[]): string[] { - const key = tokens.join(" "); - const notes = [ - "Scoped help is bounded to the requested command or command group; use `bun scripts/cli.ts gh --help` for the full top-level command index.", - ]; - if (key === "issue comment" || key.startsWith("issue comment ")) { - notes.push("Issue comments use `--body-stdin` or `--body-file <file|->` for Markdown bodies; inline `--body` is only for short single-line comments."); - notes.push("Use `issue comment update/edit` for wording fixes, `issue comment patch` for apply_patch-style local edits, and `issue comment delete` only for intentional removal."); - } else if (key === "issue close" || key === "issue reopen") { - notes.push("Issue close/reopen can post a lifecycle comment with `--comment`, `--comment-stdin`, or `--comment-file <file|->` before changing state."); - notes.push("For long closeout evidence, prefer `--comment-stdin` with a quoted heredoc."); - } else if (key === "pr comment" || key.startsWith("pr comment ")) { - notes.push("PR comments are GitHub issue comments under the hood; use comment id targets for update/edit/delete."); - } else if (key === "pr merge") { - notes.push("PR merge is one-command guarded: it performs the readiness check itself; `gh pr preflight` is optional read-only diagnosis, not a required first step."); - notes.push("When GitHub reports mergeability as UNKNOWN/null, merge automatically retries with YAML-configured exponential backoff and shows retry attempts as N/M."); - } - return notes; -} - -export function ghScopedHelp(args: string[]): unknown | null { - if (!isGhHelpRequest(args)) return null; - const requestedTokens = ghHelpCommandTokens(args); - if (requestedTokens.length === 0) return ghHelp(); - const usageLines = ghHelpUsageLines(); - for (let length = requestedTokens.length; length >= 1; length -= 1) { - const scopedTokens = requestedTokens.slice(0, length); - const usage = usageLines.filter((line) => ghUsageMatchesCommandTokens(line, scopedTokens)); - if (usage.length === 0) continue; - return { - command: `gh ${scopedTokens.join(" ")}`, - output: "json", - scoped: true, - requestedCommand: requestedTokens.join(" "), - matchedCommand: scopedTokens.join(" "), - usage, - notes: ghScopedHelpNotes(scopedTokens), - fullHelpCommand: "bun scripts/cli.ts gh --help", - }; - } - return { - command: `gh ${requestedTokens.join(" ")}`, - output: "json", - scoped: true, - requestedCommand: requestedTokens.join(" "), - usage: [], - message: "No scoped help entry matched this gh command; use the top-level help for the full command index.", - fullHelpCommand: "bun scripts/cli.ts gh --help", - }; -} - -export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> { - const scopedHelp = ghScopedHelp(args); - if (scopedHelp !== null) return scopedHelp; - const [top, sub, third] = args; - if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp(); - let options: GitHubOptions; - try { - options = parseOptions(args); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - const repo = optionValue(args, "--repo") ?? DEFAULT_REPO; - const unknownOption = /^unknown gh option:\s+(.+)$/u.exec(message)?.[1]; - return unknownOption !== undefined - ? unsupportedCommand(command, repo, message, unknownGhOptionDetails(args, unknownOption)) - : validationError(command, repo, message); - } - if (options.notifyClaudeQqBriefDiff && !(top === "issue" && sub === "edit")) { - 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 (optionWasProvided(args, "--state") && !(top === "issue" && (sub === "list" || sub === "board-row" && third === "list") || top === "pr" && sub === "list")) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--state is only supported by gh issue list, gh issue board-row list, and gh pr list"); - } - if (optionWasProvided(args, "--json") && !(top === "issue" && (isIssueReadCommand(sub) || sub === "list"))) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - if (!(top === "pr" && (isPrReadCommand(sub) || sub === "list"))) { - 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 === "list" || sub === "update" || sub === "edit" || sub === "patch")) || 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 list/read/view/update/edit/patch, 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 view owner/name#<number> --raw", - "bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state,comments", - "bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin --full <<'EOF'\n<reviewed body>\nEOF", - "bun scripts/cli.ts gh issue patch <number> --repo owner/name --body-patch-stdin --full <<'PATCH'\n*** Begin Patch\n*** Update File: issue.md\n@@\n-old\n+new\n*** End Patch\nPATCH", - "bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full", - "bun scripts/cli.ts gh pr view owner/name#<number> --raw", - `bun scripts/cli.ts gh pr view <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`, - "bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full", - ], - }); - } - if (optionWasProvided(args, "--attachment") && !(top === "issue" && sub === "attachment" && third === "download")) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--attachment is only supported by gh issue attachment download"); - } - if (optionWasProvided(args, "--output") && !(top === "issue" && sub === "attachment" && third === "download")) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--output is only supported by gh issue attachment download"); - } - if (optionWasProvided(args, "--stat") && !(top === "pr" && (sub === "diff" || sub === "files"))) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--stat is only supported by gh pr diff <number> --stat.", { - supportedCommands: [ - "bun scripts/cli.ts gh pr files <number> --repo owner/name --limit 30", - "bun scripts/cli.ts gh pr diff <number> --stat --repo owner/name --limit 30", - ], - }); - } - if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view"; - return validationError(command, options.repo, `--number is only a compatibility alias for single numeric target commands; standard ${standardViewCommand} uses a positional number or URL target.`, { - supportedCommands: [ - "bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state", - "bun scripts/cli.ts gh issue view https://github.com/owner/name/issues/<number> --raw", - "bun scripts/cli.ts gh pr view <number> --repo owner/name --json body,title,state,head,base", - "bun scripts/cli.ts gh pr view https://github.com/owner/name/pull/<number> --raw", - ], - rejectedOption: "--number", - }); - } - if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && (sub === "update" || sub === "edit")))) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--mode is only supported by gh issue update/edit and gh pr update/edit"); - } - const issueBodyGuardCommand = top === "issue" && (sub === "edit" || sub === "update" || sub === "patch" || sub === "board-row" && ["update", "add", "move", "delete", "upsert"].includes(third ?? "")); - const issueCommentPatchCommand = top === "issue" && sub === "comment" && third === "patch"; - if ((options.allowShortBody || optionWasProvided(args, "--body-profile")) && !issueBodyGuardCommand) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--allow-short-body and --body-profile are only supported by gh issue update/edit/patch and gh issue board-row update/add/move/delete/upsert"); - } - if ((options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined) && !(issueBodyGuardCommand || issueCommentPatchCommand)) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--expect-updated-at and --expect-body-sha are only supported by gh issue update/edit/patch, gh issue comment patch, and gh issue board-row update/add/move/delete/upsert"); - } - if ((optionWasProvided(args, "--body-patch-file") || optionWasProvided(args, "--body-patch-stdin")) && !(top === "issue" && (sub === "patch" || sub === "comment" && third === "patch"))) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--body-patch-file and --body-patch-stdin are only supported by gh issue patch and gh issue comment patch", { - supportedCommands: [ - "bun scripts/cli.ts gh issue patch <number> --repo owner/name --body-patch-stdin", - "bun scripts/cli.ts gh issue comment patch <commentId> --repo owner/name --body-patch-stdin", - ], - }); - } - if ((optionWasProvided(args, "--board-issue") || optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && (sub === "board-audit" || sub === "board-row"))) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--board-issue, --known-meta-issue, and --ignore-issue are only supported by gh issue board-audit and --board-issue by gh issue board-row"); - } - if ((optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && sub === "board-audit")) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--known-meta-issue and --ignore-issue are only supported by gh issue board-audit"); - } - if ((optionWasProvided(args, "--field") || optionWasProvided(args, "--value")) && !(top === "issue" && sub === "board-row" && third === "update")) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--field and --value are only supported by gh issue board-row update"); - } - if (optionWasProvided(args, "--row-file") && !(top === "issue" && sub === "board-row" && third === "add")) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--row-file is only supported by gh issue board-row add"); - } - if (optionWasProvided(args, "--section") && !(top === "issue" && sub === "board-row" && (third === "add" || third === "upsert"))) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--section is only supported by gh issue board-row add/upsert"); - } - if (optionWasProvided(args, "--to") && !(top === "issue" && sub === "board-row" && third === "move")) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--to is only supported by gh issue board-row move"); - } - if (optionWasProvided(args, "--status") && !(top === "issue" && sub === "board-row" && (third === "move" || third === "upsert"))) { - const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--status is only supported by gh issue board-row move/upsert"); - } - if ((optionWasProvided(args, "--category") || optionWasProvided(args, "--branch") || optionWasProvided(args, "--tasks") || optionWasProvided(args, "--summary") || optionWasProvided(args, "--focus") || optionWasProvided(args, "--validation") || optionWasProvided(args, "--progress")) && !(top === "issue" && sub === "board-row" && third === "upsert")) { - 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" || 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, 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, "--title-prefix") && !(top === "issue" && sub === "list")) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--title-prefix 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 (optionWasProvided(args, "--comment") && !(top === "issue" && (sub === "close" || sub === "reopen"))) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--comment/--comment-file is only supported by gh issue close/reopen; use gh issue comment create for standalone comments"); - } - if ((optionWasProvided(args, "--description") || optionWasProvided(args, "--private") || optionWasProvided(args, "--public") || optionWasProvided(args, "--auto-init")) && !(top === "repo" && sub === "create")) { - const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; - return validationError(command, options.repo, "--description, --private, --public, and --auto-init are only supported by gh repo create"); - } - - if (top === "auth" && sub === "status") return authStatus(options.repo); - - if (top === "repo") { - if (sub !== "view" && sub !== "create") { - return unsupportedCommand(`repo ${sub ?? ""}`.trim(), options.repo, "repo supported commands are view and create.", { - supportedCommands: [ - `bun scripts/cli.ts gh repo view ${options.repo}`, - `bun scripts/cli.ts gh repo create ${options.repo} --private --dry-run`, - ], - }); - } - if (sub === "create" && options.dryRun) return repoCreate(options.repo, "", options); - const { token, probe } = resolveToken(true); - const commandName = `repo ${sub}`; - const missing = authRequired(options.repo, commandName, probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - if (sub === "view") return repoView(options.repo, token); - return repoCreate(options.repo, token, options); - } - - if (top === "preflight") { - const resolved = resolvePositionalPrReference(args, 1, "preflight", options); - if (isGitHubCommandResult(resolved)) return resolved; - return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw), resolved); - } - - if (top === "issue") { - if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics."); - if (sub === "attachment") { - const action = third; - const commandName = `issue attachment ${action ?? ""}`.trim(); - if (action !== "list" && action !== "download") { - return unsupportedCommand(commandName, options.repo, "issue attachment supported commands are list and download.", { - supportedCommands: [ - "bun scripts/cli.ts gh issue attachment list <number> --repo owner/name", - "bun scripts/cli.ts gh issue attachment download <number> --repo owner/name --attachment 1 --output /tmp/issue-attachment.png", - ], - }); - } - const resolved = resolvePositionalIssueReference(args, 3, commandName, options); - if (isGitHubCommandResult(resolved)) return resolved; - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, commandName, probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - if (action === "list") return withNumberOptionHint(issueAttachmentList(resolved.repo, token, resolved.number), resolved); - return withNumberOptionHint(issueAttachmentDownload(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); - } - if (sub === "comment" && third === "delete") { - const resolved = resolvePositionalNumberReference("issue", args, 3, "issue comment delete", options); - if (isGitHubCommandResult(resolved)) return resolved; - const commentId = resolved.number; - if (typeof commentId !== "number") return commentId; - if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "issue", commentId, true), resolved); - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, "issue comment delete", probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(commentDelete(resolved.repo, token, "issue", commentId, false), resolved); - } - if (sub === "comment" && third === "patch") { - const commandName = "issue comment patch"; - const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options); - if (isGitHubCommandResult(resolved)) return resolved; - const commentId = resolved.number; - if (typeof commentId !== "number") return commentId; - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, commandName, probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(commentPatch(resolved.repo, token, "issue", commentId, { ...options, repo: resolved.repo }, commandName), resolved); - } - if (sub === "comment" && (third === "update" || third === "edit")) { - const commandName = `issue comment ${third}`; - const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options); - if (isGitHubCommandResult(resolved)) return resolved; - const commentId = resolved.number; - if (typeof commentId !== "number") return commentId; - const updateOptions = { ...options, repo: resolved.repo }; - if (options.dryRun) return withNumberOptionHint(commentUpdate(resolved.repo, "", "issue", commentId, updateOptions, commandName), resolved); - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, commandName, probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(commentUpdate(resolved.repo, token, "issue", commentId, updateOptions, commandName), resolved); - } - if (sub === "comment" && third === "create") { - const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options); - if (isGitHubCommandResult(resolved)) return resolved; - const issueNumber = resolved.number; - if (typeof issueNumber !== "number") return issueNumber; - if (options.dryRun) return withNumberOptionHint(issueComment(resolved.repo, "", issueNumber, { ...options, repo: resolved.repo }), resolved); - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, "issue comment create", probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(issueComment(resolved.repo, token, issueNumber, { ...options, repo: resolved.repo }), resolved); - } - if (sub === "scan-escape" || sub === "cleanup-plan") { - const { token, probe } = resolveToken(true); - const commandName = sub === "cleanup-plan" ? "issue cleanup-plan" : "issue scan-escape"; - const missing = authRequired(options.repo, commandName, probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - return issueScanEscape(options.repo, token, options.limit, options.dryRun || sub === "cleanup-plan", commandName); - } - if (sub === "board-audit") { - const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, "issue board-audit", probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue board-audit", { present: false, source: null, ghFallbackAttempted: true }); - return issueBoardAudit(options.repo, token, options); - } - if (sub === "board-row") { - const action = third; - const commandName = `issue board-row ${action ?? ""}`.trim(); - if (action !== "list" && action !== "get" && action !== "update" && action !== "add" && action !== "move" && action !== "delete" && action !== "upsert") { - return unsupportedCommand(commandName, options.repo, "board-row supported commands are list, get, update, add, move, delete, and upsert."); - } - const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, commandName, probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - if (action === "list") return issueBoardRowList(options.repo, token, options); - const resolvedBoardRow = resolvePositionalIssueReference(args, 3, commandName, options); - if (isGitHubCommandResult(resolvedBoardRow)) return resolvedBoardRow; - const issueNumber = resolvedBoardRow.number; - if (typeof issueNumber !== "number") return issueNumber; - const boardRowOptions = { ...options, repo: resolvedBoardRow.repo }; - if (action === "get") return withNumberOptionHint(issueBoardRowGet(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); - if (action === "update") return withNumberOptionHint(issueBoardRowUpdate(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); - if (action === "add") return withNumberOptionHint(issueBoardRowAdd(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); - if (action === "upsert") return withNumberOptionHint(issueBoardRowUpsert(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); - if (action === "move") return withNumberOptionHint(issueBoardRowMove(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); - return withNumberOptionHint(issueBoardRowDelete(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); - } - if (sub === "patch") { - const resolved = resolvePositionalIssueReference(args, 2, "issue patch", options); - if (isGitHubCommandResult(resolved)) return resolved; - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, "issue patch", probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue patch", { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(issuePatch(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); - } - if (options.dryRun) { - if (sub === "create") return issueCreate(options.repo, "", options); - if (sub === "edit") { - const issueEditRef = resolvePositionalIssueReference(args, 2, "issue edit", options); - if (isGitHubCommandResult(issueEditRef)) return issueEditRef; - const issueNumber = issueEditRef.number; - const { token } = resolveToken(false); - return withNumberOptionHint(issueEdit(issueEditRef.repo, token ?? "", issueNumber, { ...options, repo: issueEditRef.repo }), issueEditRef); - } - if (sub === "update") { - const issueUpdateRef = resolvePositionalIssueReference(args, 2, "issue update", options); - if (isGitHubCommandResult(issueUpdateRef)) return issueUpdateRef; - const issueNumber = issueUpdateRef.number; - const { token } = resolveToken(false); - return withNumberOptionHint(issueEdit(issueUpdateRef.repo, token ?? "", issueNumber, { ...options, repo: issueUpdateRef.repo }, "issue update"), issueUpdateRef); - } - if (sub === "comment") { - const r = resolvePositionalIssueReference(args, 2, "issue comment", options); - if (isGitHubCommandResult(r)) return r; - return withNumberOptionHint(issueComment(r.repo, "", r.number, { ...options, repo: r.repo }), r); - } - if (sub === "close") { - const r = resolvePositionalIssueReference(args, 2, "issue close", options); - if (isGitHubCommandResult(r)) return r; - return withNumberOptionHint(issueState(r.repo, "", r.number, "closed", true, { ...options, repo: r.repo }), r); - } - if (sub === "reopen") { - const r = resolvePositionalIssueReference(args, 2, "issue reopen", options); - if (isGitHubCommandResult(r)) return r; - return withNumberOptionHint(issueState(r.repo, "", r.number, "open", true, { ...options, repo: r.repo }), r); - } - } - if (sub === "read" || sub === "view") { - const resolved = resolveReadViewNumberReference("issue", sub, third, options, args); - if (isGitHubCommandResult(resolved)) return resolved; - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, `issue ${sub}`, probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); - const disclosure = readDisclosureOptions(options, resolved.shorthand); - if (sub === "read") return withNumberOptionHint(issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure, { includeFullCommentBodies: options.raw || options.full }), resolved); - return withNumberOptionHint(issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure, { includeFullCommentBodies: options.raw || options.full }), resolved); - } - const { token, probe } = resolveToken(true); - 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, options.labels, options.raw || options.full, options.titlePrefix); - if (sub === "stale-close") return issueStaleClose(options.repo, token, options); - if (sub === "create") return issueCreate(options.repo, token, options); - if (sub === "edit") { - const r = resolvePositionalIssueReference(args, 2, "issue edit", options); - if (isGitHubCommandResult(r)) return r; - return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }), r); - } - if (sub === "update") { - const r = resolvePositionalIssueReference(args, 2, "issue update", options); - if (isGitHubCommandResult(r)) return r; - return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }, "issue update"), r); - } - if (sub === "comment") { - const r = resolvePositionalIssueReference(args, 2, "issue comment", options); - if (isGitHubCommandResult(r)) return r; - return withNumberOptionHint(issueComment(r.repo, token, r.number, { ...options, repo: r.repo }), r); - } - if (sub === "close") { - const r = resolvePositionalIssueReference(args, 2, "issue close", options); - if (isGitHubCommandResult(r)) return r; - return withNumberOptionHint(issueState(r.repo, token, r.number, "closed", options.dryRun, { ...options, repo: r.repo }), r); - } - if (sub === "reopen") { - const r = resolvePositionalIssueReference(args, 2, "issue reopen", options); - if (isGitHubCommandResult(r)) return r; - return withNumberOptionHint(issueState(r.repo, token, r.number, "open", options.dryRun, { ...options, repo: r.repo }), r); - } - } - - if (top === "pr") { - if (sub === "diff") { - const resolved = resolvePositionalPrReference(args, 2, "pr diff", options); - if (isGitHubCommandResult(resolved)) return resolved; - if (!optionWasProvided(args, "--stat")) { - return unsupportedCommand("pr diff", options.repo, "Raw PR diff output is intentionally unsupported by UniDesk CLI; use gh pr diff <number> --stat or gh pr files for a bounded REST file/stat summary.", { - rawDiffIncluded: false, - supportedCommands: [ - `bun scripts/cli.ts gh pr files ${resolved.number} --repo ${resolved.repo} --limit 30`, - `bun scripts/cli.ts gh pr diff ${resolved.number} --stat --repo ${resolved.repo} --limit 30`, - ], - }); - } - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, "pr diff --stat", probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"), resolved); - } - if (sub === "files") { - const resolved = resolvePositionalPrReference(args, 2, "pr files", options); - if (isGitHubCommandResult(resolved)) return resolved; - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, "pr files", probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr files"), resolved); - } - if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics."); - if (sub === "preflight" || sub === "closeout") { - const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options); - if (isGitHubCommandResult(resolved)) return resolved; - return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw), resolved); - } - if (sub === "comment" && third === "delete") { - const resolved = resolvePositionalNumberReference("pr", args, 3, "pr comment delete", options); - if (isGitHubCommandResult(resolved)) return resolved; - if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "pr", resolved.number, true), resolved); - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, "pr comment delete", probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(commentDelete(resolved.repo, token, "pr", resolved.number, false), resolved); - } - if (sub === "comment" && (third === "update" || third === "edit")) { - const commandName = `pr comment ${third}`; - const resolved = resolvePositionalNumberReference("pr", args, 3, commandName, options); - if (isGitHubCommandResult(resolved)) return resolved; - const updateOptions = { ...options, repo: resolved.repo }; - if (options.dryRun) return withNumberOptionHint(commentUpdate(resolved.repo, "", "pr", resolved.number, updateOptions, commandName), resolved); - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, commandName, probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(commentUpdate(resolved.repo, token, "pr", resolved.number, updateOptions, commandName), resolved); - } - if (sub === "comment" && third === "create") { - const resolved = resolvePositionalPrReference(args, 3, "pr comment create", options); - if (isGitHubCommandResult(resolved)) return resolved; - if (options.dryRun) return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved); - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, "pr comment create", probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); - } - if (sub === "create") { - if (options.dryRun) return prCreate(options.repo, "", options); - const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, "pr create", probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr create", { present: false, source: null, ghFallbackAttempted: true }); - return prCreate(options.repo, token, options); - } - if (sub === "comment") { - if (options.dryRun) { - const resolved = resolvePositionalPrReference(args, 2, "pr comment", options); - if (isGitHubCommandResult(resolved)) return resolved; - return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved); - } - const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, "pr comment", probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true }); - const resolved = resolvePositionalPrReference(args, 2, "pr comment", options); - if (isGitHubCommandResult(resolved)) return resolved; - return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); - } - if (sub === "update" || sub === "edit") { - const commandName = `pr ${sub}`; - const resolved = resolvePositionalPrReference(args, 2, commandName, options); - if (isGitHubCommandResult(resolved)) return resolved; - if (options.dryRun && options.mode === "replace") return withNumberOptionHint(prUpdate(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }, commandName), resolved); - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, commandName, probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(prUpdate(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }, commandName), resolved); - } - if (sub === "close" || sub === "reopen") { - const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options); - if (isGitHubCommandResult(resolved)) return resolved; - if (options.dryRun) return withNumberOptionHint(prState(resolved.repo, "", resolved.number, sub === "close" ? "closed" : "open", true), resolved); - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, `pr ${sub}`, probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(prState(resolved.repo, token, resolved.number, sub === "close" ? "closed" : "open", false), resolved); - } - if (sub === "merge") { - const resolved = resolvePositionalPrReference(args, 2, "pr merge", options); - if (isGitHubCommandResult(resolved)) return resolved; - const { token, probe } = resolveToken(true); - const missing = authRequired(options.repo, "pr merge", probe); - if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr merge", { present: false, source: null, ghFallbackAttempted: true }); - return withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved); - } - if (sub !== "list" && !isPrReadCommand(sub)) { - return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/update/edit/delete, and unsupported delete."); - } - if (sub === "read" || sub === "view") { - const resolved = resolveReadViewNumberReference("pr", sub, third, options, args); - if (isGitHubCommandResult(resolved)) return resolved; - const { token, probe } = resolveToken(true); - const missing = authRequired(resolved.repo, `pr ${sub}`, probe); - if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); - const disclosure = readDisclosureOptions(options, resolved.shorthand); - if (sub === "read") return withNumberOptionHint(prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure), resolved); - return withNumberOptionHint(prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure), resolved); - } - 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, options.raw || options.full); - } - - return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() }); -} +export { commanderBriefDiff } from "./gh/body-profiles"; +export { ghHelp, ghScopedHelp } from "./gh/help"; +export { runGhCommand } from "./gh/index"; +export type { CommanderBriefDiff, GitHubCommandResult } from "./gh/types"; diff --git a/scripts/src/gh/attachments.ts b/scripts/src/gh/attachments.ts new file mode 100644 index 00000000..c54bb53f --- /dev/null +++ b/scripts/src/gh/attachments.ts @@ -0,0 +1,455 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:6091-6532. + +import path from "node:path"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { binarySha, repoParts } from "./auth-and-safety"; +import { classifyGitHubFetchFailure, commandError, errorPayload, githubGraphqlRequest, isGitHubError, validationError } from "./client"; +import { getIssue, listIssueComments } from "./issue-read"; +import { issueSummary } from "./issue-summary"; +import { isGitHubCommandResult } from "./refs"; +import { MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, REQUEST_TIMEOUT_MS, USER_AGENT } from "./types"; +import type { GitHubAttachmentDownload, GitHubCommandFailure, GitHubCommandResult, GitHubComment, GitHubDegradedReason, GitHubErrorPayload, GitHubIssue, GitHubIssueAttachment, GitHubOptions } from "./types"; + +export const GITHUB_USER_ATTACHMENT_URL_PATTERN = /https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s<>"')]+)?/giu; + +export function normalizedAttachmentUrl(raw: string): string | null { + try { + const url = new URL(raw); + if (url.protocol !== "https:" || url.hostname !== "github.com" || !url.pathname.startsWith("/user-attachments/assets/")) return null; + return url.toString(); + } catch { + return null; + } +} + +export function redactedUrl(raw: string): string { + try { + const url = new URL(raw); + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return raw.split("?")[0] ?? raw; + } +} + +export function shouldSendGitHubWebAuth(raw: string): boolean { + try { + return new URL(raw).hostname === "github.com"; + } catch { + return false; + } +} + +export function decodeHtmlUrl(raw: string): string { + return raw + .replace(/&/giu, "&") + .replace(///giu, "/") + .replace(/=/giu, "="); +} + +export function attachmentAssetId(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.pathname.split("/").filter(Boolean).at(-1) ?? null; + } catch { + return null; + } +} + +export function htmlAttribute(tag: string, name: string): string | null { + const match = new RegExp(`\\b${name}\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^\\s>]+))`, "iu").exec(tag); + return match?.[2] ?? match?.[3] ?? match?.[4] ?? null; +} + +export function extractIssueAttachmentsFromBody(body: string | null | undefined, source: Omit<GitHubIssueAttachment, "index" | "url" | "assetId" | "marker">): Array<Omit<GitHubIssueAttachment, "index">> { + const text = body ?? ""; + const attachments: Array<Omit<GitHubIssueAttachment, "index">> = []; + const seen = new Set<string>(); + const add = (rawUrl: string, marker: GitHubIssueAttachment["marker"], details: Partial<GitHubIssueAttachment> = {}) => { + const url = normalizedAttachmentUrl(rawUrl); + if (url === null || seen.has(url)) return; + seen.add(url); + attachments.push({ + ...source, + marker, + url, + assetId: attachmentAssetId(url), + alt: details.alt ?? null, + width: details.width ?? null, + height: details.height ?? null, + }); + }; + + for (const match of text.matchAll(/!\[([^\]]*)\]\((https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s)"']+)?)\)/giu)) { + add(match[2] ?? "", "markdown-image", { alt: match[1] ?? null }); + } + for (const match of text.matchAll(/<img\b[^>]*>/giu)) { + const tag = match[0] ?? ""; + const src = htmlAttribute(tag, "src"); + if (src === null) continue; + add(src, "html-img", { + alt: htmlAttribute(tag, "alt"), + width: htmlAttribute(tag, "width"), + height: htmlAttribute(tag, "height"), + }); + } + for (const match of text.matchAll(GITHUB_USER_ATTACHMENT_URL_PATTERN)) add(match[0] ?? "", "plain-url"); + return attachments; +} + +export async function collectIssueAttachments(repo: string, token: string, issueNumber: number, commandName: string): Promise<{ issue: GitHubIssue; comments: GitHubComment[]; attachments: GitHubIssueAttachment[] } | GitHubCommandFailure> { + const issue = await getIssue(token, repo, issueNumber); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); + const comments = await listIssueComments(token, repo, issueNumber); + if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) }); + const dedupe = new Set<string>(); + const attachments: GitHubIssueAttachment[] = []; + const append = (items: Array<Omit<GitHubIssueAttachment, "index">>) => { + for (const item of items) { + if (dedupe.has(item.url)) continue; + dedupe.add(item.url); + attachments.push({ ...item, index: attachments.length + 1 }); + } + }; + append(extractIssueAttachmentsFromBody(issue.body, { + source: "issue-body", + sourceUrl: issue.html_url, + author: issue.user?.login ?? null, + createdAt: issue.created_at ?? null, + updatedAt: issue.updated_at ?? null, + })); + for (const comment of comments) { + append(extractIssueAttachmentsFromBody(comment.body, { + source: "comment", + sourceUrl: comment.html_url, + commentId: comment.id, + author: comment.user?.login ?? null, + createdAt: comment.created_at ?? null, + updatedAt: comment.updated_at ?? null, + })); + } + return { issue, comments, attachments }; +} + +export function issueAttachmentSummary(attachment: GitHubIssueAttachment): Record<string, unknown> { + return { + index: attachment.index, + url: attachment.url, + assetId: attachment.assetId, + source: attachment.source, + sourceUrl: attachment.sourceUrl, + commentId: attachment.commentId ?? null, + marker: attachment.marker, + alt: attachment.alt ?? null, + width: attachment.width ?? null, + height: attachment.height ?? null, + author: attachment.author ?? null, + createdAt: attachment.createdAt ?? null, + updatedAt: attachment.updatedAt ?? null, + }; +} + +export async function issueAttachmentList(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> { + const commandName = "issue attachment list"; + const collected = await collectIssueAttachments(repo, token, issueNumber, commandName); + if (isGitHubCommandResult(collected)) return collected; + return { + ok: true, + command: commandName, + repo, + readOnly: true, + issue: issueSummary(collected.issue), + commentsScanned: collected.comments.length, + count: collected.attachments.length, + attachments: collected.attachments.map(issueAttachmentSummary), + next: collected.attachments.length > 0 + ? { + command: `bun scripts/cli.ts gh issue attachment download ${issueNumber} --repo ${repo} --attachment 1 --output /tmp/gh-issue-${issueNumber}-attachment-1`, + } + : null, + request: { + method: "GET", + paths: [ + `/repos/{owner}/{repo}/issues/${issueNumber}`, + `/repos/{owner}/{repo}/issues/${issueNumber}/comments`, + ], + }, + rest: true, + }; +} + +export function selectIssueAttachment(attachments: GitHubIssueAttachment[], selector: string | undefined): GitHubIssueAttachment | null { + if (attachments.length === 0) return null; + if (selector === undefined) return attachments[0] ?? null; + const trimmed = selector.trim(); + if (/^\d+$/u.test(trimmed)) { + const index = Number(trimmed); + return attachments.find((attachment) => attachment.index === index) ?? null; + } + const url = normalizedAttachmentUrl(trimmed); + if (url !== null) return attachments.find((attachment) => attachment.url === url) ?? null; + return attachments.find((attachment) => attachment.assetId === trimmed) ?? null; +} + +export function attachmentExtension(contentType: string | null): string { + const normalized = (contentType ?? "").split(";")[0]?.trim().toLowerCase(); + if (normalized === "image/png") return ".png"; + if (normalized === "image/jpeg" || normalized === "image/jpg") return ".jpg"; + if (normalized === "image/gif") return ".gif"; + if (normalized === "image/webp") return ".webp"; + if (normalized === "image/svg+xml") return ".svg"; + return ".bin"; +} + +export function defaultAttachmentOutputPath(repo: string, issueNumber: number, attachment: GitHubIssueAttachment, contentType: string | null): string { + const safeRepo = repo.replace(/[^A-Za-z0-9._-]+/gu, "_"); + return path.join("/tmp/unidesk-gh-attachments", `${safeRepo}-issue-${issueNumber}-attachment-${attachment.index}${attachmentExtension(contentType)}`); +} + +export function attachmentFetchError(url: string, status: number, message: string, details: Record<string, unknown> = {}): GitHubErrorPayload { + const reason: GitHubDegradedReason = status === 401 + ? "auth-failed" + : status === 403 + ? "permission-denied" + : status === 404 + ? "validation-failed" + : "invalid-response"; + return errorPayload(reason, message, { + status, + details: { + url: redactedUrl(url), + ...details, + }, + request: { method: "GET", path: redactedUrl(url) }, + }); +} + +export async function fetchAttachmentResponse(url: string, token: string, useAuth: boolean, redirect: RequestRedirect): Promise<Response | GitHubErrorPayload> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const headers: Record<string, string> = { + Accept: "image/*,application/octet-stream,*/*", + "User-Agent": USER_AGENT, + }; + if (useAuth && token.length > 0 && shouldSendGitHubWebAuth(url)) headers.Authorization = `Bearer ${token}`; + return await fetch(url, { + method: "GET", + redirect, + signal: controller.signal, + headers, + }); + } catch (error) { + const classified = classifyGitHubFetchFailure(error); + return errorPayload(classified.reason, classified.message, { + request: { method: "GET", path: redactedUrl(url) }, + details: { ...classified.details, url: redactedUrl(url) }, + retryable: classified.retryable, + commanderAction: classified.commanderAction, + }); + } finally { + clearTimeout(timeout); + } +} + +export async function readAttachmentResponse(response: Response, url: string): Promise<GitHubAttachmentDownload | GitHubErrorPayload> { + if (!response.ok) return attachmentFetchError(url, response.status, response.statusText || "GitHub attachment download failed"); + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader === null ? null : Number(contentLengthHeader); + if (contentLength !== null && Number.isFinite(contentLength) && contentLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) { + return attachmentFetchError(url, 413, "GitHub attachment is larger than the UniDesk gh download safety limit", { + contentLength, + maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, + }); + } + let buffer: Buffer; + try { + buffer = Buffer.from(await response.arrayBuffer()); + } catch (error) { + const classified = classifyGitHubFetchFailure(error); + return errorPayload(classified.reason, classified.message, { + request: { method: "GET", path: redactedUrl(url) }, + details: { ...classified.details, url: redactedUrl(url), phase: "read-body" }, + retryable: classified.retryable, + commanderAction: classified.commanderAction, + }); + } + if (buffer.byteLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) { + return attachmentFetchError(url, 413, "GitHub attachment body exceeded the UniDesk gh download safety limit", { + bytes: buffer.byteLength, + maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, + }); + } + return { + buffer, + contentType: response.headers.get("content-type"), + contentLengthHeader, + finalUrl: response.url, + }; +} + +export async function downloadGitHubAttachment(url: string, token: string): Promise<GitHubAttachmentDownload | GitHubErrorPayload> { + const direct = await fetchAttachmentResponse(url, "", false, "follow"); + if (!isGitHubError(direct)) { + if (direct.ok) return readAttachmentResponse(direct, url); + } else { + return direct; + } + + const authenticated = await fetchAttachmentResponse(url, token, true, "manual"); + if (isGitHubError(authenticated)) return authenticated; + if (authenticated.status >= 300 && authenticated.status < 400) { + const location = authenticated.headers.get("location"); + if (location === null || location.length === 0) return attachmentFetchError(url, authenticated.status, "GitHub attachment redirect did not include a Location header"); + const redirectedUrl = new URL(location, url).toString(); + const redirected = await fetchAttachmentResponse(redirectedUrl, "", false, "follow"); + if (isGitHubError(redirected)) return redirected; + return readAttachmentResponse(redirected, redirectedUrl); + } + return readAttachmentResponse(authenticated, url); +} + +export function extractRenderedAttachmentUrlCandidates(html: string | null | undefined, assetId: string | null, source: Record<string, unknown>): Array<{ url: string; source: Record<string, unknown> }> { + if (assetId === null || assetId.length === 0) return []; + const text = html ?? ""; + const candidates: Array<{ url: string; source: Record<string, unknown> }> = []; + const seen = new Set<string>(); + for (const match of text.matchAll(/https?:\/\/[^"'<> ]+/giu)) { + const decoded = decodeHtmlUrl(match[0] ?? ""); + if (!decoded.includes(assetId)) continue; + if (!/private-user-images\.githubusercontent\.com|github-production-user-asset|github\.com\/user-attachments\/assets/iu.test(decoded)) continue; + if (seen.has(decoded)) continue; + seen.add(decoded); + candidates.push({ url: decoded, source }); + } + return candidates; +} + +export async function resolveRenderedIssueAttachmentUrl(repo: string, token: string, issueNumber: number, attachment: GitHubIssueAttachment): Promise<{ url: string; source: Record<string, unknown> } | GitHubErrorPayload | null> { + const { owner, name } = repoParts(repo); + const query = ` + query UniDeskIssueAttachmentRenderedUrl($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + bodyHTML + comments(first: 100) { + nodes { + id + url + bodyHTML + } + } + } + } + } + `; + const response = await githubGraphqlRequest<{ + repository?: { + issue?: { + bodyHTML?: string | null; + comments?: { + nodes?: Array<{ id?: string; url?: string; bodyHTML?: string | null } | null> | null; + } | null; + } | null; + } | null; + }>(token, query, { owner, name, number: issueNumber }); + if (isGitHubError(response)) return response; + const issue = response.repository?.issue; + if (issue === null || issue === undefined) return null; + const candidates = [ + ...extractRenderedAttachmentUrlCandidates(issue.bodyHTML, attachment.assetId, { source: "issue-bodyHTML" }), + ...((issue.comments?.nodes ?? []).flatMap((comment) => extractRenderedAttachmentUrlCandidates(comment?.bodyHTML, attachment.assetId, { + source: "comment-bodyHTML", + commentNodeId: comment?.id ?? null, + commentUrl: comment?.url ?? null, + }))), + ]; + return candidates[0] ?? null; +} + +export async function issueAttachmentDownload(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue attachment download"; + const collected = await collectIssueAttachments(repo, token, issueNumber, commandName); + if (isGitHubCommandResult(collected)) return collected; + const attachment = selectIssueAttachment(collected.attachments, options.attachmentSelector); + if (attachment === null) { + return validationError(commandName, repo, "No matching GitHub user attachment found in the issue body or comments", { + issueNumber, + selector: options.attachmentSelector ?? null, + attachments: collected.attachments.map(issueAttachmentSummary), + }); + } + if (options.dryRun) { + return { + ok: true, + command: commandName, + repo, + dryRun: true, + planned: true, + issue: issueSummary(collected.issue), + attachment: issueAttachmentSummary(attachment), + outputPath: options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, null), + noWrite: true, + }; + } + let downloaded = await downloadGitHubAttachment(attachment.url, token); + let renderedFallback: Record<string, unknown> | null = null; + if (isGitHubError(downloaded)) { + const rendered = await resolveRenderedIssueAttachmentUrl(repo, token, issueNumber, attachment); + if (isGitHubError(rendered)) { + return commandError(commandName, repo, downloaded, { + issueNumber, + attachment: issueAttachmentSummary(attachment), + renderedFallback: { + attempted: true, + ok: false, + degradedReason: rendered.degradedReason, + message: rendered.message, + }, + }); + } + if (rendered !== null) { + renderedFallback = { + attempted: true, + ok: true, + source: rendered.source, + url: redactedUrl(rendered.url), + }; + downloaded = await downloadGitHubAttachment(rendered.url, token); + } else { + renderedFallback = { + attempted: true, + ok: false, + reason: "rendered-bodyHTML-url-not-found", + }; + } + } + if (isGitHubError(downloaded)) return commandError(commandName, repo, downloaded, { issueNumber, attachment: issueAttachmentSummary(attachment), renderedFallback }); + const outputPath = options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, downloaded.contentType); + mkdirSync(path.dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, downloaded.buffer); + return { + ok: true, + command: commandName, + repo, + issue: issueSummary(collected.issue), + attachment: issueAttachmentSummary(attachment), + output: { + path: outputPath, + bytes: downloaded.buffer.byteLength, + sha256: binarySha(downloaded.buffer), + contentType: downloaded.contentType, + contentLengthHeader: downloaded.contentLengthHeader, + finalUrl: redactedUrl(downloaded.finalUrl), + }, + request: { + method: "GET", + url: attachment.url, + maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, + }, + renderedFallback, + valuesPrinted: false, + }; +} diff --git a/scripts/src/gh/auth-and-safety.ts b/scripts/src/gh/auth-and-safety.ts new file mode 100644 index 00000000..18e3f59f --- /dev/null +++ b/scripts/src/gh/auth-and-safety.ts @@ -0,0 +1,120 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:1557-1667. + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readIssueCommentBody } from "./body-input"; +import { PREVIEW_CHARS } from "./types"; +import type { GitHubOptions, GitHubTokenProbe } from "./types"; + +export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null { + if (options.comment === undefined && options.commentFile === undefined) return null; + if (options.comment !== undefined && options.commentFile !== undefined) { + throw new Error(`${command} --comment and --comment-file/--comment-stdin are mutually exclusive`); + } + if (options.body !== undefined || options.bodyFile !== undefined) { + throw new Error(`${command} --comment/--comment-file/--comment-stdin cannot be combined with --body/--body-file/--body-stdin`); + } + if (options.commentFile !== undefined) { + return readIssueCommentBody({ ...options, body: undefined, bodyFile: options.commentFile }); + } + return readIssueCommentBody({ ...options, body: options.comment, bodyFile: undefined }); +} + +export function tokenFromEnvironment(): GitHubTokenProbe { + if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) { + return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false }; + } + if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) { + return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false }; + } + return { present: false, source: null, ghFallbackAttempted: false }; +} + +export function ghBinaryPath(): string | null { + try { + const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return output.length > 0 ? output : null; + } catch { + return null; + } +} + +export function ghAuthToken(): string | null { + try { + const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); + return output.length > 0 ? output : null; + } catch { + return null; + } +} + +export function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } { + const envProbe = tokenFromEnvironment(); + if (envProbe.present) { + const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null; + return { token, probe: envProbe }; + } + if (!allowGhFallback) return { token: null, probe: envProbe }; + const ghPath = ghBinaryPath(); + if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } }; + const token = ghAuthToken(); + if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } }; + return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } }; +} + +export function repoParts(repo: string): { owner: string; name: string } { + const [owner, name, extra] = repo.split("/"); + if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format"); + return { owner, name }; +} + +export function preview(text: string): string { + return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text; +} + +export function previewLines(text: string, maxLines = 12): string[] { + return text.split(/\r?\n/).slice(0, maxLines).map((line) => preview(line)); +} + +export function bodySha(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +export function binarySha(data: Buffer): string { + return createHash("sha256").update(data).digest("hex"); +} + +export function normalizeExpectedSha(raw: string): string { + const value = raw.replace(/^sha256:/iu, "").toLowerCase(); + if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("--expect-body-sha must be a 64-character SHA-256 hex value, optionally prefixed with sha256:"); + return value; +} + +export function shellPollutionEvidence(body: string): string[] { + const evidence: string[] = []; + if (body.includes("\\n")) evidence.push("literal-backslash-n"); + if (body.includes("\\t")) evidence.push("literal-backslash-t"); + if (body.includes("\\`")) evidence.push("escaped-backtick"); + if (/\\x1b|\\u001b|\u001b\[/u.test(body)) evidence.push("ansi-escape"); + if (/\r(?!\n)/u.test(body)) evidence.push("bare-carriage-return"); + return evidence; +} + +export function bodySafetySignals(body: string): Record<string, unknown> { + const evidence = shellPollutionEvidence(body); + return { + bodyChars: body.length, + bodyTrimmedChars: body.trim().length, + bodyLines: body.length === 0 ? 0 : body.split(/\r?\n/).length, + bodySha: bodySha(body), + preservesRawNewlines: body.includes("\n"), + containsLiteralBackslashN: body.includes("\\n"), + containsBackticks: body.includes("`"), + containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body), + shellPollution: { + suspected: evidence.length > 0, + evidence, + }, + }; +} diff --git a/scripts/src/gh/auth-pr-read.ts b/scripts/src/gh/auth-pr-read.ts new file mode 100644 index 00000000..7400b258 --- /dev/null +++ b/scripts/src/gh/auth-pr-read.ts @@ -0,0 +1,204 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:7964-8156. + +import path from "node:path"; +import { ghBinaryPath, repoParts, resolveToken } from "./auth-and-safety"; +import { commandError, errorPayload, githubRequest, isGitHubError, runnerDisposition } from "./client"; +import { issueRead } from "./issue-read"; +import { needsPrGraphqlMetadata, numberOrNull, prCloseoutMetadata, prCloseoutMetadataError, prCompactSummary, prFileSummary, prGraphqlMetadata, prMetadataSummary, prSummary, selectedPrJson, sumPrFileStats } from "./pr-summary"; +import { MAX_PR_FILES_LIMIT, PR_LIST_JSON_FIELDS } from "./types"; +import type { GitHubCommandResult, GitHubDegradedReason, GitHubIssue, GitHubPullRequest, GitHubPullRequestFile, PrListJsonField, PrListState, PrReadJsonField } from "./types"; + +export async function authStatus(repo: string): Promise<GitHubCommandResult> { + const ghPath = ghBinaryPath(); + const { token, probe } = resolveToken(ghPath !== null); + const degraded: GitHubDegradedReason[] = []; + if (ghPath === null) degraded.push("missing-binary"); + if (!probe.present || token === null) { + if (ghPath === null) { + return { + ok: false, + command: "auth status", + repo, + degradedReason: "missing-binary", + degraded, + runnerDisposition: runnerDisposition("missing-binary"), + gh: { binaryFound: false, path: null }, + token: probe, + probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" }, + }; + } + degraded.push("missing-token"); + return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), { + degraded, + gh: { binaryFound: ghPath !== null, path: ghPath }, + token: probe, + probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" }, + }); + } + + const { owner, name } = repoParts(repo); + const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit"); + if (isGitHubError(api)) { + return commandError("auth status", repo, api, { + degraded: [...degraded, api.degradedReason], + gh: { binaryFound: ghPath !== null, path: ghPath }, + token: probe, + probes: { restApi: api, repo: "skipped", issueRead: "skipped" }, + }); + } + + const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`); + if (isGitHubError(repoProbe)) { + return commandError("auth status", repo, repoProbe, { + degraded: [...degraded, repoProbe.degradedReason], + gh: { binaryFound: ghPath !== null, path: ghPath }, + token: probe, + probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" }, + }); + } + + const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`); + if (isGitHubError(issueProbe)) { + return commandError("auth status", repo, issueProbe, { + degraded: [...degraded, issueProbe.degradedReason], + gh: { binaryFound: ghPath !== null, path: ghPath }, + token: probe, + probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe }, + }); + } + + return { + ok: true, + command: "auth status", + repo, + degraded, + gh: { binaryFound: ghPath !== null, path: ghPath }, + token: probe, + probes: { + restApi: "ok", + repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, + issueRead: { ok: true, readable: true, sampleCount: issueProbe.length }, + }, + restFallback: true, + }; +} + +export 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 }); + const fields = jsonFields ?? PR_LIST_JSON_FIELDS.slice(); + return { + ok: true, + command: "pr list", + repo, + ...(noDump ? { noDump: true } : {}), + state, + limit, + count: prs.length, + jsonFields: fields, + pullRequests: prs.map((pr) => selectedPrJson(prSummary(pr), fields)), + }; +} + +export async function prFiles(repo: string, token: string, number: number, limit: number, commandName = "pr files"): Promise<GitHubCommandResult> { + const { owner, name } = repoParts(repo); + const boundedLimit = Math.min(limit, MAX_PR_FILES_LIMIT); + const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number }); + const perPage = Math.max(1, Math.min(100, boundedLimit)); + const files: GitHubPullRequestFile[] = []; + let page = 1; + while (files.length < boundedLimit) { + const remaining = boundedLimit - files.length; + const pageSize = Math.min(perPage, remaining); + const path = `/repos/${owner}/${name}/pulls/${number}/files?per_page=${pageSize}&page=${page}`; + const pageFiles = await githubRequest<GitHubPullRequestFile[]>(token, "GET", path); + if (isGitHubError(pageFiles)) return commandError(commandName, repo, pageFiles, { number, phase: "fetch-pr-files", filesReturned: files.length }); + files.push(...pageFiles); + if (pageFiles.length < pageSize || pageFiles.length === 0) break; + page += 1; + } + const totalFiles = numberOrNull(pr.changed_files); + const listedStats = sumPrFileStats(files); + const totalAdditions = numberOrNull(pr.additions); + const totalDeletions = numberOrNull(pr.deletions); + const fullStatsAvailable = totalAdditions !== null && totalDeletions !== null; + const totalChanges = fullStatsAvailable ? totalAdditions + totalDeletions : listedStats.changes; + const truncated = totalFiles !== null ? files.length < totalFiles : files.length >= boundedLimit; + const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT); + const nextCommand = truncated + ? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}` + : `bun scripts/cli.ts gh pr view ${number} --repo ${repo} --json body,title,state,head,base`; + return { + ok: true, + command: commandName, + repo, + readOnly: true, + rawDiffIncluded: false, + pullRequest: prCompactSummary(pr), + summary: { + files: totalFiles ?? files.length, + additions: totalAdditions ?? listedStats.additions, + deletions: totalDeletions ?? listedStats.deletions, + changes: totalChanges, + commits: numberOrNull(pr.commits), + source: fullStatsAvailable && totalFiles !== null ? "pull-request-rest" : "listed-files-rest", + }, + files: files.map(prFileSummary), + filesReturned: files.length, + limit: boundedLimit, + truncation: { + truncated, + requestedLimit: limit, + appliedLimit: boundedLimit, + returned: files.length, + totalFiles, + maxLimit: MAX_PR_FILES_LIMIT, + }, + next: { + command: nextCommand, + purpose: truncated ? "Fetch a larger bounded file summary page." : "Fetch full PR metadata/body; raw diffs remain intentionally excluded.", + }, + request: { + method: "GET", + paths: [ + `/repos/${owner}/${name}/pulls/${number}`, + `/repos/${owner}/${name}/pulls/${number}/files`, + ], + }, + }; +} + +export async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> { + const { owner, name } = repoParts(repo); + const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number }); + const fullSummary = prSummary(pr); + const includeBody = jsonFields === undefined || jsonFields.includes("body"); + const summary = includeBody ? fullSummary : prSummary(pr, { includeBody: false, includePreview: false }); + const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null; + if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) }); + const selectionSummary = metadata === null ? fullSummary : { ...fullSummary, ...prMetadataSummary(metadata) }; + return { + ok: true, + command: commandName, + repo, + ...(disclosure === null ? {} : { disclosure }), + pullRequest: summary, + ...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }), + ...(jsonFields === undefined ? {} : { + jsonFields, + json: selectedPrJson(selectionSummary, jsonFields), + compatibility: { + legacyJsonBodyPath: includeBody ? ".data.pullRequest.body" : null, + bodyOmitted: !includeBody, + }, + }), + }; +} + +export async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> { + return prRead(repo, token, number, jsonFields, "pr view", disclosure); +} diff --git a/scripts/src/gh/board-audit.ts b/scripts/src/gh/board-audit.ts new file mode 100644 index 00000000..32a547f9 --- /dev/null +++ b/scripts/src/gh/board-audit.ts @@ -0,0 +1,102 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:6666-6756. + +import { bodySha } from "./auth-and-safety"; +import { mergedKnownMetaIssues, parseBoardTables } from "./board-parser"; +import { normalizeNewlines } from "./body-profiles"; +import { commandError, isGitHubError } from "./client"; +import { getIssue } from "./issue-read"; +import { CODE_QUEUE_BOARD_TARGET_ISSUE } from "./types"; +import type { GitHubCommandResult, GitHubOptions } from "./types"; + +export async function issueBoardAudit(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue board-audit"; + const boardIssue = await getIssue(token, repo, options.boardIssue); + if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue }); + const parsed = parseBoardTables(boardIssue.body ?? ""); + const openRows = parsed.sections.filter((section) => section.kind === "open").flatMap((section) => section.rows); + const closedRows = parsed.sections.filter((section) => section.kind === "closed").flatMap((section) => section.rows); + const body = boardIssue.body ?? ""; + + return { + ok: true, + command: commandName, + repo, + dryRun: true, + planned: true, + readOnly: true, + boardIssue: { + number: boardIssue.number, + title: boardIssue.title, + state: boardIssue.state, + url: boardIssue.html_url, + bodyChars: body.length, + bodyLines: body.length === 0 ? 0 : normalizeNewlines(body).split("\n").length, + bodySha: bodySha(body), + updatedAt: boardIssue.updated_at ?? null, + }, + config: { + limit: options.limit, + knownMetaIssues: mergedKnownMetaIssues(options), + ignoredIssues: options.ignoredIssues, + briefIndexManagedIssuePattern: options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE ? "YYYY-MM-DD 指挥简报(北京时间)" : null, + openClosedCoverageValidation: false, + requiredColumns: [], + }, + summary: { + openIssues: null, + closedIssues: null, + openRows: openRows.length, + closedRows: closedRows.length, + parsedSections: parsed.sections.length, + parsedRows: parsed.sections.reduce((sum, section) => sum + section.rows.length, 0), + parserWarnings: parsed.warnings.length, + missingOpenIssues: 0, + closedInOpenRows: 0, + missingClosedRows: 0, + openInClosedRows: 0, + rowValidationWarnings: 0, + ignoredIssues: 0, + boardOnlyRows: 0, + staleOpenRows: 0, + recommendedActions: 0, + }, + validation: { + openClosedCoverage: { + enabled: false, + reason: "OPEN/CLOSED table coverage validation has been disabled; this command no longer compares GitHub issue state against board rows.", + }, + rowRequiredColumns: { + enabled: false, + reason: "board-audit no longer reports missing required row columns; use board-row commands when intentionally maintaining a legacy OPEN/CLOSED table.", + }, + }, + sections: parsed.sections.map((section) => ({ + kind: section.kind, + heading: section.heading, + headingLine: section.headingLine, + headerLine: section.headerLine, + headers: section.headers, + rows: section.rows.length, + issueNumbers: section.rows.flatMap((row) => row.issueNumbers), + })), + parserWarnings: parsed.warnings, + missingOpenIssues: [], + closedInOpenRows: [], + missingClosedRows: [], + openInClosedRows: [], + staleOpenRows: [], + boardOnlyRows: [], + rowValidationWarnings: [], + ignoredIssues: [], + recommendedActions: [], + request: { + method: "GET", + paths: [ + `/repos/{owner}/{repo}/issues/${options.boardIssue}`, + ], + query: {}, + }, + note: "Read-only board structure audit; OPEN/CLOSED coverage validation is disabled, no issue body was edited, no issue was closed, and no comments were written.", + }; +} diff --git a/scripts/src/gh/board-commands.ts b/scripts/src/gh/board-commands.ts new file mode 100644 index 00000000..db582329 --- /dev/null +++ b/scripts/src/gh/board-commands.ts @@ -0,0 +1,331 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:4145-4460. + +import path from "node:path"; +import { bodySafetySignals, bodySha, preview, repoParts } from "./auth-and-safety"; +import { boardBodyGuardSummary, boardBodySafetyIssue, boardRowsForState, boardRowSummary, boardRowValuePollutionEvidence, findBoardRow, parseBoardTables, readBoardRowFile } from "./board-parser"; +import { boardRowAddPlan, boardRowDeletePlan, boardRowMovePlan, boardRowUpdatePlan, boardRowUpsertPlan } from "./board-plans"; +import { assertConcurrencyOptions, validateIssueConcurrency } from "./body-guards"; +import { codeQueueBoardCommanderBriefHint } from "./body-profiles"; +import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, issueWriteDisclosure, validationError } from "./client"; +import { getIssue } from "./issue-read"; +import { issueSummary } from "./issue-summary"; +import { BOARD_MUTATION_SECTIONS, BOARD_ROW_FIELDS } from "./types"; +import type { BoardMutationSection, BoardRowMutationPlanResult, BoardRowValidationWarning, BoardTableSection, GitHubCommandFailure, GitHubCommandResult, GitHubIssue, GitHubOptions } from "./types"; + +export async function issueBoardRowMutation( + repo: string, + token: string, + issueNumber: number, + options: GitHubOptions, + commandName: "issue board-row add" | "issue board-row move" | "issue board-row delete" | "issue board-row upsert", + mutationKey: "add" | "move" | "delete" | "upsert", + buildPlan: (boardIssue: GitHubIssue, parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }) => BoardRowMutationPlanResult | GitHubCommandFailure, +): Promise<GitHubCommandResult> { + const concurrencyOptionError = assertConcurrencyOptions(options, commandName); + if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo }; + const boardIssue = await getIssue(token, repo, options.boardIssue); + if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); + const parsed = parseBoardTables(boardIssue.body ?? ""); + const planned = buildPlan(boardIssue, parsed); + if (planned.ok === false) return planned; + const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options); + const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options); + const hasConcurrencyGuard = options.expectBodySha !== undefined || options.expectUpdatedAt !== undefined; + const effectiveDryRun = options.dryRun || !hasConcurrencyGuard; + const base = { + ok: true, + command: commandName, + repo, + boardIssue: { + number: boardIssue.number, + title: boardIssue.title, + state: boardIssue.state, + url: boardIssue.html_url, + bodyChars: (boardIssue.body ?? "").length, + bodySha: bodySha(boardIssue.body ?? ""), + updatedAt: boardIssue.updated_at ?? null, + }, + codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody), + issueNumber, + operation: typeof planned.plan.operation === "string" ? planned.plan.operation : mutationKey, + dryRun: effectiveDryRun, + planned: true, + [mutationKey]: planned.plan, + guard, + bodyOnlySafety: { + oldBody: { + fetched: true, + bodyChars: (boardIssue.body ?? "").length, + bodySha: bodySha(boardIssue.body ?? ""), + updatedAt: boardIssue.updated_at ?? null, + }, + newBody: { + bodyChars: planned.newBody.length, + bodySha: bodySha(planned.newBody), + ...bodySafetySignals(planned.newBody), + }, + }, + concurrency: { + expectUpdatedAt: options.expectUpdatedAt ?? null, + expectBodySha: options.expectBodySha ?? null, + note: `non-dry-run board-row ${mutationKey} requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH`, + }, + }; + if (guardError !== null) return { ...guardError, command: commandName, [mutationKey]: planned.plan, guard }; + if (effectiveDryRun) { + return { + ...base, + dryRun: true, + disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true), + readCommands: issueBodyReadCommands(repo, options.boardIssue), + wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length }, + note: !hasConcurrencyGuard + ? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified." + : "Dry-run only; no GitHub issue body was modified.", + }; + } + const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options); + if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName }; + const { owner, name } = repoParts(repo); + const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody }); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, [mutationKey]: planned.plan }); + return { + ...base, + dryRun: false, + planned: false, + issue: issueSummary(issue, { includeBody: options.raw || options.full }), + disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false), + readCommands: issueBodyReadCommands(repo, options.boardIssue), + concurrency: { + checked: true, + oldIssueUpdatedAt: boardIssue.updated_at ?? null, + oldBodySha: bodySha(boardIssue.body ?? ""), + expectUpdatedAt: options.expectUpdatedAt ?? null, + expectBodySha: options.expectBodySha ?? null, + }, + rest: true, + }; +} + +export async function issueBoardRowList(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue board-row list"; + const boardIssue = await getIssue(token, repo, options.boardIssue); + if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue }); + const parsed = parseBoardTables(boardIssue.body ?? ""); + const rows = boardRowsForState(parsed.sections, options.listState); + return { + ok: true, + command: commandName, + repo, + dryRun: true, + readOnly: true, + codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""), + boardIssue: { + number: boardIssue.number, + title: boardIssue.title, + state: boardIssue.state, + url: boardIssue.html_url, + bodyChars: (boardIssue.body ?? "").length, + bodySha: bodySha(boardIssue.body ?? ""), + updatedAt: boardIssue.updated_at ?? null, + }, + state: options.listState, + count: rows.length, + rows: rows.map(({ section, row }) => boardRowSummary(section, row)), + sections: parsed.sections.map((section) => ({ kind: section.kind, heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine, headers: section.headers, rows: section.rows.length })), + rowValidationWarnings: parsed.warnings, + note: "Read-only board row list; no issue body was edited.", + }; +} + +export async function issueBoardRowGet(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue board-row get"; + const boardIssue = await getIssue(token, repo, options.boardIssue); + if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); + const parsed = parseBoardTables(boardIssue.body ?? ""); + const found = findBoardRow(repo, parsed.sections, issueNumber); + if (found.ok === false) return { ...found, command: commandName, repo, boardIssue: options.boardIssue }; + return { + ok: true, + command: commandName, + repo, + dryRun: true, + readOnly: true, + codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, boardIssue.body ?? ""), + boardIssue: { + number: boardIssue.number, + title: boardIssue.title, + state: boardIssue.state, + url: boardIssue.html_url, + bodyChars: (boardIssue.body ?? "").length, + bodySha: bodySha(boardIssue.body ?? ""), + updatedAt: boardIssue.updated_at ?? null, + }, + issueNumber, + row: boardRowSummary(found.section, found.row), + rowValidationWarnings: parsed.warnings.filter((warning) => warning.issueNumber === issueNumber), + note: "Read-only board row get; no issue body was edited.", + }; +} + +export async function issueBoardRowUpdate(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue board-row update"; + if (options.boardRowField === undefined) return validationError(commandName, repo, "issue board-row update requires --field progress|status|validation|branch|tasks|focus", { supportedFields: BOARD_ROW_FIELDS.slice() }); + if (options.boardRowValue === undefined) return validationError(commandName, repo, "issue board-row update requires --value <text>", { issueNumber, field: options.boardRowField }); + const valuePollutionEvidence = boardRowValuePollutionEvidence(options.boardRowValue); + if (valuePollutionEvidence.length > 0) { + return validationError(commandName, repo, "issue board-row update --value contains literal shell escape text that would pollute the board cell; pass real newlines or plain text instead", { + issueNumber, + field: options.boardRowField, + valuePreview: preview(options.boardRowValue), + shellPollution: { suspected: true, evidence: valuePollutionEvidence }, + }); + } + const concurrencyOptionError = assertConcurrencyOptions(options, commandName); + if (concurrencyOptionError !== null) return { ...concurrencyOptionError, command: commandName, repo }; + + const boardIssue = await getIssue(token, repo, options.boardIssue); + if (isGitHubError(boardIssue)) return commandError(commandName, repo, boardIssue, { boardIssue: options.boardIssue, issueNumber }); + const parsed = parseBoardTables(boardIssue.body ?? ""); + const planned = boardRowUpdatePlan(repo, boardIssue, issueNumber, options.boardRowField, options.boardRowValue, parsed); + if (planned.ok === false) return planned; + const guardError = boardBodySafetyIssue(repo, options.boardIssue, planned.newBody, options); + const guard = boardBodyGuardSummary(options.boardIssue, planned.newBody, options); + const effectiveDryRun = options.dryRun || (options.expectBodySha === undefined && options.expectUpdatedAt === undefined); + const base = { + ok: true, + command: commandName, + repo, + boardIssue: { + number: boardIssue.number, + title: boardIssue.title, + state: boardIssue.state, + url: boardIssue.html_url, + bodyChars: (boardIssue.body ?? "").length, + bodySha: bodySha(boardIssue.body ?? ""), + updatedAt: boardIssue.updated_at ?? null, + }, + codeQueueBoardHint: codeQueueBoardCommanderBriefHint(options.boardIssue, planned.newBody), + issueNumber, + dryRun: effectiveDryRun, + planned: true, + update: planned.plan, + guard, + bodyOnlySafety: { + oldBody: { + fetched: true, + bodyChars: (boardIssue.body ?? "").length, + bodySha: bodySha(boardIssue.body ?? ""), + updatedAt: boardIssue.updated_at ?? null, + }, + newBody: { + bodyChars: planned.newBody.length, + bodySha: bodySha(planned.newBody), + ...bodySafetySignals(planned.newBody), + }, + }, + concurrency: { + expectUpdatedAt: options.expectUpdatedAt ?? null, + expectBodySha: options.expectBodySha ?? null, + note: "non-dry-run board-row update requires --expect-body-sha or --expect-updated-at and checks the current board issue before PATCH", + }, + }; + if (guardError !== null) return { ...guardError, command: commandName, update: planned.plan, guard }; + if (effectiveDryRun) { + return { + ...base, + dryRun: true, + disclosure: issueWriteDisclosure(options, repo, options.boardIssue, true), + readCommands: issueBodyReadCommands(repo, options.boardIssue), + wouldPatch: { issueNumber: options.boardIssue, bodySha: bodySha(planned.newBody), bodyChars: planned.newBody.length }, + note: options.expectBodySha === undefined && options.expectUpdatedAt === undefined + ? "Default dry-run because no concurrency expectation was supplied; no GitHub issue body was modified." + : "Dry-run only; no GitHub issue body was modified.", + }; + } + const concurrencyError = validateIssueConcurrency(repo, options.boardIssue, boardIssue, options); + if (concurrencyError !== null) return { ...base, ...concurrencyError, command: commandName }; + const { owner, name } = repoParts(repo); + const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${options.boardIssue}`, { body: planned.newBody }); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, update: planned.plan }); + return { + ...base, + dryRun: false, + planned: false, + issue: issueSummary(issue, { includeBody: options.raw || options.full }), + disclosure: issueWriteDisclosure(options, repo, options.boardIssue, false), + readCommands: issueBodyReadCommands(repo, options.boardIssue), + concurrency: { + checked: true, + oldIssueUpdatedAt: boardIssue.updated_at ?? null, + oldBodySha: bodySha(boardIssue.body ?? ""), + expectUpdatedAt: options.expectUpdatedAt ?? null, + expectBodySha: options.expectBodySha ?? null, + }, + rest: true, + }; +} + +export async function issueBoardRowAdd(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue board-row add"; + if (options.boardSection === undefined) return validationError(commandName, repo, "issue board-row add requires --section open|closed", { issueNumber, supportedSections: BOARD_MUTATION_SECTIONS.slice() }); + let rowFile: { path: string; raw: string; cells: string[] }; + try { + rowFile = readBoardRowFile(options.boardRowFile, commandName); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { + issueNumber, + rowFile: options.boardRowFile ?? null, + }); + } + return issueBoardRowMutation( + repo, + token, + issueNumber, + options, + commandName, + "add", + (boardIssue, parsed) => boardRowAddPlan(repo, boardIssue, issueNumber, options.boardSection as BoardMutationSection, rowFile, parsed), + ); +} + +export async function issueBoardRowUpsert(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue board-row upsert"; + return issueBoardRowMutation( + repo, + token, + issueNumber, + options, + commandName, + "upsert", + (boardIssue, parsed) => boardRowUpsertPlan(repo, boardIssue, issueNumber, options, parsed), + ); +} + +export async function issueBoardRowMove(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue board-row move"; + if (options.boardMoveTo === undefined) return validationError(commandName, repo, "issue board-row move requires --to open|closed", { issueNumber, supportedTargets: BOARD_MUTATION_SECTIONS.slice() }); + return issueBoardRowMutation( + repo, + token, + issueNumber, + options, + commandName, + "move", + (boardIssue, parsed) => boardRowMovePlan(repo, boardIssue, issueNumber, options.boardMoveTo as BoardMutationSection, options.boardGithubStatus, parsed), + ); +} + +export async function issueBoardRowDelete(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue board-row delete"; + return issueBoardRowMutation( + repo, + token, + issueNumber, + options, + commandName, + "delete", + (boardIssue, parsed) => boardRowDeletePlan(repo, boardIssue, issueNumber, parsed), + ); +} diff --git a/scripts/src/gh/board-parser.ts b/scripts/src/gh/board-parser.ts new file mode 100644 index 00000000..201366cd --- /dev/null +++ b/scripts/src/gh/board-parser.ts @@ -0,0 +1,530 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:2674-3191. + +import path from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { preview, shellPollutionEvidence } from "./auth-and-safety"; +import { isDailyCommanderBriefTitle, issueEditGuardSummary, validateIssueBodyGuard } from "./body-guards"; +import { normalizeNewlines } from "./body-profiles"; +import { validationError } from "./client"; +import { BOARD_AUDIT_REQUIRED_COLUMNS, CODE_QUEUE_BOARD_TARGET_ISSUE, DEFAULT_BOARD_KNOWN_META_ISSUES } from "./types"; +import type { BoardColumnKind, BoardIgnoreReason, BoardIssueEntry, BoardMutationSection, BoardRequiredColumn, BoardRowField, BoardRowLookup, BoardRowUpsertField, BoardRowValidationWarning, BoardSectionKind, BoardTableRow, BoardTableSection, GitHubCommandFailure, GitHubCommandResult, GitHubIssue, GitHubOptions, IssueListState } from "./types"; + +export function boardIssueEntry(issue: GitHubIssue): BoardIssueEntry { + return { + number: issue.number, + title: issue.title, + state: issue.state, + url: issue.html_url, + }; +} + +export function sortedIssueEntries(issues: BoardIssueEntry[]): BoardIssueEntry[] { + return issues.slice().sort((a, b) => a.number - b.number); +} + +export function mergedKnownMetaIssues(options: GitHubOptions): number[] { + const seen = new Set<number>(); + const values: number[] = []; + for (const issueNumber of [...DEFAULT_BOARD_KNOWN_META_ISSUES, ...options.knownMetaIssues]) { + if (seen.has(issueNumber)) continue; + seen.add(issueNumber); + values.push(issueNumber); + } + return values; +} + +export function boardIgnoreMap(options: GitHubOptions, issues: BoardIssueEntry[] = []): Map<number, BoardIgnoreReason> { + const ignored = new Map<number, BoardIgnoreReason>(); + for (const issueNumber of mergedKnownMetaIssues(options)) ignored.set(issueNumber, "known-meta"); + for (const issueNumber of options.ignoredIssues) ignored.set(issueNumber, "ignored"); + if (options.boardIssue === CODE_QUEUE_BOARD_TARGET_ISSUE) { + for (const issue of issues) { + if (!ignored.has(issue.number) && isDailyCommanderBriefTitle(issue.title)) ignored.set(issue.number, "brief-index-managed"); + } + } + return ignored; +} + +export function stripMarkdownInline(text: string): string { + return text + .replace(/\[[^\]]*]\(([^)]*)\)/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/[*_`~]/g, "") + .trim(); +} + +export function normalizeBoardHeader(header: string): string { + return stripMarkdownInline(header).toLowerCase().replace(/\s+/g, ""); +} + +export function boardHeaderColumnKind(header: string): BoardColumnKind | null { + const normalized = normalizeBoardHeader(header); + if (["category", "类别", "分类", "类型", "任务类型"].includes(normalized)) return "category"; + if (["branch", "分支", "目标分支", "工作分支"].includes(normalized)) return "branch"; + if (["acceptance", "验收", "验收状态", "验收结果", "验收标准"].includes(normalized)) return "acceptance"; + if (["relatedtask", "task", "codequeue", "codequeuetask", "相关任务", "任务", "codequeue任务", "cq任务", "相关codequeue任务", "相关codequeuetask"].includes(normalized)) return "relatedTask"; + if (["githubstatus", "githubstate", "github状态", "githubissue状态", "issuestate", "issue状态", "issuegithub状态", "开闭状态", "openclosed", "openclosed状态"].includes(normalized)) return "githubStatus"; + if (["summary", "摘要", "概述", "任务摘要", "任务概述", "标题"].includes(normalized)) return "summary"; + if (["focus", "currentfocus", "关注点", "当前关注点", "当前focus"].includes(normalized)) return "focus"; + if (["progress", "进度", "状态", "当前进度"].includes(normalized)) return "progress"; + return null; +} + +export function boardColumnIndexMap(headers: string[]): Map<BoardColumnKind, number> { + const map = new Map<BoardColumnKind, number>(); + headers.forEach((header, headerIndex) => { + const kind = boardHeaderColumnKind(header); + if (kind !== null && !map.has(kind)) map.set(kind, headerIndex); + }); + return map; +} + +export function boardRequiredColumnIndexMap(headers: string[]): Map<BoardRequiredColumn, number> { + const map = new Map<BoardRequiredColumn, number>(); + const allColumns = boardColumnIndexMap(headers); + for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) { + const index = allColumns.get(column); + if (index !== undefined) map.set(column, index); + } + return map; +} + +export function boardColumnKindForField(field: BoardRowField): BoardColumnKind { + if (field === "branch") return "branch"; + if (field === "tasks") return "relatedTask"; + if (field === "focus") return "focus"; + if (field === "progress") return "progress"; + return "acceptance"; +} + +export function boardIssueColumnIndex(headers: string[]): number | null { + for (let index = 0; index < headers.length; index += 1) { + const normalized = normalizeBoardHeader(headers[index]); + if (["issue", "issue#", "issuenumber", "issueid", "issue编号", "issue号", "编号"].includes(normalized)) return index; + } + return null; +} + +export function markdownCells(line: string): string[] { + const trimmed = line.trim(); + const withoutOuterPipes = trimmed.replace(/^\|/u, "").replace(/\|$/u, ""); + const cells: string[] = []; + let current = ""; + for (let index = 0; index < withoutOuterPipes.length; index += 1) { + const char = withoutOuterPipes[index]; + const previous = withoutOuterPipes[index - 1]; + if (char === "|" && previous !== "\\") { + cells.push(current.trim().replace(/\\\|/g, "|")); + current = ""; + continue; + } + current += char; + } + cells.push(current.trim().replace(/\\\|/g, "|")); + return cells; +} + +export function isMarkdownTableSeparator(line: string): boolean { + const cells = markdownCells(line); + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/u.test(cell.trim())); +} + +export function boardSectionKindFromHeading(line: string): BoardSectionKind | null { + const normalized = line.toLowerCase(); + if (!line.trimStart().startsWith("#")) return null; + if (normalized.includes("open") || normalized.includes("看板(open)") || normalized.includes("看板(open)") || normalized.includes("开放")) return "open"; + if (normalized.includes("closed") || normalized.includes("看板(closed)") || normalized.includes("看板(closed)") || normalized.includes("关闭") || normalized.includes("已关闭")) return "closed"; + return null; +} + +export function extractIssueNumbers(text: string): number[] { + const numbers: number[] = []; + const seen = new Set<number>(); + const patterns = [/#(\d+)/g, /\/issues\/(\d+)/g]; + for (const pattern of patterns) { + pattern.lastIndex = 0; + let match = pattern.exec(text); + while (match !== null) { + const value = Number(match[1]); + if (Number.isInteger(value) && value > 0 && !seen.has(value)) { + seen.add(value); + numbers.push(value); + } + match = pattern.exec(text); + } + } + return numbers; +} + +export function primaryBoardIssueNumberFromIssueCell(issueCell: string | undefined): number | null { + if (issueCell === undefined) return null; + const markdownLinkPattern = /\[[^\]]*]\(([^)]*)\)/g; + let match = markdownLinkPattern.exec(issueCell); + while (match !== null) { + const issueNumber = /\/issues\/(\d+)/u.exec(match[1] ?? "")?.[1]; + if (issueNumber !== undefined) { + const value = Number(issueNumber); + if (Number.isInteger(value) && value > 0) return value; + } + match = markdownLinkPattern.exec(issueCell); + } + const leadingIssueReference = /^\s*#(\d+)\b/u.exec(issueCell)?.[1]; + if (leadingIssueReference !== undefined) { + const value = Number(leadingIssueReference); + if (Number.isInteger(value) && value > 0) return value; + } + return null; +} + +export function boardRowFromCells(section: BoardTableSection, raw: string, cells: string[], lineNumber: number): BoardTableRow { + const columnMap = boardRequiredColumnIndexMap(section.headers); + const issueColumnIndex = boardIssueColumnIndex(section.headers); + const issueCell = issueColumnIndex === null ? undefined : cells[issueColumnIndex]; + const primaryIssueNumber = issueColumnIndex === null ? null : primaryBoardIssueNumberFromIssueCell(issueCell); + const issueNumbers = issueColumnIndex === null + ? extractIssueNumbers(raw) + : (primaryIssueNumber === null ? extractIssueNumbers(issueCell ?? "") : [primaryIssueNumber]); + const columns: Partial<Record<BoardRequiredColumn, string>> = {}; + for (const column of BOARD_AUDIT_REQUIRED_COLUMNS) { + const cellIndex = columnMap.get(column); + if (cellIndex !== undefined) columns[column] = cells[cellIndex] ?? ""; + } + return { + section: section.kind, + lineNumber, + raw, + cells, + issueNumbers, + issueNumber: issueNumbers[0] ?? null, + title: issueColumnIndex === null + ? (cells.find((cell) => extractIssueNumbers(cell).length > 0) ?? cells[0] ?? null) + : (issueCell ?? cells[0] ?? null), + columns, + }; +} + +export function normalizedBoardCellPlaceholder(value: string): { spaced: string; compact: string } { + const spaced = stripMarkdownInline(value).toLowerCase().replace(/\s+/g, " ").trim(); + return { spaced, compact: spaced.replace(/\s+/g, "") }; +} + +export function isRelatedTaskNoTaskPlaceholder(value: string): boolean { + const normalized = normalizedBoardCellPlaceholder(value); + return [ + "-", + "—", + "–", + "n/a", + "na", + "none", + "no task", + "no-task", + "no code queue task", + "not applicable", + ].includes(normalized.spaced) || [ + "无", + "无任务", + "无关联任务", + "无相关任务", + "无codequeue任务", + "无codequeuetask", + "不适用", + ].includes(normalized.compact); +} + +export function cellHasMeaningfulValue(value: string | undefined, column: BoardRequiredColumn): boolean { + if (value === undefined) return false; + const stripped = stripMarkdownInline(value); + if (stripped.length === 0) return false; + if (column === "relatedTask" && isRelatedTaskNoTaskPlaceholder(value)) return true; + return !["-", "—", "n/a", "na", "todo", "tbd", "待补", "未填", "无"].includes(stripped.toLowerCase()); +} + +export function parseBoardTables(body: string): { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] } { + const lines = normalizeNewlines(body).split("\n"); + const sections: BoardTableSection[] = []; + const warnings: BoardRowValidationWarning[] = []; + let currentKind: BoardSectionKind | null = null; + let currentHeading = ""; + let currentHeadingLine = 0; + for (let index = 0; index < lines.length; index += 1) { + const headingKind = boardSectionKindFromHeading(lines[index]); + if (headingKind !== null) { + currentKind = headingKind; + currentHeading = lines[index].trim(); + currentHeadingLine = index + 1; + continue; + } + if (currentKind === null) continue; + if (!lines[index].trim().startsWith("|")) continue; + const separator = lines[index + 1]; + if (separator === undefined || !separator.trim().startsWith("|") || !isMarkdownTableSeparator(separator)) continue; + const headers = markdownCells(lines[index]); + const rows: BoardTableRow[] = []; + let rowIndex = index + 2; + while (rowIndex < lines.length && lines[rowIndex].trim().startsWith("|")) { + if (!isMarkdownTableSeparator(lines[rowIndex])) { + const cells = markdownCells(lines[rowIndex]); + const row = boardRowFromCells( + { kind: currentKind, heading: currentHeading, headingLine: currentHeadingLine, headerLine: index + 1, headers, rows: [] }, + lines[rowIndex], + cells, + rowIndex + 1, + ); + rows.push(row); + if (row.issueNumbers.length === 0) { + warnings.push({ + issueNumber: null, + section: currentKind, + lineNumber: row.lineNumber, + kind: "missing-issue-reference", + message: "Board table row does not contain a GitHub issue reference such as #36.", + rowPreview: preview(row.raw), + }); + } + if (row.issueNumbers.length > 1) { + warnings.push({ + issueNumber: row.issueNumber, + section: currentKind, + lineNumber: row.lineNumber, + kind: "multiple-issue-references", + message: boardIssueColumnIndex(headers) === null + ? "Board table row contains multiple issue references; audit uses the first number as the row key." + : "Board table row contains multiple issue references in the Issue column; audit uses the first number as the row key.", + rowPreview: preview(row.raw), + }); + } + const missingColumns = BOARD_AUDIT_REQUIRED_COLUMNS.filter((column) => !cellHasMeaningfulValue(row.columns[column], column)); + if (missingColumns.length > 0) { + warnings.push({ + issueNumber: row.issueNumber, + section: currentKind, + lineNumber: row.lineNumber, + kind: "missing-required-columns", + message: "Board table row is missing branch, acceptance, related task, or progress information.", + missingColumns, + columns: row.columns, + rowPreview: preview(row.raw), + }); + } + } + rowIndex += 1; + } + sections.push({ + kind: currentKind, + heading: currentHeading, + headingLine: currentHeadingLine, + headerLine: index + 1, + headers, + rows, + }); + index = rowIndex - 1; + } + return { sections, warnings }; +} + +export function boardRowsByIssue(rows: BoardTableRow[]): Map<number, BoardTableRow[]> { + const map = new Map<number, BoardTableRow[]>(); + for (const row of rows) { + if (row.issueNumber === null) continue; + const list = map.get(row.issueNumber); + if (list === undefined) map.set(row.issueNumber, [row]); + else list.push(row); + } + return map; +} + +export function boardRowFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowField, string | null> { + const columnMap = boardColumnIndexMap(section.headers); + const valueFor = (kind: BoardColumnKind): string | null => { + const index = columnMap.get(kind); + return index === undefined ? null : row.cells[index] ?? ""; + }; + const acceptance = valueFor("acceptance"); + return { + progress: valueFor("progress"), + status: acceptance, + validation: acceptance, + branch: valueFor("branch"), + tasks: valueFor("relatedTask"), + focus: valueFor("focus"), + }; +} + +export function boardRowUpsertFieldValues(section: BoardTableSection, row: BoardTableRow): Record<BoardRowUpsertField, string | null> { + const columnMap = boardColumnIndexMap(section.headers); + const valueFor = (kind: BoardColumnKind): string | null => { + const index = columnMap.get(kind); + return index === undefined ? null : row.cells[index] ?? ""; + }; + const acceptance = valueFor("acceptance"); + return { + category: valueFor("category"), + branch: valueFor("branch"), + tasks: valueFor("relatedTask"), + summary: valueFor("summary"), + focus: valueFor("focus"), + validation: acceptance, + progress: valueFor("progress"), + status: valueFor("githubStatus"), + }; +} + +export function boardRowSummary(section: BoardTableSection, row: BoardTableRow): Record<string, unknown> { + return { + issueNumber: row.issueNumber, + section: row.section, + lineNumber: row.lineNumber, + heading: section.heading, + headers: section.headers, + title: row.title, + cells: row.cells, + fields: boardRowFieldValues(section, row), + raw: row.raw, + rowPreview: preview(row.raw), + }; +} + +export function boardRowsExactMatch( + sections: BoardTableSection[], + issueNumber: number, +): Array<{ section: BoardTableSection; row: BoardTableRow }> { + return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber); +} + +export function boardRowsForState(sections: BoardTableSection[], state: IssueListState): Array<{ section: BoardTableSection; row: BoardTableRow }> { + return sections + .filter((section) => state === "all" || section.kind === state) + .flatMap((section) => section.rows.map((row) => ({ section, row }))); +} + +export function boardDuplicateRows( + sections: BoardTableSection[], + issueNumber: number, +): Array<{ section: BoardTableSection; row: BoardTableRow }> { + return boardRowsForState(sections, "all").filter(({ row }) => row.issueNumber === issueNumber); +} + +export function targetBoardSection( + repo: string, + sections: BoardTableSection[], + kind: BoardMutationSection, + commandName: string, +): { ok: true; section: BoardTableSection } | GitHubCommandFailure { + const matches = sections.filter((section) => section.kind === kind); + if (matches.length === 0) { + return validationError(commandName, repo, `board ${kind.toUpperCase()} table was not found`, { + section: kind, + availableSections: sections.map((section) => ({ kind: section.kind, heading: section.heading, headerLine: section.headerLine })), + }) as GitHubCommandFailure; + } + if (matches.length > 1) { + return validationError(commandName, repo, `board ${kind.toUpperCase()} table is ambiguous`, { + section: kind, + matches: matches.map((section) => ({ heading: section.heading, headingLine: section.headingLine, headerLine: section.headerLine })), + }) as GitHubCommandFailure; + } + return { ok: true, section: matches[0] }; +} + +export function findBoardRow( + repo: string, + sections: BoardTableSection[], + issueNumber: number, + state: IssueListState = "all", +): BoardRowLookup | GitHubCommandFailure { + const matches = boardRowsForState(sections, state).filter(({ row }) => row.issueNumber === issueNumber); + if (matches.length === 0) { + return validationError("issue board-row", repo, `board row for issue #${issueNumber} was not found`, { + issueNumber, + state, + availableIssueNumbers: boardRowsForState(sections, state) + .map(({ row }) => row.issueNumber) + .filter((value): value is number => value !== null) + .sort((a, b) => a - b), + }) as GitHubCommandFailure; + } + if (matches.length > 1) { + return validationError("issue board-row", repo, `board row for issue #${issueNumber} is ambiguous`, { + issueNumber, + state, + matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), + }) as GitHubCommandFailure; + } + return { ok: true, ...matches[0], matches }; +} + +export function escapeMarkdownTableCell(value: string): string { + return value + .replace(/\r\n/g, " ") + .replace(/[\r\n]+/g, " ") + .replace(/\|/g, "\\|") + .trim(); +} + +export function boardRowValuePollutionEvidence(value: string): string[] { + return shellPollutionEvidence(value).filter((item) => item !== "bare-carriage-return"); +} + +export function renderMarkdownTableRow(cells: string[]): string { + return `| ${cells.map(escapeMarkdownTableCell).join(" | ")} |`; +} + +export function validateMarkdownTableRowLine(line: string): string | null { + if (!line.trim().startsWith("|") || !line.trim().endsWith("|")) return "row-file must contain a Markdown table row starting and ending with |"; + if (isMarkdownTableSeparator(line)) return "row-file must contain a data row, not a Markdown table separator"; + return null; +} + +export function readBoardRowFile(path: string | undefined, command: string): { path: string; raw: string; cells: string[] } { + if (path === undefined) throw new Error(`${command} requires --row-file <markdown-row-file>`); + if (!existsSync(path)) throw new Error(`row file not found: ${path}`); + const normalized = normalizeNewlines(readFileSync(path, "utf8")).trim(); + if (normalized.length === 0) throw new Error("row-file must contain exactly one non-empty Markdown table row"); + const lines = normalized.split("\n"); + if (lines.length !== 1) throw new Error("row-file must contain exactly one Markdown table row"); + const line = lines[0]; + const lineError = validateMarkdownTableRowLine(line); + if (lineError !== null) throw new Error(lineError); + return { path, raw: line, cells: markdownCells(line) }; +} + +export function replaceBoardBodyLine(body: string, lineNumber: number, newLine: string): string { + const normalized = normalizeNewlines(body); + const lines = normalized.split("\n"); + if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`); + lines[lineNumber - 1] = newLine; + return lines.join("\n"); +} + +export function removeBoardBodyLine(body: string, lineNumber: number): string { + const normalized = normalizeNewlines(body); + const lines = normalized.split("\n"); + if (lineNumber <= 0 || lineNumber > lines.length) throw new Error(`line ${lineNumber} is outside the board body`); + lines.splice(lineNumber - 1, 1); + return lines.join("\n"); +} + +export function insertBoardBodyLineAfter(body: string, lineNumber: number, newLine: string): string { + const normalized = normalizeNewlines(body); + const lines = normalized.split("\n"); + if (lineNumber < 0 || lineNumber > lines.length) throw new Error(`insert line ${lineNumber} is outside the board body`); + lines.splice(lineNumber, 0, newLine); + return lines.join("\n"); +} + +export function boardSectionInsertAfterLine(section: BoardTableSection): number { + if (section.rows.length === 0) return section.headerLine + 1; + return section.rows[section.rows.length - 1].lineNumber; +} + +export function boardBodySafetyIssue(repo: string, boardIssueNumber: number, body: string, options: GitHubOptions): GitHubCommandResult | null { + const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile }; + return validateIssueBodyGuard(repo, boardIssueNumber, body, guardOptions); +} + +export function boardBodyGuardSummary(boardIssueNumber: number, body: string, options: GitHubOptions): Record<string, unknown> { + const guardOptions: GitHubOptions = { ...options, bodyProfile: boardIssueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE ? "code-queue-board" : options.bodyProfile }; + return issueEditGuardSummary(boardIssueNumber, body, guardOptions); +} diff --git a/scripts/src/gh/board-plans.ts b/scripts/src/gh/board-plans.ts new file mode 100644 index 00000000..2083af6e --- /dev/null +++ b/scripts/src/gh/board-plans.ts @@ -0,0 +1,962 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:3193-4143. + +import path from "node:path"; +import { bodySha, preview } from "./auth-and-safety"; +import { boardColumnIndexMap, boardColumnKindForField, boardDuplicateRows, boardHeaderColumnKind, boardIssueColumnIndex, boardRowFromCells, boardRowsExactMatch, boardRowSummary, boardRowUpsertFieldValues, boardRowValuePollutionEvidence, boardSectionInsertAfterLine, findBoardRow, insertBoardBodyLineAfter, normalizeBoardHeader, removeBoardBodyLine, renderMarkdownTableRow, replaceBoardBodyLine, stripMarkdownInline, targetBoardSection } from "./board-parser"; +import { normalizeNewlines } from "./body-profiles"; +import { validationError } from "./client"; +import { BOARD_MUTATION_SECTIONS, BOARD_ROW_FIELDS, BOARD_ROW_UPSERT_TEXT_FIELDS } from "./types"; +import type { BoardColumnKind, BoardGithubStatus, BoardMutationSection, BoardRowField, BoardRowMutationPlanResult, BoardRowUpdatePlanResult, BoardRowUpsertField, BoardRowUpsertTextField, BoardRowUpsertValues, BoardRowValidationWarning, BoardTableRow, BoardTableSection, GitHubCommandFailure, GitHubIssue, GitHubOptions } from "./types"; + +export function boardRowUpdatePlan( + repo: string, + boardIssue: GitHubIssue, + issueNumber: number, + field: BoardRowField, + value: string, + parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, +): BoardRowUpdatePlanResult | GitHubCommandFailure { + const found = findBoardRow(repo, parsed.sections, issueNumber); + if (found.ok === false) return found; + const { section, row } = found; + const targetColumn = boardColumnKindForField(field); + const columnMap = boardColumnIndexMap(section.headers); + const columnIndex = columnMap.get(targetColumn); + if (columnIndex === undefined) { + return validationError("issue board-row update", repo, `board table does not contain a column for --field ${field}`, { + issueNumber, + field, + targetColumn, + section: section.kind, + headers: section.headers, + supportedFields: BOARD_ROW_FIELDS.slice(), + }) as GitHubCommandFailure; + } + const oldCells = row.cells.slice(); + const newCells = oldCells.slice(); + while (newCells.length < section.headers.length) newCells.push(""); + newCells[columnIndex] = value; + const newRow = renderMarkdownTableRow(newCells); + const oldBody = boardIssue.body ?? ""; + let newBody: string; + try { + newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow); + } catch (error) { + return validationError("issue board-row update", repo, error instanceof Error ? error.message : String(error), { + issueNumber, + field, + lineNumber: row.lineNumber, + }) as GitHubCommandFailure; + } + return { + ok: true, + newBody, + plan: { + repo, + boardIssue: boardIssue.number, + issueNumber, + field, + targetColumn, + targetColumnIndex: columnIndex, + section: section.kind, + lineNumber: row.lineNumber, + oldRow: row.raw, + newRow, + oldCells, + newCells, + oldValue: oldCells[columnIndex] ?? "", + newValue: value, + row: { + old: boardRowSummary(section, row), + new: { + ...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }), + updatedField: field, + }, + }, + body: { + oldBodyChars: oldBody.length, + oldBodySha: bodySha(oldBody), + newBodyChars: newBody.length, + newBodySha: bodySha(newBody), + changed: oldBody !== newBody, + }, + tableParser: { + reused: "parseBoardTables", + rowValidationWarnings: parsed.warnings.length, + }, + request: { + method: "PATCH", + path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, + body: { bodyChars: newBody.length }, + }, + }, + }; +} + +export function expectedBoardGithubStatus(section: BoardMutationSection): BoardGithubStatus { + return section === "open" ? "OPEN" : "CLOSED"; +} + +export function normalizeBoardGithubStatusCell(value: string | undefined): BoardGithubStatus | null { + const normalized = stripMarkdownInline(value ?? "").toUpperCase().replace(/\s+/g, ""); + if (normalized === "OPEN") return "OPEN"; + if (normalized === "CLOSED") return "CLOSED"; + return null; +} + +export function boardHeaderShape(headers: string[]): string[] { + return headers.map((header) => normalizeBoardHeader(header)); +} + +export function sameBoardHeaderShape(a: string[], b: string[]): boolean { + const left = boardHeaderShape(a); + const right = boardHeaderShape(b); + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function boardRowDuplicateFailure( + repo: string, + commandName: string, + sections: BoardTableSection[], + issueNumber: number, +): GitHubCommandFailure | null { + const duplicates = boardDuplicateRows(sections, issueNumber); + if (duplicates.length === 0) return null; + return validationError(commandName, repo, `board row for issue #${issueNumber} already exists; refusing duplicate row`, { + issueNumber, + matches: duplicates.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), + }) as GitHubCommandFailure; +} + +export function boardStatusOptionFailure( + repo: string, + commandName: string, + targetSection: BoardMutationSection, + status: BoardGithubStatus | undefined, +): GitHubCommandFailure | null { + if (status === undefined) return null; + const expected = expectedBoardGithubStatus(targetSection); + if (status === expected) return null; + return validationError(commandName, repo, `--status ${status} conflicts with --to ${targetSection}; expected ${expected}`, { + targetSection, + status, + expectedStatus: expected, + }) as GitHubCommandFailure; +} + +export function boardRowStatusConsistency( + repo: string, + commandName: string, + section: BoardTableSection, + row: BoardTableRow, + role: "source" | "target", +): { ok: true; githubStatusColumnIndex: number; actualStatus: BoardGithubStatus | null; expectedStatus: BoardGithubStatus } | GitHubCommandFailure { + const columnMap = boardColumnIndexMap(section.headers); + const githubStatusColumnIndex = columnMap.get("githubStatus"); + if (githubStatusColumnIndex === undefined) { + return validationError(commandName, repo, `${role} board table does not contain a GitHub 状态 column`, { + section: section.kind, + headers: section.headers, + lineNumber: row.lineNumber, + rowPreview: preview(row.raw), + }) as GitHubCommandFailure; + } + const expectedStatus = expectedBoardGithubStatus(section.kind); + const actualStatus = normalizeBoardGithubStatusCell(row.cells[githubStatusColumnIndex]); + if (actualStatus !== expectedStatus) { + return validationError(commandName, repo, `${role} row GitHub 状态 ${actualStatus ?? "missing"} conflicts with ${section.kind.toUpperCase()} section; expected ${expectedStatus}`, { + section: section.kind, + lineNumber: row.lineNumber, + rowPreview: preview(row.raw), + actualStatus, + expectedStatus, + githubStatusColumnIndex, + headers: section.headers, + }) as GitHubCommandFailure; + } + return { ok: true, githubStatusColumnIndex, actualStatus, expectedStatus }; +} + +export function boardRowUpsertValueEntries(values: BoardRowUpsertValues): Array<{ field: BoardRowUpsertField; value: string }> { + const entries: Array<{ field: BoardRowUpsertField; value: string }> = []; + for (const field of BOARD_ROW_UPSERT_TEXT_FIELDS) { + const value = values[field]; + if (value !== undefined) entries.push({ field, value }); + } + if (values.status !== undefined) entries.push({ field: "status", value: values.status }); + return entries; +} + +export function boardColumnKindForUpsertField(field: BoardRowUpsertField): BoardColumnKind { + if (field === "category") return "category"; + if (field === "branch") return "branch"; + if (field === "tasks") return "relatedTask"; + if (field === "summary") return "summary"; + if (field === "focus") return "focus"; + if (field === "validation") return "acceptance"; + if (field === "status") return "githubStatus"; + return "progress"; +} + +export function renderBoardIssueCell(repo: string, issueNumber: number, summary: string | undefined): string { + const trimmed = (summary ?? "").trim(); + if (trimmed.length === 0) return `#${issueNumber}`; + const targetHref = `https://github.com/${repo}/issues/${issueNumber}`; + const firstMarkdownIssue = /^\s*\[[^\]]*\]\(([^)]*)\)/u.exec(trimmed)?.[1] ?? ""; + if (new RegExp(`/issues/${issueNumber}(?:\\b|$)`, "u").test(firstMarkdownIssue)) return trimmed; + return `[#${issueNumber}](${targetHref}) ${trimmed}`; +} + +export function boardRowUpsertTargetColumn( + repo: string, + commandName: string, + section: BoardTableSection, + issueNumber: number, + field: BoardRowUpsertField, +): { ok: true; columnIndex: number; targetColumn: BoardColumnKind | "issue"; usesIssueCell: boolean } | GitHubCommandFailure { + const columnMap = boardColumnIndexMap(section.headers); + if (field === "summary") { + const summaryIndex = columnMap.get("summary"); + if (summaryIndex !== undefined) return { ok: true, columnIndex: summaryIndex, targetColumn: "summary", usesIssueCell: false }; + const issueColumnIndex = boardIssueColumnIndex(section.headers); + if (issueColumnIndex !== null) return { ok: true, columnIndex: issueColumnIndex, targetColumn: "issue", usesIssueCell: true }; + } + const targetColumn = boardColumnKindForUpsertField(field); + const columnIndex = columnMap.get(targetColumn); + if (columnIndex === undefined) { + return validationError(commandName, repo, `board table does not contain a column for --${field}`, { + issueNumber, + field, + targetColumn, + section: section.kind, + headers: section.headers, + }) as GitHubCommandFailure; + } + return { ok: true, columnIndex, targetColumn, usesIssueCell: false }; +} + +export function boardRowUpsertRenderedValue( + repo: string, + issueNumber: number, + field: BoardRowUpsertField, + value: string, + targetColumn: BoardColumnKind | "issue", +): string { + if (field === "summary" && targetColumn === "issue") return renderBoardIssueCell(repo, issueNumber, value); + return value; +} + +export function boardRowUpsertPollutionFailure( + repo: string, + commandName: string, + issueNumber: number, + values: BoardRowUpsertValues, +): GitHubCommandFailure | null { + const findings = BOARD_ROW_UPSERT_TEXT_FIELDS + .map((field) => { + const value = values[field]; + if (value === undefined) return null; + const evidence = boardRowValuePollutionEvidence(value); + return evidence.length === 0 ? null : { field, valuePreview: preview(value), evidence }; + }) + .filter((item): item is { field: BoardRowUpsertTextField; valuePreview: string; evidence: string[] } => item !== null); + if (findings.length === 0) return null; + return validationError(commandName, repo, "issue board-row upsert field value contains literal shell escape text that would pollute a board cell; pass real newlines or plain text instead", { + issueNumber, + shellPollution: { suspected: true, findings }, + }) as GitHubCommandFailure; +} + +export function boardRowUpsertUpdatePlan( + repo: string, + boardIssue: GitHubIssue, + issueNumber: number, + found: { section: BoardTableSection; row: BoardTableRow }, + values: BoardRowUpsertValues, + requestedSection: BoardMutationSection | undefined, + parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, +): BoardRowMutationPlanResult | GitHubCommandFailure { + const commandName = "issue board-row upsert"; + const { section, row } = found; + if (requestedSection !== undefined && requestedSection !== section.kind) { + return validationError(commandName, repo, `--section ${requestedSection} conflicts with existing row in ${section.kind}; use gh issue board-row move for section migration`, { + issueNumber, + requestedSection, + existingSection: section.kind, + moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${requestedSection}`, + }) as GitHubCommandFailure; + } + if (values.status !== undefined) { + const expectedStatus = expectedBoardGithubStatus(section.kind); + if (values.status !== expectedStatus) { + return validationError(commandName, repo, `--status ${values.status} conflicts with existing ${section.kind.toUpperCase()} section; use gh issue board-row move for section migration`, { + issueNumber, + existingSection: section.kind, + requestedStatus: values.status, + expectedStatus, + moveCommand: `bun scripts/cli.ts gh issue board-row move ${issueNumber} --board-issue ${boardIssue.number} --to ${values.status === "OPEN" ? "open" : "closed"} --status ${values.status}`, + }) as GitHubCommandFailure; + } + } + const entries = boardRowUpsertValueEntries(values); + if (entries.length === 0) { + return validationError(commandName, repo, "issue board-row upsert found an existing row but no upsert fields were provided", { + issueNumber, + existingSection: section.kind, + supportedFields: ["--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--status"], + }) as GitHubCommandFailure; + } + if (row.cells.length !== section.headers.length) { + return validationError(commandName, repo, "existing row column count does not match the board table header", { + issueNumber, + section: section.kind, + expectedColumns: section.headers.length, + actualColumns: row.cells.length, + headers: section.headers, + lineNumber: row.lineNumber, + rowPreview: preview(row.raw), + }) as GitHubCommandFailure; + } + const oldCells = row.cells.slice(); + const newCells = oldCells.slice(); + const changedFields: Record<string, unknown>[] = []; + for (const entry of entries) { + const target = boardRowUpsertTargetColumn(repo, commandName, section, issueNumber, entry.field); + if (target.ok === false) return target; + const newValue = boardRowUpsertRenderedValue(repo, issueNumber, entry.field, entry.value, target.targetColumn); + const oldValue = oldCells[target.columnIndex] ?? ""; + newCells[target.columnIndex] = newValue; + changedFields.push({ + field: entry.field, + targetColumn: target.targetColumn, + targetColumnIndex: target.columnIndex, + requestedValue: entry.value, + oldValue, + newValue, + changed: oldValue !== newValue, + }); + } + const newRow = renderMarkdownTableRow(newCells); + const oldBody = boardIssue.body ?? ""; + let newBody: string; + try { + newBody = replaceBoardBodyLine(oldBody, row.lineNumber, newRow); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { + issueNumber, + lineNumber: row.lineNumber, + }) as GitHubCommandFailure; + } + return { + ok: true, + newBody, + plan: { + repo, + boardIssue: boardIssue.number, + issueNumber, + operation: "update", + action: "update", + section: section.kind, + lineNumber: row.lineNumber, + requestedSection: requestedSection ?? null, + providedFields: entries.map((entry) => entry.field), + changedFields, + oldRow: row.raw, + newRow, + oldCells, + newCells, + row: { + old: boardRowSummary(section, row), + new: { + ...boardRowSummary(section, { ...row, raw: newRow, cells: newCells }), + upsertFields: boardRowUpsertFieldValues(section, { ...row, raw: newRow, cells: newCells }), + }, + }, + linePlan: { + action: "replace", + section: section.kind, + lineNumber: row.lineNumber, + }, + sectionPlan: { + kind: section.kind, + heading: section.heading, + headerLine: section.headerLine, + rowCount: section.rows.length, + }, + body: { + oldBodyChars: oldBody.length, + oldBodySha: bodySha(oldBody), + newBodyChars: newBody.length, + newBodySha: bodySha(newBody), + changed: oldBody !== newBody, + }, + tableParser: { + reused: "parseBoardTables", + rowValidationWarnings: parsed.warnings.length, + }, + request: { + method: "PATCH", + path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, + body: { bodyChars: newBody.length }, + }, + }, + }; +} + +export function boardRowUpsertAddPlan( + repo: string, + boardIssue: GitHubIssue, + issueNumber: number, + sectionKind: BoardMutationSection, + values: BoardRowUpsertValues, + parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, +): BoardRowMutationPlanResult | GitHubCommandFailure { + const commandName = "issue board-row upsert"; + const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName); + if (targetLookup.ok === false) return targetLookup; + const target = targetLookup.section; + const issueColumnIndex = boardIssueColumnIndex(target.headers); + if (issueColumnIndex === null) { + return validationError(commandName, repo, "board table does not contain an Issue column required for generated upsert rows", { + boardIssue: boardIssue.number, + section: sectionKind, + headers: target.headers, + }) as GitHubCommandFailure; + } + const columnMap = boardColumnIndexMap(target.headers); + const githubStatusColumnIndex = columnMap.get("githubStatus"); + if (githubStatusColumnIndex === undefined) { + return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for generated upsert rows", { + boardIssue: boardIssue.number, + section: sectionKind, + headers: target.headers, + }) as GitHubCommandFailure; + } + const expectedStatus = expectedBoardGithubStatus(sectionKind); + const requestedStatus = values.status ?? expectedStatus; + if (requestedStatus !== expectedStatus) { + return validationError(commandName, repo, `--status ${requestedStatus} conflicts with --section ${sectionKind}; expected ${expectedStatus}`, { + issueNumber, + section: sectionKind, + requestedStatus, + expectedStatus, + }) as GitHubCommandFailure; + } + const appliedFields = new Set<BoardRowUpsertField>(); + const missingFields: BoardRowUpsertTextField[] = []; + const unsupportedColumns: Array<{ index: number; header: string }> = []; + const summaryColumnIndex = columnMap.get("summary"); + const cells = target.headers.map((header, index) => { + if (index === issueColumnIndex) { + if (summaryColumnIndex === undefined && values.summary !== undefined) appliedFields.add("summary"); + return summaryColumnIndex === undefined ? renderBoardIssueCell(repo, issueNumber, values.summary) : `#${issueNumber}`; + } + const kind = boardHeaderColumnKind(header); + if (kind === "githubStatus") { + if (values.status !== undefined) appliedFields.add("status"); + return requestedStatus; + } + const valueFor = (field: BoardRowUpsertTextField, value: string | undefined): string => { + if (value === undefined) { + missingFields.push(field); + return ""; + } + appliedFields.add(field); + return value; + }; + if (kind === "category") return valueFor("category", values.category); + if (kind === "branch") return valueFor("branch", values.branch); + if (kind === "relatedTask") return valueFor("tasks", values.tasks); + if (kind === "summary") return valueFor("summary", values.summary); + if (kind === "focus") return valueFor("focus", values.focus); + if (kind === "acceptance") return valueFor("validation", values.validation); + if (kind === "progress") return valueFor("progress", values.progress); + unsupportedColumns.push({ index, header }); + return ""; + }); + if (unsupportedColumns.length > 0) { + return validationError(commandName, repo, "target board table contains unsupported column(s) for generated upsert rows", { + issueNumber, + section: sectionKind, + unsupportedColumns, + headers: target.headers, + }) as GitHubCommandFailure; + } + const unappliedFields = boardRowUpsertValueEntries(values) + .map((entry) => entry.field) + .filter((field) => !appliedFields.has(field)); + if (unappliedFields.length > 0) { + return validationError(commandName, repo, "target board table does not contain column(s) for supplied upsert field(s)", { + issueNumber, + section: sectionKind, + unappliedFields, + headers: target.headers, + }) as GitHubCommandFailure; + } + const uniqueMissingFields = Array.from(new Set(missingFields)); + if (uniqueMissingFields.length > 0) { + return validationError(commandName, repo, "issue board-row upsert add requires values for every generated row column", { + issueNumber, + section: sectionKind, + missingFields: uniqueMissingFields, + requiredOptions: uniqueMissingFields.map((field) => `--${field}`), + headers: target.headers, + }) as GitHubCommandFailure; + } + const newRow = renderMarkdownTableRow(cells); + const row = boardRowFromCells(target, newRow, cells, 0); + if (row.issueNumber !== issueNumber) { + return validationError(commandName, repo, "generated Issue column does not match the requested issue number", { + issueNumber, + issueColumnIndex, + issueCell: cells[issueColumnIndex] ?? "", + detectedIssueNumber: row.issueNumber, + rowPreview: preview(newRow), + }) as GitHubCommandFailure; + } + const actualStatus = normalizeBoardGithubStatusCell(cells[githubStatusColumnIndex]); + if (actualStatus !== expectedStatus) { + return validationError(commandName, repo, "generated GitHub 状态 column does not match the target section", { + issueNumber, + section: sectionKind, + expectedStatus, + actualStatus, + statusColumnIndex: githubStatusColumnIndex, + statusCell: cells[githubStatusColumnIndex] ?? "", + rowPreview: preview(newRow), + }) as GitHubCommandFailure; + } + const oldBody = boardIssue.body ?? ""; + const insertAfterLine = boardSectionInsertAfterLine(target); + let newBody: string; + try { + newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, newRow); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { + issueNumber, + section: sectionKind, + insertAfterLine, + }) as GitHubCommandFailure; + } + return { + ok: true, + newBody, + plan: { + repo, + boardIssue: boardIssue.number, + issueNumber, + operation: "add", + action: "add", + section: sectionKind, + insertAfterLine, + providedFields: boardRowUpsertValueEntries(values).map((entry) => entry.field), + newRow, + newCells: cells, + row: { + old: null, + new: { + ...boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }), + upsertFields: boardRowUpsertFieldValues(target, { ...row, lineNumber: insertAfterLine + 1 }), + }, + }, + linePlan: { + action: "insert", + section: sectionKind, + insertAfterLine, + newLineNumber: insertAfterLine + 1, + }, + sectionPlan: { + kind: sectionKind, + heading: target.heading, + headerLine: target.headerLine, + rowCount: target.rows.length, + }, + validation: { + issueColumnIndex, + githubStatusColumnIndex, + expectedStatus, + actualStatus, + columnCount: cells.length, + }, + body: { + oldBodyChars: oldBody.length, + oldBodySha: bodySha(oldBody), + newBodyChars: newBody.length, + newBodySha: bodySha(newBody), + changed: oldBody !== newBody, + }, + tableParser: { + reused: "parseBoardTables", + rowValidationWarnings: parsed.warnings.length, + }, + request: { + method: "PATCH", + path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, + body: { bodyChars: newBody.length }, + }, + }, + }; +} + +export function boardRowUpsertPlan( + repo: string, + boardIssue: GitHubIssue, + issueNumber: number, + options: GitHubOptions, + parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, +): BoardRowMutationPlanResult | GitHubCommandFailure { + const commandName = "issue board-row upsert"; + const pollutionFailure = boardRowUpsertPollutionFailure(repo, commandName, issueNumber, options.boardRowUpsertValues); + if (pollutionFailure !== null) return pollutionFailure; + const matches = boardRowsExactMatch(parsed.sections, issueNumber); + if (matches.length > 1) { + return validationError(commandName, repo, `board row for issue #${issueNumber} is ambiguous; refusing upsert`, { + issueNumber, + matches: matches.map(({ section, row }) => ({ section: section.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw) })), + }) as GitHubCommandFailure; + } + if (matches.length === 1) { + return boardRowUpsertUpdatePlan(repo, boardIssue, issueNumber, matches[0], options.boardRowUpsertValues, options.boardSection, parsed); + } + if (options.boardSection === undefined) { + return validationError(commandName, repo, "issue board-row upsert add requires --section open|closed when the row does not already exist", { + issueNumber, + supportedSections: BOARD_MUTATION_SECTIONS.slice(), + }) as GitHubCommandFailure; + } + return boardRowUpsertAddPlan(repo, boardIssue, issueNumber, options.boardSection, options.boardRowUpsertValues, parsed); +} + +export function boardRowAddPlan( + repo: string, + boardIssue: GitHubIssue, + issueNumber: number, + sectionKind: BoardMutationSection, + rowFile: { path: string; raw: string; cells: string[] }, + parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, +): BoardRowMutationPlanResult | GitHubCommandFailure { + const commandName = "issue board-row add"; + const duplicate = boardRowDuplicateFailure(repo, commandName, parsed.sections, issueNumber); + if (duplicate !== null) return duplicate; + const targetLookup = targetBoardSection(repo, parsed.sections, sectionKind, commandName); + if (targetLookup.ok === false) return targetLookup; + const target = targetLookup.section; + const issueColumnIndex = boardIssueColumnIndex(target.headers); + if (issueColumnIndex === null) { + return validationError(commandName, repo, "board table does not contain an Issue column required for row-file validation", { + boardIssue: boardIssue.number, + section: sectionKind, + headers: target.headers, + }) as GitHubCommandFailure; + } + const columnMap = boardColumnIndexMap(target.headers); + const githubStatusColumnIndex = columnMap.get("githubStatus"); + if (githubStatusColumnIndex === undefined) { + return validationError(commandName, repo, "board table does not contain a GitHub 状态 column required for add section validation", { + boardIssue: boardIssue.number, + section: sectionKind, + headers: target.headers, + }) as GitHubCommandFailure; + } + if (rowFile.cells.length !== target.headers.length) { + return validationError(commandName, repo, "row-file column count does not match the target board table header", { + boardIssue: boardIssue.number, + section: sectionKind, + expectedColumns: target.headers.length, + actualColumns: rowFile.cells.length, + headers: target.headers, + rowFile: rowFile.path, + rowPreview: preview(rowFile.raw), + }) as GitHubCommandFailure; + } + const row = boardRowFromCells(target, rowFile.raw, rowFile.cells, 0); + if (row.issueNumber !== issueNumber) { + return validationError(commandName, repo, "row-file Issue column does not match the requested issue number", { + issueNumber, + issueColumnIndex, + issueCell: rowFile.cells[issueColumnIndex] ?? "", + detectedIssueNumber: row.issueNumber, + rowPreview: preview(rowFile.raw), + }) as GitHubCommandFailure; + } + const expectedStatus = expectedBoardGithubStatus(sectionKind); + const actualStatus = normalizeBoardGithubStatusCell(rowFile.cells[githubStatusColumnIndex]); + if (actualStatus !== expectedStatus) { + return validationError(commandName, repo, "row-file GitHub 状态 column does not match the target section", { + issueNumber, + section: sectionKind, + expectedStatus, + actualStatus, + statusColumnIndex: githubStatusColumnIndex, + statusCell: rowFile.cells[githubStatusColumnIndex] ?? "", + rowPreview: preview(rowFile.raw), + }) as GitHubCommandFailure; + } + const oldBody = boardIssue.body ?? ""; + const insertAfterLine = boardSectionInsertAfterLine(target); + let newBody: string; + try { + newBody = insertBoardBodyLineAfter(oldBody, insertAfterLine, rowFile.raw); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { + issueNumber, + section: sectionKind, + insertAfterLine, + }) as GitHubCommandFailure; + } + return { + ok: true, + newBody, + plan: { + repo, + boardIssue: boardIssue.number, + issueNumber, + action: "add", + section: sectionKind, + insertAfterLine, + rowFile: rowFile.path, + newRow: rowFile.raw, + newCells: rowFile.cells, + row: { + old: null, + new: boardRowSummary(target, { ...row, lineNumber: insertAfterLine + 1 }), + }, + linePlan: { + action: "insert", + section: sectionKind, + insertAfterLine, + newLineNumber: insertAfterLine + 1, + }, + sectionPlan: { + kind: sectionKind, + heading: target.heading, + headerLine: target.headerLine, + rowCount: target.rows.length, + }, + validation: { + issueColumnIndex, + githubStatusColumnIndex, + expectedStatus, + actualStatus, + columnCount: rowFile.cells.length, + }, + body: { + oldBodyChars: oldBody.length, + oldBodySha: bodySha(oldBody), + newBodyChars: newBody.length, + newBodySha: bodySha(newBody), + changed: oldBody !== newBody, + }, + tableParser: { + reused: "parseBoardTables", + rowValidationWarnings: parsed.warnings.length, + }, + request: { + method: "PATCH", + path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, + body: { bodyChars: newBody.length }, + }, + }, + }; +} + +export function moveBoardBodyRow(body: string, sourceLineNumber: number, insertAfterLine: number, newLine: string): string { + const normalized = normalizeNewlines(body); + const lines = normalized.split("\n"); + if (sourceLineNumber <= 0 || sourceLineNumber > lines.length) throw new Error(`line ${sourceLineNumber} is outside the board body`); + if (insertAfterLine < 0 || insertAfterLine > lines.length) throw new Error(`insert line ${insertAfterLine} is outside the board body`); + lines.splice(sourceLineNumber - 1, 1); + const adjustedInsertAfterLine = sourceLineNumber <= insertAfterLine ? insertAfterLine - 1 : insertAfterLine; + lines.splice(adjustedInsertAfterLine, 0, newLine); + return lines.join("\n"); +} + +export function boardRowMovePlan( + repo: string, + boardIssue: GitHubIssue, + issueNumber: number, + targetKind: BoardMutationSection, + status: BoardGithubStatus | undefined, + parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, +): BoardRowMutationPlanResult | GitHubCommandFailure { + const commandName = "issue board-row move"; + const statusFailure = boardStatusOptionFailure(repo, commandName, targetKind, status); + if (statusFailure !== null) return statusFailure; + const found = findBoardRow(repo, parsed.sections, issueNumber); + if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure; + const sourceConsistency = boardRowStatusConsistency(repo, commandName, found.section, found.row, "source"); + if (sourceConsistency.ok === false) return sourceConsistency; + const targetLookup = targetBoardSection(repo, parsed.sections, targetKind, commandName); + if (targetLookup.ok === false) return targetLookup; + const target = targetLookup.section; + const { section: source, row } = found; + if (!sameBoardHeaderShape(source.headers, target.headers)) { + return validationError(commandName, repo, "source and target board tables have different headers; refusing to move a raw row across mismatched columns", { + issueNumber, + source: { section: source.kind, headers: source.headers, headerLine: source.headerLine }, + target: { section: target.kind, headers: target.headers, headerLine: target.headerLine }, + }) as GitHubCommandFailure; + } + if (row.cells.length !== target.headers.length) { + return validationError(commandName, repo, "source row column count does not match the target board table header", { + issueNumber, + source: { section: source.kind, lineNumber: row.lineNumber, rowPreview: preview(row.raw), cells: row.cells.length }, + target: { section: target.kind, headers: target.headers, columns: target.headers.length }, + }) as GitHubCommandFailure; + } + const columnMap = boardColumnIndexMap(target.headers); + const githubStatusColumnIndex = columnMap.get("githubStatus"); + if (githubStatusColumnIndex === undefined) { + return validationError(commandName, repo, "target board table does not contain a GitHub 状态 column required for section migration", { + issueNumber, + targetSection: targetKind, + headers: target.headers, + }) as GitHubCommandFailure; + } + const oldCells = row.cells.slice(); + const newCells = oldCells.slice(); + const expectedStatus = expectedBoardGithubStatus(targetKind); + if (githubStatusColumnIndex !== undefined) newCells[githubStatusColumnIndex] = status ?? expectedStatus; + const newRow = renderMarkdownTableRow(newCells); + const oldBody = boardIssue.body ?? ""; + const insertAfterLine = boardSectionInsertAfterLine(target); + let newBody: string; + try { + newBody = source.kind === target.kind + ? replaceBoardBodyLine(oldBody, row.lineNumber, newRow) + : moveBoardBodyRow(oldBody, row.lineNumber, insertAfterLine, newRow); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { + issueNumber, + sourceLineNumber: row.lineNumber, + insertAfterLine, + }) as GitHubCommandFailure; + } + return { + ok: true, + newBody, + plan: { + repo, + boardIssue: boardIssue.number, + issueNumber, + action: source.kind === target.kind ? "move-within-section" : "move", + from: source.kind, + to: target.kind, + sourceLineNumber: row.lineNumber, + insertAfterLine, + oldRow: row.raw, + newRow, + oldCells, + newCells, + row: { + old: boardRowSummary(source, row), + new: boardRowSummary(target, { ...row, section: target.kind, raw: newRow, cells: newCells, lineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1 }), + }, + linePlan: { + action: source.kind === target.kind ? "replace" : "move", + sourceLineNumber: row.lineNumber, + insertAfterLine, + newLineNumber: source.kind === target.kind ? row.lineNumber : insertAfterLine + 1, + }, + sectionPlan: { + from: source.kind, + to: target.kind, + sourceHeading: source.heading, + targetHeading: target.heading, + }, + status: { + requested: status ?? null, + targetDefault: expectedStatus, + githubStatusColumnIndex: githubStatusColumnIndex ?? null, + old: oldCells[githubStatusColumnIndex] ?? "", + new: newCells[githubStatusColumnIndex] ?? "", + }, + body: { + oldBodyChars: oldBody.length, + oldBodySha: bodySha(oldBody), + newBodyChars: newBody.length, + newBodySha: bodySha(newBody), + changed: oldBody !== newBody, + }, + tableParser: { + reused: "parseBoardTables", + rowValidationWarnings: parsed.warnings.length, + }, + request: { + method: "PATCH", + path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, + body: { bodyChars: newBody.length }, + }, + }, + }; +} + +export function boardRowDeletePlan( + repo: string, + boardIssue: GitHubIssue, + issueNumber: number, + parsed: { sections: BoardTableSection[]; warnings: BoardRowValidationWarning[] }, +): BoardRowMutationPlanResult | GitHubCommandFailure { + const commandName = "issue board-row delete"; + const found = findBoardRow(repo, parsed.sections, issueNumber); + if (found.ok === false) return { ...found, command: commandName, repo } as GitHubCommandFailure; + const { section, row } = found; + const sourceConsistency = boardRowStatusConsistency(repo, commandName, section, row, "source"); + if (sourceConsistency.ok === false) return sourceConsistency; + const oldBody = boardIssue.body ?? ""; + let newBody: string; + try { + newBody = removeBoardBodyLine(oldBody, row.lineNumber); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { + issueNumber, + lineNumber: row.lineNumber, + }) as GitHubCommandFailure; + } + return { + ok: true, + newBody, + plan: { + repo, + boardIssue: boardIssue.number, + issueNumber, + action: "delete", + section: section.kind, + lineNumber: row.lineNumber, + oldRow: row.raw, + oldCells: row.cells, + row: { + old: boardRowSummary(section, row), + new: null, + }, + linePlan: { + action: "remove", + section: section.kind, + lineNumber: row.lineNumber, + }, + sectionPlan: { + kind: section.kind, + heading: section.heading, + headerLine: section.headerLine, + rowCount: section.rows.length, + }, + body: { + oldBodyChars: oldBody.length, + oldBodySha: bodySha(oldBody), + newBodyChars: newBody.length, + newBodySha: bodySha(newBody), + changed: oldBody !== newBody, + }, + tableParser: { + reused: "parseBoardTables", + rowValidationWarnings: parsed.warnings.length, + }, + request: { + method: "PATCH", + path: `/repos/{owner}/{repo}/issues/${boardIssue.number}`, + body: { bodyChars: newBody.length }, + }, + }, + }; +} diff --git a/scripts/src/gh/body-guards.ts b/scripts/src/gh/body-guards.ts new file mode 100644 index 00000000..0c5682cd --- /dev/null +++ b/scripts/src/gh/body-guards.ts @@ -0,0 +1,234 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:4462-4684. + +import path from "node:path"; +import { bodySafetySignals, bodySha, normalizeExpectedSha, preview, previewLines } from "./auth-and-safety"; +import { sortedIssueEntries } from "./board-parser"; +import { codeQueueBoardCommanderBriefHint, codeQueueBoardHintHasCommanderBriefUpdates, codeQueueBoardHintHasHwlabProductRouting, extractTimelineSections, normalizeNewlines } from "./body-profiles"; +import { validationError } from "./client"; +import { CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDER_BRIEF_TARGET_ISSUE, DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN, DAILY_COMMANDER_BRIEF_TITLE_PATTERN, ISSUE_BODY_PROFILES, LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN, MIN_SAFE_ISSUE_BODY_CHARS } from "./types"; +import type { BoardIgnoredIssue, BoardIgnoreReason, BoardIssueEntry, GitHubCommandResult, GitHubComment, GitHubIssue, GitHubOptions, IssueBodyProfileName, IssueBodyProfileOption, IssueProfileValidationContext } from "./types"; + +export function ignoredIssueList(issues: BoardIssueEntry[], ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] { + return sortedIssueEntries(issues.filter((issue) => ignoreMap.has(issue.number))).map((issue) => ({ + number: issue.number, + reason: ignoreMap.get(issue.number) ?? "ignored", + title: issue.title, + state: issue.state, + url: issue.url, + })); +} + +export function ignoredBoardOnlyIssues(issueNumbers: number[], issueMap: Map<number, BoardIssueEntry>, ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] { + return issueNumbers + .filter((issueNumber) => ignoreMap.has(issueNumber) && !issueMap.has(issueNumber)) + .sort((a, b) => a - b) + .map((issueNumber) => ({ number: issueNumber, reason: ignoreMap.get(issueNumber) ?? "ignored" })); +} + +export function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null { + if (requested !== "auto") return requested; + if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board"; + if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief"; + return null; +} + +export function firstMarkdownLine(body: string | null | undefined): string { + return normalizeNewlines(body ?? "").split("\n").find((line) => line.trim().length > 0)?.trim() ?? ""; +} + +export function isDailyCommanderBriefTitle(title: string | null | undefined): boolean { + return DAILY_COMMANDER_BRIEF_TITLE_PATTERN.test((title ?? "").trim()); +} + +export function isDailyCommanderBriefBodyHeading(line: string): boolean { + return DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim()); +} + +export function isLegacyCommanderBriefBodyHeading(line: string): boolean { + return LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim()); +} + +export function currentBodyLooksLikeCommanderBrief(body: string | null | undefined): boolean { + const normalized = normalizeNewlines(body ?? ""); + const firstLine = firstMarkdownLine(normalized); + if (isDailyCommanderBriefBodyHeading(firstLine)) return true; + return isLegacyCommanderBriefBodyHeading(firstLine) + && normalized.includes("## 常驻观察与长期建议") + && extractTimelineSections(normalized).length > 0; +} + +export function commanderBriefIssueProfileMatch(issueNumber: number, context: IssueProfileValidationContext): { ok: boolean; reason: string } { + if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return { ok: true, reason: "legacy-issue-number" }; + if (isDailyCommanderBriefTitle(context.issueTitle)) return { ok: true, reason: "daily-title" }; + if (currentBodyLooksLikeCommanderBrief(context.issueBody)) return { ok: true, reason: "current-body-heading" }; + return { + ok: false, + reason: context.issueMetadataFetched === true ? "not-commander-brief" : "issue-metadata-unavailable", + }; +} + +export function issueProfileNeedsMetadata(issueNumber: number, requested: IssueBodyProfileOption): boolean { + return requested === "commander-brief" && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE; +} + +export function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption, context: IssueProfileValidationContext = {}): Record<string, unknown> { + const profileName = issueProfileFor(issueNumber, requested); + if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true }; + const profile = ISSUE_BODY_PROFILES[profileName]; + const match = profileName === "commander-brief" + ? commanderBriefIssueProfileMatch(issueNumber, context) + : { ok: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber, reason: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber ? "issue-number" : "wrong-issue-number" }; + const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading)); + return { + profile: profileName, + label: profile.label, + applied: true, + ...(profileName === "code-queue-board" + ? { expectedIssueNumber: ISSUE_BODY_PROFILES["code-queue-board"].issueNumber } + : { legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE }), + issueMatchesProfile: match.ok, + issueMatchReason: match.reason, + issueMetadataFetched: context.issueMetadataFetched === true, + issueTitle: context.issueTitle ?? null, + requiredHeadings: profile.requiredHeadings, + missingHeadings, + ok: match.ok && missingHeadings.length === 0, + }; +} + +export function validationBoolean(record: Record<string, unknown>, key: string): boolean { + return record[key] === true; +} + +export function validationStringArray(record: Record<string, unknown>, key: string): string[] { + const value = record[key]; + return Array.isArray(value) ? value.map((item) => String(item)) : []; +} + +export function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}, commandName = "issue edit", bodySource: Record<string, unknown> = { kind: "body-file", path: options.bodyFile ?? null }): GitHubCommandResult | null { + const trimmed = body.trim(); + const isLiteralNull = trimmed.toLowerCase() === "null"; + const isBlank = trimmed.length === 0; + const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS; + const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext); + const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body); + const boardContainsCommanderBriefUpdates = codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint); + const profileOk = validationBoolean(profileValidation, "ok"); + const failures: string[] = []; + if (isLiteralNull) failures.push("literal-null-body"); + if (isBlank) failures.push("blank-body"); + if (isShort && !options.allowShortBody) failures.push("short-body"); + if (boardContainsCommanderBriefUpdates) failures.push("code-queue-board-contains-commander-brief-updates"); + if (!profileOk) { + if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch"); + const missingHeadings = validationStringArray(profileValidation, "missingHeadings"); + if (missingHeadings.length > 0) failures.push("profile-heading-missing"); + } + if (failures.length === 0) return null; + const overrideAvailable = failures.every((failure) => failure === "short-body"); + const message = overrideAvailable + ? `${commandName} body is shorter than the safe default; pass --allow-short-body only for intentional short writes` + : `${commandName} body failed safety guard; refusing to write possible body-only issue corruption`; + return validationError(commandName, repo, message, { + issueNumber, + guard: { + ok: false, + failures, + minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS, + allowShortBody: options.allowShortBody, + shortBodyOverrideAvailable: overrideAvailable, + bodySource, + bodyPreview: preview(body), + bodyPreviewLines: previewLines(body), + codeQueueBoardHint: boardBriefHint, + ...bodySafetySignals(body), + isLiteralNull, + isBlank, + isShort, + profile: profileValidation, + }, + }); +} + +export function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): Record<string, unknown> { + const trimmed = body.trim(); + const profile = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext); + const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body); + const warnings: string[] = []; + if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) { + warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail"); + } + if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk"); + if (profile.ok === false) warnings.push("profile guard would fail"); + if (codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint)) warnings.push("code-queue-board-contains-commander-brief-updates"); + if (codeQueueBoardHintHasHwlabProductRouting(boardBriefHint)) warnings.push("code-queue-board-contains-hwlab-product-work"); + const signals = bodySafetySignals(body); + const shellPollution = signals.shellPollution as Record<string, unknown>; + if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected"); + return { + ok: trimmed.length > 0 + && trimmed.toLowerCase() !== "null" + && (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody) + && profile.ok === true + && !codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint), + minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS, + allowShortBody: options.allowShortBody, + warnings, + profile, + codeQueueBoardHint: boardBriefHint, + ...signals, + }; +} + +export function assertConcurrencyOptions(options: GitHubOptions, commandName: string): GitHubCommandResult | null { + if (options.expectBodySha === undefined) return null; + try { + normalizeExpectedSha(options.expectBodySha); + return null; + } catch (error) { + return validationError(commandName, options.repo, error instanceof Error ? error.message : String(error), { + expectBodySha: options.expectBodySha, + }); + } +} + +export function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions, commandName = "issue edit"): GitHubCommandResult | null { + const checks: Record<string, unknown> = { + issueNumber, + expectUpdatedAt: options.expectUpdatedAt ?? null, + actualUpdatedAt: oldIssue.updated_at ?? null, + expectBodySha: options.expectBodySha ?? null, + actualBodySha: bodySha(oldIssue.body ?? ""), + }; + if (options.expectUpdatedAt !== undefined && oldIssue.updated_at !== options.expectUpdatedAt) { + return validationError(commandName, repo, "--expect-updated-at did not match current GitHub issue updated_at; refusing stale body overwrite", checks); + } + if (options.expectBodySha !== undefined) { + const expected = normalizeExpectedSha(options.expectBodySha); + if (bodySha(oldIssue.body ?? "") !== expected) { + return validationError(commandName, repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks); + } + } + return null; +} + +export function validateCommentConcurrency(repo: string, commentId: number, oldComment: GitHubComment, options: GitHubOptions, commandName: string): GitHubCommandResult | null { + const checks: Record<string, unknown> = { + commentId, + expectUpdatedAt: options.expectUpdatedAt ?? null, + actualUpdatedAt: oldComment.updated_at ?? null, + expectBodySha: options.expectBodySha ?? null, + actualBodySha: bodySha(oldComment.body ?? ""), + }; + if (options.expectUpdatedAt !== undefined && oldComment.updated_at !== options.expectUpdatedAt) { + return validationError(commandName, repo, "--expect-updated-at did not match current GitHub comment updated_at; refusing stale body overwrite", checks); + } + if (options.expectBodySha !== undefined) { + const expected = normalizeExpectedSha(options.expectBodySha); + if (bodySha(oldComment.body ?? "") !== expected) { + return validationError(commandName, repo, "--expect-body-sha did not match current GitHub comment body; refusing stale body overwrite", checks); + } + } + return null; +} diff --git a/scripts/src/gh/body-input.ts b/scripts/src/gh/body-input.ts new file mode 100644 index 00000000..66050a5a --- /dev/null +++ b/scripts/src/gh/body-input.ts @@ -0,0 +1,222 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:1346-1555. + +import path from "node:path"; +import { ApplyPatchV2Error, deriveUpdatedContent, parseApplyPatchV2 } from "../apply-patch-v2"; +import { existsSync, readFileSync } from "node:fs"; +import type { PatchHunk } from "../apply-patch-v2"; +import { shellPollutionEvidence } from "./auth-and-safety"; +import { isRecord } from "./notify-claudeqq"; +import { MAX_INLINE_ISSUE_COMMENT_BODY_CHARS } from "./types"; +import type { GitHubOptions } from "./types"; + +export function readMarkdownBodyFileOrStdin(path: string): { body: string; bodySource: Record<string, unknown> } { + if (path === "-") { + return { body: readFileSync(0, "utf8"), bodySource: { kind: "stdin", path: "-" } }; + } + if (!existsSync(path)) throw new Error(`body file not found: ${path}`); + return { body: readFileSync(path, "utf8"), bodySource: { kind: "body-file", path } }; +} + +export function readBodyPatchFileOrStdin(path: string): { patch: string; patchSource: Record<string, unknown> } { + if (path === "-") { + return { patch: readFileSync(0, "utf8"), patchSource: { kind: "body-patch-stdin", path: "-" } }; + } + if (!existsSync(path)) throw new Error(`body patch file not found: ${path}`); + return { patch: readFileSync(path, "utf8"), patchSource: { kind: "body-patch-file", path } }; +} + +export function readBodyPatch(options: GitHubOptions, command: string): { patch: string; patchSource: Record<string, unknown> } { + const conflictingSources: string[] = []; + if (options.body !== undefined) conflictingSources.push("--body"); + if (options.bodyFile !== undefined) conflictingSources.push(options.bodyFile === "-" ? "--body-stdin" : "--body-file"); + if (options.comment !== undefined) conflictingSources.push("--comment"); + if (options.commentFile !== undefined) conflictingSources.push(options.commentFile === "-" ? "--comment-stdin" : "--comment-file"); + if (conflictingSources.length > 0) { + throw new Error(`${command} accepts only a patch source; remove ${conflictingSources.join(",")} and use --body-patch-stdin or --body-patch-file <file|->`); + } + if (options.bodyPatchFile === undefined) throw new Error(`${command} requires --body-patch-stdin or --body-patch-file <file|->`); + return readBodyPatchFileOrStdin(options.bodyPatchFile); +} + +export function patchPathList(hunks: PatchHunk[]): string[] { + return Array.from(new Set(hunks.map((hunk) => hunk.path))); +} + +export function applyVirtualBodyPatch(virtualPath: "issue.md" | "comment.md", oldBody: string, patchText: string): { newBody: string; patchSummary: Record<string, unknown> } { + const parsed = parseApplyPatchV2(patchText); + let newBody = oldBody; + let updateChunkCount = 0; + for (let index = 0; index < parsed.hunks.length; index += 1) { + const hunk = parsed.hunks[index] as PatchHunk; + if (hunk.kind !== "update") { + throw new ApplyPatchV2Error("GitHub body patch supports only Update File hunks", { + hunk: index + 1, + action: hunk.kind, + path: hunk.path, + expectedPath: virtualPath, + }); + } + if (hunk.path !== virtualPath) { + throw new ApplyPatchV2Error("GitHub body patch target path mismatch", { + hunk: index + 1, + path: hunk.path, + expectedPath: virtualPath, + }); + } + if (hunk.movePath !== null && hunk.movePath !== hunk.path) { + throw new ApplyPatchV2Error("GitHub body patch does not support Move to", { + hunk: index + 1, + path: hunk.path, + targetPath: hunk.movePath, + expectedPath: virtualPath, + }); + } + updateChunkCount += hunk.chunks.length; + newBody = deriveUpdatedContent(virtualPath, newBody, hunk.chunks).newContent; + } + if (newBody === oldBody) { + throw new ApplyPatchV2Error("GitHub body patch did not change the body; no GitHub write will be performed", { + path: virtualPath, + hunkCount: parsed.hunks.length, + }); + } + return { + newBody, + patchSummary: { + engine: "apply-patch-v2", + virtualFile: virtualPath, + patchBytes: Buffer.byteLength(patchText, "utf8"), + hunkCount: parsed.hunks.length, + updateChunkCount, + paths: patchPathList(parsed.hunks), + hints: parsed.hints, + }, + }; +} + +export function sanitizedPatchDiagnostics(value: unknown): Record<string, unknown> | null { + if (!isRecord(value)) return null; + const diagnostics: Record<string, unknown> = {}; + const candidateLines = value.firstExpectedLineCandidates; + if (Array.isArray(candidateLines)) diagnostics.firstExpectedLineCandidates = candidateLines.filter((item): item is number => typeof item === "number"); + for (const key of ["firstExpectedLineCandidatesTruncated", "bestPrefixMatchedLines", "bestPrefixStartLine", "likelyMissingAddedPrefixes", "likelyStaleOrOversizedContext"]) { + const item = value[key]; + if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) diagnostics[key] = item; + } + return Object.keys(diagnostics).length > 0 ? diagnostics : null; +} + +export function sanitizedPatchDetails(value: unknown): Record<string, unknown> | null { + if (!isRecord(value)) return null; + const result: Record<string, unknown> = {}; + for (const key of ["path", "targetPath", "expectedPath", "hunk", "chunk", "action", "status", "line", "hunkCount"]) { + const item = value[key]; + if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) result[key] = item; + } + if (typeof value.expected === "string") { + result.expectedLineCount = value.expected.length === 0 ? 0 : value.expected.split(/\r?\n/u).length; + result.expectedChars = value.expected.length; + } + const diagnostics = sanitizedPatchDiagnostics(value.diagnostics); + if (diagnostics !== null) result.diagnostics = diagnostics; + if (Array.isArray(value.partialChanges)) result.partialChanges = value.partialChanges.filter((item): item is string => typeof item === "string"); + if (Array.isArray(value.outcomes)) { + result.outcomes = value.outcomes + .filter(isRecord) + .map((outcome) => { + const summary: Record<string, unknown> = {}; + for (const key of ["hunk", "action", "status", "path", "targetPath", "change"]) { + const item = outcome[key]; + if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) summary[key] = item; + } + if (isRecord(outcome.error) && typeof outcome.error.message === "string") summary.errorMessage = outcome.error.message; + return summary; + }); + } + if (isRecord(value.failed)) result.failed = sanitizedPatchDetails(value.failed); + if (isRecord(value.cause)) result.cause = sanitizedPatchDetails(value.cause); + return Object.keys(result).length > 0 ? result : null; +} + +export function bodyPatchErrorSummary(error: unknown): Record<string, unknown> { + const message = error instanceof Error ? error.message : String(error); + const summary: Record<string, unknown> = { + name: error instanceof Error ? error.name : "Error", + message, + bodyTextRedacted: true, + note: "Patch diagnostics omit expected/context text so GitHub body contents and credentials are not echoed.", + }; + const details = error instanceof ApplyPatchV2Error ? sanitizedPatchDetails(error.details) : null; + if (details !== null) summary.details = details; + return summary; +} + +export function readOptionalMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null { + if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`); + if (options.bodyFile !== undefined) { + return readMarkdownBodyFileOrStdin(options.bodyFile); + } + if (options.body !== undefined) { + return { + body: options.body, + bodySource: { + kind: "inline", + warning: options.body.includes("\n") ? "inline body contains real newlines; --body-stdin with a quoted heredoc is safer for generated Markdown" : "inline body is intended only for short single-line text", + }, + }; + } + return null; +} + +export function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } { + const body = readOptionalMarkdownBody(options, command); + if (body !== null) return body; + throw new Error(`${command} requires --body-stdin, --body-file <file|->, or --body <text>`); +} + +export function secretLikeInlineFindings(body: string): string[] { + const findings: string[] = []; + if (/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-token-like-value"); + if (/\bgithub_pat_[A-Za-z0-9_]{6,}\b/u.test(body)) findings.push("github-pat-like-value"); + if (/\b(?:token|password|passwd|secret|api[_-]?key)\s*[:=]\s*["']?[^"'\s,;]{6,}/iu.test(body)) findings.push("credential-assignment-like-value"); + if (/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/iu.test(body)) findings.push("bearer-token-like-value"); + return findings; +} + +export function readIssueCommentBody(options: GitHubOptions, command = "issue comment create"): { body: string; bodySource: Record<string, unknown> } { + if (options.bodyFile !== undefined && options.body !== undefined) { + throw new Error(`${command} accepts only one body source: --body-file/--body-stdin or --body`); + } + if (options.bodyFile !== undefined) { + return readMarkdownBodyFileOrStdin(options.bodyFile); + } + if (options.body === undefined) throw new Error(`${command} requires --body-stdin, --body-file <file|->, or --body <text>`); + const body = options.body; + const trimmed = body.trim(); + const shellPollution = shellPollutionEvidence(body); + const secretLike = secretLikeInlineFindings(body); + if (trimmed.length === 0) { + throw new Error(`${command} --body must not be blank; use --body-stdin with a quoted heredoc for reviewed Markdown`); + } + if (body.length > MAX_INLINE_ISSUE_COMMENT_BODY_CHARS) { + throw new Error(`${command} --body is limited to ${MAX_INLINE_ISSUE_COMMENT_BODY_CHARS} characters; use --body-stdin with a quoted heredoc for long Markdown`); + } + if (body.includes("\n") || body.includes("\r")) { + throw new Error(`${command} --body supports short single-line text only; use --body-stdin with a quoted heredoc for multiline Markdown`); + } + if (shellPollution.length > 0) { + throw new Error(`${command} --body contains shell-pollution signals (${shellPollution.join(",")}); use --body-stdin with a quoted heredoc for reviewed Markdown bytes`); + } + if (secretLike.length > 0) { + throw new Error(`${command} --body appears to contain secret-like text (${secretLike.join(",")}); refusing to print or submit it`); + } + return { + body, + bodySource: { + kind: "inline", + maxInlineBodyChars: MAX_INLINE_ISSUE_COMMENT_BODY_CHARS, + warning: "inline issue comments are intended only for short single-line text; use --body-stdin with a quoted heredoc for Markdown or generated content", + }, + }; +} diff --git a/scripts/src/gh/body-profiles.ts b/scripts/src/gh/body-profiles.ts new file mode 100644 index 00000000..4b3082ee --- /dev/null +++ b/scripts/src/gh/body-profiles.ts @@ -0,0 +1,229 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:1669-1889. + +import { bodySafetySignals, preview, previewLines } from "./auth-and-safety"; +import { isMarkdownTableSeparator } from "./board-parser"; +import { CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS } from "./types"; +import type { CommanderBriefDiff, CommanderBriefSection } from "./types"; + +export function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> { + return { + repo, + ...(title === undefined ? {} : { title }), + bodyPreview: preview(body), + bodyPreviewLines: previewLines(body), + ...bodySafetySignals(body), + }; +} + +export function normalizeNewlines(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +export function isTimelineHeading(line: string): boolean { + return /^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u.test(line.trimEnd()); +} + +export function isCommanderBriefUpdateHeading(line: string): boolean { + const trimmed = line.trimEnd(); + return COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS.some((pattern) => pattern.test(trimmed)); +} + +export function commanderBriefUpdateHeadings(body: string): string[] { + return normalizeNewlines(body) + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => isCommanderBriefUpdateHeading(line)); +} + +export function codeQueueBoardHwlabProductRoutingFindings(body: string | undefined): Array<Record<string, unknown>> { + if (body === undefined) return []; + const findings: Array<Record<string, unknown>> = []; + const directIssuePatterns = [ + { kind: "hwlab-repo-issue-url", pattern: /(?:https?:\/\/github\.com\/)?pikasTech\/HWLAB\/issues\/\d+/iu }, + { kind: "hwlab-repo-issue-short-ref", pattern: /\bpikasTech\/HWLAB#\d+\b/iu }, + { kind: "hwlab-short-issue-ref", pattern: /\bHWLAB#\d+\b/iu }, + ]; + const hwlabContextPattern = /\bHWLAB\b|pikasTech\/HWLAB|hwlab-/iu; + const strongProductPattern = /hwlab-cloud-web|Cloud Workbench|DEV-LIVE|res_boxsimu|hwlab-patch-panel|patch panel|M3\s*(?:虚拟硬件|可信闭环)/iu; + const genericProductPattern = /用户反馈|user feedback/iu; + const governanceContextPattern = /\b(?:commander|Code Queue|CLI|infra|governance|guard|guardrail|routing|misfile|board)\b|治理|调度|基础设施|守卫|边界|分流|看板/iu; + const lines = normalizeNewlines(body).split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const isTableRow = line.trim().startsWith("|") && !isMarkdownTableSeparator(line); + const strongProductSignal = strongProductPattern.test(line); + const genericProductSignal = genericProductPattern.test(line); + const governanceContext = governanceContextPattern.test(line); + for (const definition of directIssuePatterns) { + const match = definition.pattern.exec(line); + if (match !== null && !governanceContext && (isTableRow || strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) { + findings.push({ + kind: definition.kind, + lineNumber: index + 1, + match: match[0], + snippet: preview(line.trim()), + }); + } + } + if (isTableRow && !governanceContext && (strongProductSignal || (genericProductSignal && hwlabContextPattern.test(line)))) { + const match = strongProductSignal ? strongProductPattern.exec(line) : genericProductPattern.exec(line); + findings.push({ + kind: "hwlab-product-signal", + lineNumber: index + 1, + match: match?.[0] ?? "HWLAB product signal", + snippet: preview(line.trim()), + }); + } + if (findings.length >= 12) break; + } + return findings; +} + +export function codeQueueBoardHwlabProductRoutingHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null { + if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null; + const findings = codeQueueBoardHwlabProductRoutingFindings(body); + return { + warning: "#20 is only for UniDesk commander/Code Queue/CLI/infra governance; HWLAB user/product issues belong in pikasTech/HWLAB.", + route: "Create or update the corresponding issue in pikasTech/HWLAB; keep #20 rows limited to UniDesk governance or infrastructure support work.", + forbiddenPatterns: [ + "pikasTech/HWLAB#<N>", + "github.com/pikasTech/HWLAB/issues/<N>", + "HWLAB#<N>", + "HWLAB product/live validation rows", + ], + allowedScope: ["commander governance", "Code Queue supervision", "UniDesk CLI guardrails", "UniDesk infrastructure"], + detected: findings.length > 0, + findings, + }; +} + +export function codeQueueBoardHintHasHwlabProductRouting(hint: Record<string, unknown> | null): boolean { + if (hint === null) return false; + const routing = hint.hwlabProductRouting; + return typeof routing === "object" && routing !== null && (routing as { detected?: unknown }).detected === true; +} + +export function codeQueueBoardHintHasCommanderBriefUpdates(hint: Record<string, unknown> | null): boolean { + if (hint === null) return false; + const commanderBrief = hint.commanderBrief; + return typeof commanderBrief === "object" && commanderBrief !== null && (commanderBrief as { detected?: unknown }).detected === true; +} + +export function codeQueueBoardCommanderBriefHint(boardIssueNumber: number, body?: string): Record<string, unknown> | null { + if (boardIssueNumber !== CODE_QUEUE_BOARD_TARGET_ISSUE) return null; + const headings = body === undefined ? [] : commanderBriefUpdateHeadings(body); + const hwlabProductRouting = codeQueueBoardHwlabProductRoutingHint(boardIssueNumber, body); + const commanderBriefDetected = headings.length > 0; + const hwlabProductDetected = codeQueueBoardHintHasHwlabProductRouting({ hwlabProductRouting }); + return { + warning: "#20 is the long-term UniDesk commander/Code Queue/CLI/infra governance board only; do not write daily commander brief updates or HWLAB product/user issue tracking into #20.", + route: commanderBriefDetected + ? "Move daily progress notes to the daily rolling commander brief issue referenced in #20's 指挥简报索引, using --body-profile commander-brief." + : hwlabProductDetected + ? "Move HWLAB product/user issue tracking to pikasTech/HWLAB; keep #20 limited to UniDesk commander/Code Queue/CLI/infra governance." + : "Use #20 only for UniDesk commander/Code Queue/CLI/infra governance rows.", + forbiddenHeadings: ["## 更新 YYYY-MM-DD HH:mm 北京时间", "## YYYY-MM-DD HH:mm 北京时间指挥更新", "### YYYY-MM-DD HH:mm CST:..."], + suggestedCommand: "bun scripts/cli.ts gh issue update <daily-brief-issue> --mode replace --body-stdin --body-profile commander-brief --expect-body-sha <sha> <<'EOF'\n<reviewed body>\nEOF", + detected: commanderBriefDetected || hwlabProductDetected, + detectedHeadings: headings, + commanderBrief: { + detected: commanderBriefDetected, + detectedHeadings: headings, + }, + hwlabProductRouting, + }; +} + +export function extractTimelineSections(markdown: string): CommanderBriefSection[] { + const normalized = normalizeNewlines(markdown); + const lines = normalized.split("\n"); + const sections: CommanderBriefSection[] = []; + let currentStart = -1; + for (let index = 0; index < lines.length; index += 1) { + if (!isTimelineHeading(lines[index])) continue; + if (currentStart !== -1) { + sections.push({ + heading: lines[currentStart].trimEnd(), + text: lines.slice(currentStart, index).join("\n").trimEnd(), + startLine: currentStart + 1, + }); + } + currentStart = index; + } + if (currentStart !== -1) { + sections.push({ + heading: lines[currentStart].trimEnd(), + text: lines.slice(currentStart).join("\n").trimEnd(), + startLine: currentStart + 1, + }); + } + return sections.filter((section) => section.text.length > 0); +} + +export function commanderBriefDiff(oldBodyRaw: string, newBodyRaw: string): CommanderBriefDiff { + const oldBody = normalizeNewlines(oldBodyRaw); + const newBody = normalizeNewlines(newBodyRaw); + if (oldBody === newBody) { + return { + ok: false, + mode: "identical", + message: "", + chars: 0, + sections: [], + sectionCount: 0, + skippedReason: "issue body is unchanged", + }; + } + + if (newBody.startsWith(oldBody)) { + const suffix = newBody.slice(oldBody.length); + const sections = extractTimelineSections(suffix).map((section) => section.text); + const message = sections.join("\n\n").trim(); + if (message.length === 0) { + return { + ok: false, + mode: "append-only", + message: "", + chars: 0, + sections: [], + sectionCount: 0, + skippedReason: "append-only suffix contains no new commander brief update section", + }; + } + return { ok: true, mode: "append-only", message, chars: message.length, sections, sectionCount: sections.length }; + } + + const oldSections = new Set(extractTimelineSections(oldBody).map((section) => section.text)); + const allNewSections = extractTimelineSections(newBody); + const newSections = allNewSections.map((section) => section.text).filter((section) => !oldSections.has(section)); + if (newSections.length === 0) { + return { + ok: false, + mode: "heading-diff", + message: "", + chars: 0, + sections: [], + sectionCount: 0, + skippedReason: "no new commander brief update section found; non-timeline edits are not notified", + }; + } + const duplicateNewHeadings = allNewSections.some((section, index) => allNewSections.findIndex((candidate) => candidate.heading === section.heading) !== index); + const oldHeadings = new Set(extractTimelineSections(oldBody).map((section) => section.heading)); + const ambiguousChangedHeadings = allNewSections.some((section) => oldHeadings.has(section.heading) && !oldSections.has(section.text)); + if (duplicateNewHeadings || ambiguousChangedHeadings) { + return { + ok: false, + mode: "unreliable", + message: "", + chars: 0, + sections: [], + sectionCount: 0, + skippedReason: duplicateNewHeadings + ? "duplicate update headings make commander brief diff unreliable" + : "an existing update section changed; cannot reliably isolate only new timeline content", + }; + } + const message = newSections.join("\n\n").trim(); + return { ok: true, mode: "heading-diff", message, chars: message.length, sections: newSections, sectionCount: newSections.length }; +} diff --git a/scripts/src/gh/client.ts b/scripts/src/gh/client.ts new file mode 100644 index 00000000..a94e87d6 --- /dev/null +++ b/scripts/src/gh/client.ts @@ -0,0 +1,384 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:2087-2461. + +import path from "node:path"; +import { bodySafetySignals, preview, previewLines } from "./auth-and-safety"; +import { isRecord } from "./notify-claudeqq"; +import { GITHUB_API, REQUEST_TIMEOUT_MS, USER_AGENT } from "./types"; +import type { GitHubCommandResult, GitHubDegradedReason, GitHubErrorPayload, GitHubOptions, GitHubTokenProbe, RunnerDisposition } from "./types"; + +export function writeBodyPlan(command: "issue create" | "issue comment create" | "issue comment update" | "pr create" | "pr comment" | "pr comment update", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> { + const isIssueWrite = command === "issue create" || command === "issue comment create" || command === "issue comment update"; + const isCommentUpdate = command === "issue comment update" || command === "pr comment update"; + const source = String(bodySource.kind ?? "unknown"); + const requestBody: Record<string, unknown> = { + bodyChars: body.length, + bodySource, + }; + if (command === "issue create" && typeof extra.title === "string") requestBody.title = extra.title; + if (command === "issue create" && Array.isArray(extra.labels)) requestBody.labels = extra.labels; + return { + repo, + bodySource, + source, + bodyPreview: preview(body), + bodyPreviewLines: previewLines(body), + ...bodySafetySignals(body), + request: { + method: isCommentUpdate ? "PATCH" : "POST", + path: isIssueWrite + ? (command === "issue create" + ? "/repos/{owner}/{repo}/issues" + : isCommentUpdate + ? "/repos/{owner}/{repo}/issues/comments/{comment_id}" + : "/repos/{owner}/{repo}/issues/{issue_number}/comments") + : (command === "pr create" + ? "/repos/{owner}/{repo}/pulls" + : isCommentUpdate + ? "/repos/{owner}/{repo}/issues/comments/{comment_id}" + : "/repos/{owner}/{repo}/issues/{issue_number}/comments"), + body: requestBody, + }, + validation: { + source, + rawText: source === "body-file" + ? "read from file bytes without shell interpolation" + : source === "stdin" + ? "read from stdin bytes" + : "short inline argument accepted only by command-specific safety rules", + }, + ...extra, + }; +} + +export function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition { + if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed"; + return "infra-blocked"; +} + +export function sanitizedErrorDetails(parsed: unknown): unknown { + if (typeof parsed !== "object" || parsed === null) return parsed; + const source = parsed as Record<string, unknown>; + const details: Record<string, unknown> = {}; + if ("message" in source) details.message = source.message; + if ("documentation_url" in source) details.documentationUrl = source.documentation_url; + if ("status" in source) details.status = source.status; + if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5); + if ("raw" in source) details.raw = source.raw; + return details; +} + +export function errorProperty(value: unknown, key: string): string | null { + if (!isRecord(value)) return null; + const item = value[key]; + return typeof item === "string" && item.length > 0 ? item : null; +} + +export function errorChainText(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + const seen = new Set<unknown>(); + while (current !== null && current !== undefined && !seen.has(current)) { + seen.add(current); + if (current instanceof Error) { + parts.push(current.name, current.message); + current = (current as Error & { cause?: unknown }).cause; + continue; + } + if (isRecord(current)) { + for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) { + const value = errorProperty(current, key); + if (value !== null) parts.push(value); + } + current = current.cause; + continue; + } + parts.push(String(current)); + break; + } + return parts.join(" ").replace(/\s+/gu, " ").trim(); +} + +export function firstErrorProperty(error: unknown, key: string): string | null { + let current: unknown = error; + const seen = new Set<unknown>(); + while (current !== null && current !== undefined && !seen.has(current)) { + seen.add(current); + const value = errorProperty(current, key); + if (value !== null) return value; + current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null; + } + return null; +} + +export function classifyGitHubFetchFailure(error: unknown): { + reason: GitHubDegradedReason; + message: string; + retryable?: boolean; + commanderAction?: string; + details: Record<string, unknown>; +} { + const text = errorChainText(error); + const lower = text.toLowerCase(); + const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text); + const isGitHubTransient = !isProxyFailure && ( + /temporary failure in name resolution/iu.test(text) + || /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text) + || /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text) + ); + if (!isGitHubTransient) { + return { + reason: "network-proxy-failed", + message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response", + details: { + scope: "github-api", + transient: false, + errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"), + errorCode: firstErrorProperty(error, "code"), + host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"), + }, + }; + } + return { + reason: "github-transient", + message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response", + retryable: true, + commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh", + details: { + scope: "github-api", + transient: true, + retryable: true, + authFailure: false, + semanticFailure: false, + errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"), + errorCode: firstErrorProperty(error, "code"), + host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"), + commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work", + valuesPrinted: false, + }, + }; +} + +export function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload { + return { + ok: false, + degradedReason: reason, + runnerDisposition: runnerDisposition(reason), + message, + ...extra, + }; +} + +export function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult { + return { + ok: false, + command, + repo, + degradedReason: error.degradedReason, + runnerDisposition: error.runnerDisposition, + details: error, + ...extra, + }; +} + +export function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult { + const error = errorPayload("validation-failed", message, { details: extra }); + return commandError(command, repo, error, extra); +} + +export function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult { + const error = errorPayload("unsupported-command", message, { details: extra }); + return commandError(command, repo, error, { message, ...extra }); +} + +export async function parseGitHubResponse(response: Response): Promise<unknown> { + const text = await response.text(); + if (text.length === 0) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return { raw: preview(text) }; + } +} + +export function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason { + const lower = message.toLowerCase(); + const acceptedScopes = response.headers.get("x-accepted-oauth-scopes"); + if (status === 401) return "auth-failed"; + if (status === 403) { + if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient"; + if (path.startsWith("/repos/")) return "repo-forbidden"; + return "permission-denied"; + } + if (status === 404) { + if (/\/pulls\/\d+/u.test(path)) return "pr-not-found"; + if (/\/issues\/\d+/u.test(path)) return "issue-not-found"; + if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed"; + return "repo-not-found"; + } + if (status === 422) return "validation-failed"; + return "invalid-response"; +} + +export async function githubRequest<T>( + token: string, + method: string, + path: string, + body?: Record<string, unknown>, +): Promise<T | GitHubErrorPayload> { + let response: Response; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + response = await fetch(`${GITHUB_API}${path}`, { + method, + signal: controller.signal, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + "X-GitHub-Api-Version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (error) { + const classified = classifyGitHubFetchFailure(error); + return errorPayload(classified.reason, classified.message, { + request: { method, path }, + details: classified.details, + retryable: classified.retryable, + commanderAction: classified.commanderAction, + }); + } finally { + clearTimeout(timeout); + } + const parsed = await parseGitHubResponse(response); + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText; + return errorPayload(classifyHttpStatus(response.status, message, path, response), message, { + status: response.status, + details: sanitizedErrorDetails(parsed), + scopes: { + accepted: response.headers.get("x-accepted-oauth-scopes"), + token: response.headers.get("x-oauth-scopes"), + }, + request: { method, path }, + }); + } + return parsed as T; +} + +export async function githubGraphqlRequest<T>( + token: string, + query: string, + variables: Record<string, unknown>, +): Promise<T | GitHubErrorPayload> { + let response: Response; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + response = await fetch(`${GITHUB_API}/graphql`, { + method: "POST", + signal: controller.signal, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ query, variables }), + }); + } catch (error) { + const classified = classifyGitHubFetchFailure(error); + return errorPayload(classified.reason, classified.message, { + request: { method: "POST", path: "/graphql" }, + details: classified.details, + retryable: classified.retryable, + commanderAction: classified.commanderAction, + }); + } finally { + clearTimeout(timeout); + } + const parsed = await parseGitHubResponse(response); + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText; + return errorPayload(classifyHttpStatus(response.status, message, "/graphql", response), message, { + status: response.status, + details: sanitizedErrorDetails(parsed), + scopes: { + accepted: response.headers.get("x-accepted-oauth-scopes"), + token: response.headers.get("x-oauth-scopes"), + }, + request: { method: "POST", path: "/graphql" }, + }); + } + if (!isRecord(parsed)) { + return errorPayload("invalid-response", "GitHub GraphQL response was not an object", { details: parsed }); + } + if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { + const firstError = parsed.errors[0]; + const message = isRecord(firstError) && typeof firstError.message === "string" ? firstError.message : "GitHub GraphQL query failed"; + return errorPayload("validation-failed", message, { + details: sanitizedErrorDetails(parsed), + request: { method: "POST", path: "/graphql" }, + }); + } + const data = parsed.data; + if (!isRecord(data)) { + return errorPayload("invalid-response", "GitHub GraphQL response did not include an object data payload", { details: sanitizedErrorDetails(parsed) }); + } + return data as T; +} + +export function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null { + if (!tokenProbe.present) { + if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) { + return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe })); + } + if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) { + return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe })); + } + return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), { + degraded: ["missing-token"], + token: tokenProbe, + }); + } + return null; +} + +export function isGitHubError(value: unknown): value is GitHubErrorPayload { + return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value; +} + +export function issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> { + return { + body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`, + full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`, + raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`, + }; +} + +export function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> { + return { + comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`, + full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`, + raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`, + }; +} + +export function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> { + const explicitFullDisclosure = options.raw || options.full; + return { + defaultCompact: dryRun || !explicitFullDisclosure, + explicitFullDisclosure, + fullBodyIncluded: explicitFullDisclosure && !dryRun, + bodyOmitted: dryRun || !explicitFullDisclosure, + dryRunBoundedPreview: dryRun, + note: explicitFullDisclosure && !dryRun + ? "The returned issue object includes the full body because --full or --raw was explicitly requested." + : "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.", + readCommands: issueBodyReadCommands(repo, issueNumber), + }; +} diff --git a/scripts/src/gh/comments-and-state.ts b/scripts/src/gh/comments-and-state.ts new file mode 100644 index 00000000..e491728f --- /dev/null +++ b/scripts/src/gh/comments-and-state.ts @@ -0,0 +1,339 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:7148-7472. + +import path from "node:path"; +import { bodySha, preview, previewLines, readIssueLifecycleCommentBody, repoParts } from "./auth-and-safety"; +import { assertConcurrencyOptions, validateCommentConcurrency } from "./body-guards"; +import { applyVirtualBodyPatch, bodyPatchErrorSummary, readBodyPatch, readIssueCommentBody } from "./body-input"; +import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, issueCommentReadCommands, validationError, writeBodyPlan } from "./client"; +import { getIssueComment } from "./issue-read"; +import { issueLifecycleDisclosure, issueLifecycleSummary } from "./issue-summary"; +import { isRecord } from "./notify-claudeqq"; +import { commentSummary, compactCommentSummary, ghShort, ghTable, ghText } from "./render"; +import type { GitHubCommandResult, GitHubComment, GitHubIssue, GitHubOptions } from "./types"; + +export function withIssueCommentCreateRendered(result: GitHubCommandResult): GitHubCommandResult { + return { + ...result, + contentType: "text/plain", + renderedText: renderIssueCommentCreateTable(result), + }; +} + +export function renderIssueCommentCreateTable(result: GitHubCommandResult): string { + const comment = isRecord(result.comment) ? result.comment : {}; + const readCommands = isRecord(result.readCommands) ? result.readCommands : {}; + const status = result.dryRun === true ? "dry-run" : "created"; + const issueNumber = ghText(result.issueNumber); + const commentId = ghText(comment.id ?? result.commentId); + const sourceKind = isRecord(result.bodySource) ? result.bodySource.kind : result.source; + const lines = [ + `gh issue comment create (${status})`, + "", + ghTable(["ISSUE", "COMMENT", "STATUS", "BODY", "URL"], [[ + issueNumber === "-" ? "-" : `#${issueNumber}`, + commentId, + status, + `${ghText(result.bodyChars ?? comment.bodyChars)} chars`, + ghShort(ghText(comment.url ?? result.url), 96), + ]]), + "", + "Summary:", + ` repo=${ghText(result.repo)} source=${ghText(sourceKind)} bodySha=${ghText(result.bodySha ?? comment.bodySha)}`, + "", + "Next:", + ` ${ghText(readCommands.comments ?? readCommands.full ?? readCommands.raw)}`, + "", + "Disclosure:", + " default view is a bounded table; use --full/--raw or the read command for full comment metadata/body.", + ]; + return lines.join("\n"); +} + +export async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + let bodyInput: { body: string; bodySource: Record<string, unknown> }; + try { + bodyInput = readIssueCommentBody(options, "issue comment create"); + } catch (error) { + return validationError("issue comment create", repo, error instanceof Error ? error.message : String(error), { issueNumber }); + } + const { body, bodySource } = bodyInput; + if (options.dryRun) { + return withIssueCommentCreateRendered({ + ok: true, + command: "issue comment create", + repo, + dryRun: true, + planned: true, + issueNumber, + readCommands: issueCommentReadCommands(repo, issueNumber), + ...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }), + }); + } + const { owner, name } = repoParts(repo); + const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); + if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber }); + return withIssueCommentCreateRendered({ + ok: true, + command: "issue comment create", + repo, + issueNumber, + comment: compactCommentSummary(comment), + bodySource, + bodyChars: body.length, + bodySha: bodySha(body), + bodyPreview: preview(body), + source: String(bodySource.kind ?? "unknown"), + readCommands: issueCommentReadCommands(repo, issueNumber), + rest: true, + }); +} + +export async function commentPatch(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, options: GitHubOptions, commandName?: string): Promise<GitHubCommandResult> { + const command = commandName ?? `${ownerKind} comment patch`; + const concurrencyOptionError = assertConcurrencyOptions(options, command); + if (concurrencyOptionError !== null) return concurrencyOptionError; + let patchInput: { patch: string; patchSource: Record<string, unknown> }; + try { + patchInput = readBodyPatch(options, command); + } catch (error) { + return validationError(command, repo, error instanceof Error ? error.message : String(error), { commentId }); + } + const { patch, patchSource } = patchInput; + const readCommands = ownerKind === "issue" + ? { + comments: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --json comments`, + full: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --full`, + note: "GitHub comment patch targets commentId directly; use the owning issue number to read the surrounding comments.", + } + : { + comments: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --json comments`, + full: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --full`, + note: "GitHub PR comments are issue comments; use the owning PR number through gh issue view to read the surrounding comments.", + }; + const oldComment = await getIssueComment(token, repo, commentId); + if (isGitHubError(oldComment)) return commandError(command, repo, oldComment, { commentId, phase: "read-before-patch" }); + const concurrencyError = validateCommentConcurrency(repo, commentId, oldComment, options, command); + if (concurrencyError !== null) return concurrencyError; + const oldBody = oldComment.body ?? ""; + let patched: { newBody: string; patchSummary: Record<string, unknown> }; + try { + patched = applyVirtualBodyPatch("comment.md", oldBody, patch); + } catch (error) { + return validationError(command, repo, "body patch failed; no GitHub write performed", { + commentId, + patchSource, + patchError: bodyPatchErrorSummary(error), + oldBody: { + bodyChars: oldBody.length, + bodySha: bodySha(oldBody), + updatedAt: oldComment.updated_at ?? null, + }, + noWrite: true, + }); + } + const bodyChange = { + oldBodyChars: oldBody.length, + newBodyChars: patched.newBody.length, + deltaChars: patched.newBody.length - oldBody.length, + oldBodySha: bodySha(oldBody), + newBodySha: bodySha(patched.newBody), + oldUpdatedAt: oldComment.updated_at ?? null, + }; + const concurrency = { + checked: true, + oldCommentUpdatedAt: oldComment.updated_at ?? null, + oldBodySha: bodySha(oldBody), + expectUpdatedAt: options.expectUpdatedAt ?? null, + expectBodySha: options.expectBodySha ?? null, + }; + if (options.dryRun) { + return { + ok: true, + command, + repo, + dryRun: true, + planned: true, + commentId, + patchSource, + patch: patched.patchSummary, + bodyPreview: preview(patched.newBody), + bodyPreviewLines: previewLines(patched.newBody, 4), + bodyChars: patched.newBody.length, + bodySha: bodySha(patched.newBody), + bodyChange, + concurrency: { ...concurrency, dryRunNoWrite: true }, + readCommands, + wouldPatch: { + commentId, + bodyChars: patched.newBody.length, + bodySha: bodySha(patched.newBody), + method: "PATCH", + path: `/repos/{owner}/{repo}/issues/comments/${commentId}`, + }, + }; + } + const { owner, name } = repoParts(repo); + const comment = await githubRequest<GitHubComment>(token, "PATCH", `/repos/${owner}/${name}/issues/comments/${commentId}`, { body: patched.newBody }); + if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId }); + return { + ok: true, + command, + repo, + commentId, + comment: compactCommentSummary(comment), + patchSource, + patch: patched.patchSummary, + bodyChange: { + ...bodyChange, + newUpdatedAt: comment.updated_at ?? null, + }, + concurrency, + readCommands, + request: { + method: "PATCH", + path: `/repos/${owner}/${name}/issues/comments/${commentId}`, + bodyChars: patched.newBody.length, + }, + rest: true, + }; +} + +export async function commentUpdate(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, options: GitHubOptions, commandName?: string): Promise<GitHubCommandResult> { + const command = commandName ?? `${ownerKind} comment update`; + let bodyInput: { body: string; bodySource: Record<string, unknown> }; + try { + bodyInput = readIssueCommentBody(options, command); + } catch (error) { + return validationError(command, repo, error instanceof Error ? error.message : String(error), { commentId }); + } + const { body, bodySource } = bodyInput; + const readCommands = ownerKind === "issue" + ? { + comments: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --json comments`, + full: `bun scripts/cli.ts gh issue view <issueNumber> --repo ${repo} --full`, + note: "GitHub comment update targets commentId directly; use the owning issue number to read the surrounding comments.", + } + : { + comments: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --json comments`, + full: `bun scripts/cli.ts gh issue view <prNumber> --repo ${repo} --full`, + note: "GitHub PR comments are issue comments; use the owning PR number through gh issue view to read the surrounding comments.", + }; + const writePlanCommand = ownerKind === "issue" ? "issue comment update" : "pr comment update"; + if (options.dryRun) { + return { + ok: true, + command, + repo, + dryRun: true, + planned: true, + commentId, + readCommands, + ...writeBodyPlan(writePlanCommand, repo, body, bodySource, { commentId }), + }; + } + const { owner, name } = repoParts(repo); + const comment = await githubRequest<GitHubComment>(token, "PATCH", `/repos/${owner}/${name}/issues/comments/${commentId}`, { body }); + if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId }); + return { + ok: true, + command, + repo, + commentId, + comment: compactCommentSummary(comment), + bodySource, + bodyChars: body.length, + bodySha: bodySha(body), + bodyPreview: preview(body), + source: String(bodySource.kind ?? "unknown"), + readCommands, + request: { + method: "PATCH", + path: `/repos/${owner}/${name}/issues/comments/${commentId}`, + bodyChars: body.length, + }, + rest: true, + }; +} + +export async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> { + const command = `${ownerKind} comment delete`; + const { owner, name } = repoParts(repo); + if (dryRun) { + return { + ok: true, + command, + repo, + dryRun: true, + planned: true, + commentId, + request: { method: "DELETE", path: `/repos/{owner}/{repo}/issues/comments/${commentId}` }, + }; + } + const result = await githubRequest<null>(token, "DELETE", `/repos/${owner}/${name}/issues/comments/${commentId}`); + if (isGitHubError(result)) return commandError(command, repo, result, { commentId }); + return { + ok: true, + command, + repo, + commentId, + deleted: true, + request: { method: "DELETE", path: `/repos/${owner}/${name}/issues/comments/${commentId}` }, + rest: true, + }; +} + +export async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean, options?: GitHubOptions): Promise<GitHubCommandResult> { + const command = state === "closed" ? "issue close" : "issue reopen"; + let lifecycleComment: { body: string; bodySource: Record<string, unknown> } | null = null; + try { + lifecycleComment = options === undefined ? null : readIssueLifecycleCommentBody(options, command); + } catch (error) { + return validationError(command, repo, error instanceof Error ? error.message : String(error), { issueNumber }); + } + if (dryRun) { + return { + ok: true, + command, + dryRun: true, + repo, + issueNumber, + disclosure: issueLifecycleDisclosure(repo, issueNumber, true), + readCommands: issueBodyReadCommands(repo, issueNumber), + comment: lifecycleComment === null + ? null + : { + planned: true, + ...writeBodyPlan("issue comment create", repo, lifecycleComment.body, lifecycleComment.bodySource, { issueNumber }), + }, + wouldPatch: { state }, + }; + } + const { owner, name } = repoParts(repo); + let commentSummary: Record<string, unknown> | null = null; + if (lifecycleComment !== null) { + const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body: lifecycleComment.body }); + if (isGitHubError(comment)) { + return commandError(command, repo, comment, { + issueNumber, + phase: "comment", + commentBodySource: lifecycleComment.bodySource, + commentBodyChars: lifecycleComment.body.length, + commentBodySha: bodySha(lifecycleComment.body), + }); + } + commentSummary = compactCommentSummary(comment); + } + const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state }); + if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber, phase: "state", comment: commentSummary }); + return { + ok: true, + command, + repo, + comment: commentSummary, + issue: issueLifecycleSummary(issue), + disclosure: issueLifecycleDisclosure(repo, issueNumber, false), + readCommands: issueBodyReadCommands(repo, issueNumber), + rest: true, + }; +} diff --git a/scripts/src/gh/escape-scan.ts b/scripts/src/gh/escape-scan.ts new file mode 100644 index 00000000..ea4d1b58 --- /dev/null +++ b/scripts/src/gh/escape-scan.ts @@ -0,0 +1,365 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:7607-7962. + +import { preview } from "./auth-and-safety"; +import { commandError, isGitHubError, runnerDisposition } from "./client"; +import { issueListPaginationSummary, listIssueComments, listIssues } from "./issue-read"; +import { ISSUE_SCAN_MAX_FINDINGS, MIN_SAFE_BODY_SCAN_CHARS } from "./types"; +import type { EscapeBodyKind, EscapeCleanupSuggestion, EscapeFindingClassification, EscapeFindingSeverity, EscapeMatchFinding, EscapePatternDefinition, EscapeScanEntry, GitHubCommandResult } from "./types"; + +export function escapeSnippet(text: string, index: number, radius = 80): string { + const start = Math.max(0, index - radius); + const end = Math.min(text.length, index + radius + 40); + return text.slice(start, end).replace(/\n/g, "\\n"); +} + +export function lineColumnAt(text: string, index: number): { lineNumber: number; column: number } { + let lineNumber = 1; + let column = 1; + for (let cursor = 0; cursor < index && cursor < text.length; cursor += 1) { + if (text[cursor] === "\n") { + lineNumber += 1; + column = 1; + } else { + column += 1; + } + } + return { lineNumber, column }; +} + +export function lineTextAt(text: string, lineNumber: number): string { + const lines = text.split(/\r?\n/); + return lines[lineNumber - 1] ?? ""; +} + +export function isInsideFencedCodeBlock(text: string, index: number): boolean { + const lines = text.slice(0, index).split(/\r?\n/); + let openFence = false; + for (const line of lines) { + const trimmed = line.trimStart(); + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) openFence = !openFence; + } + return openFence; +} + +export function isInsideInlineCode(text: string, index: number): boolean { + const { lineNumber, column } = lineColumnAt(text, index); + const line = lineTextAt(text, lineNumber); + const before = line.slice(0, Math.max(0, column - 1)); + const after = line.slice(Math.max(0, column - 1)); + return (before.match(/`/g)?.length ?? 0) % 2 === 1 && (after.match(/`/g)?.length ?? 0) > 0; +} + +export function isExplanatoryLiteralBackslashN(text: string, index: number): boolean { + if (isInsideFencedCodeBlock(text, index) || isInsideInlineCode(text, index)) return true; + const window = text.slice(Math.max(0, index - 90), Math.min(text.length, index + 90)).toLowerCase(); + return [ + "字面量", + "说明", + "示例", + "举例", + "提到", + "引用", + "文本", + "字符串", + "escape", + "literal", + "example", + "mention", + "quoted", + "反斜杠", + ].some((keyword) => window.includes(keyword)); +} + +export function cleanupEscapedText(text: string): string { + return text + .replace(/\\r\\n/g, "\n") + .replace(/\\n/g, "\n") + .replace(/\\t/g, "\t") + .replace(/\\x0a/gi, "\n") + .replace(/\\x1b/gi, "") + .replace(/\\u001b/gi, "") + .replace(/\\033/g, "") + .replace(/\\`/g, "`"); +} + +export function escapeBodyId(bodyKind: EscapeBodyKind, issueNumber: number, issueId: number, commentId?: number): string { + return bodyKind === "issue-body" + ? `issue:${issueNumber}:body:${issueId}` + : `issue:${issueNumber}:comment:${commentId ?? "unknown"}`; +} + +export function textFindingPatterns(): EscapePatternDefinition[] { + return [ + { + kind: "literal-backslash-n", + pattern: /\\n/g, + description: "literal backslash-n", + }, + { + kind: "literal-backslash-t", + pattern: /\\t/g, + description: "literal backslash-t", + }, + { + kind: "escaped-backtick", + pattern: /\\`/g, + description: "escaped backtick", + }, + { + kind: "shell-escaped-newline", + pattern: /\\r\\n|\\012|\\x0a/gi, + description: "shell escaped newline", + }, + { + kind: "ansi-escape-literal", + pattern: /\\x1b|\\u001b|\\033/gi, + description: "ANSI escape literal", + }, + ]; +} + +export function bodyRiskFindings(kind: EscapeBodyKind, issueNumber: number, issueId: number, body: string | null, url: string, commentId?: number): EscapeMatchFinding[] { + const bodyChars = body === null ? null : body.length; + const bodyTrimmedChars = body === null ? null : body.trim().length; + const location = { + type: kind === "issue-body" ? "issue" as const : "comment" as const, + bodyKind: kind, + bodyId: escapeBodyId(kind, issueNumber, issueId, commentId), + issueNumber, + issueId, + ...(commentId === undefined ? {} : { commentId }), + url, + bodyChars, + bodyTrimmedChars, + }; + if (body === null) { + return [{ + ...location, + kind: "null-body", + classification: "risk", + severity: "high", + reason: "GitHub returned a null body", + snippet: "", + lineNumber: 0, + column: 0, + match: "", + }]; + } + const trimmed = body.trim(); + if (trimmed.toLowerCase() === "null") { + return [{ + ...location, + kind: "literal-null-body", + classification: "risk", + severity: "high", + reason: "body text is the literal string null", + snippet: escapeSnippet(body, 0), + lineNumber: 1, + column: 1, + match: "null", + }]; + } + if (trimmed.length === 0) { + return [{ + ...location, + kind: "blank-body", + classification: "risk", + severity: "medium", + reason: "body is blank after trimming", + snippet: "", + lineNumber: 0, + column: 0, + match: "", + }]; + } + if (trimmed.length < MIN_SAFE_BODY_SCAN_CHARS) { + return [{ + ...location, + kind: "short-body", + classification: "risk", + severity: "medium", + reason: `body is shorter than the safe guard threshold (${MIN_SAFE_BODY_SCAN_CHARS} chars)`, + snippet: escapeSnippet(body, 0), + lineNumber: 1, + column: 1, + match: "", + }]; + } + return []; +} + +export function scanText(text: string, patterns: EscapePatternDefinition[], issueNumber: number, issueId: number, bodyKind: EscapeBodyKind, url: string, commentId?: number): EscapeMatchFinding[] { + const findings: EscapeMatchFinding[] = []; + const counts = new Map<string, number>(); + for (const item of patterns) { + item.pattern.lastIndex = 0; + const matches = text.match(item.pattern); + counts.set(item.kind, matches === null ? 0 : matches.length); + } + for (const item of patterns) { + item.pattern.lastIndex = 0; + let match = item.pattern.exec(text); + while (match !== null) { + const { lineNumber, column } = lineColumnAt(text, match.index); + const explanatory = item.kind === "literal-backslash-n" && counts.get(item.kind) === 1 && isExplanatoryLiteralBackslashN(text, match.index); + const classification: EscapeFindingClassification = explanatory ? "explanatory-mention" : "suspected-pollution"; + const severity: EscapeFindingSeverity = explanatory ? "low" : item.kind === "ansi-escape-literal" ? "high" : "medium"; + findings.push({ + type: bodyKind === "issue-body" ? "issue" : "comment", + bodyKind, + bodyId: escapeBodyId(bodyKind, issueNumber, issueId, commentId), + issueNumber, + issueId, + ...(commentId === undefined ? {} : { commentId }), + url, + kind: item.kind, + classification, + severity, + reason: explanatory + ? "literal backslash-n appears in explanatory context" + : `matched ${item.description}`, + snippet: escapeSnippet(text, match.index), + lineNumber, + column, + match: match[0], + bodyChars: text.length, + bodyTrimmedChars: text.trim().length, + ...(classification === "suspected-pollution" + ? { cleanupPreview: { before: escapeSnippet(text, match.index), after: escapeSnippet(cleanupEscapedText(text), match.index) } } + : {}), + }); + if (findings.length >= ISSUE_SCAN_MAX_FINDINGS) return findings; + match = item.pattern.exec(text); + } + } + return findings; +} + +export function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupSuggestion[] { + const groups = new Map<string, EscapeMatchFinding[]>(); + for (const finding of findings) { + if (finding.type === "comment-scan-error") continue; + if (finding.classification !== "suspected-pollution" && finding.kind !== "short-body" && finding.kind !== "null-body" && finding.kind !== "blank-body" && finding.kind !== "literal-null-body") continue; + const locatorKey = `${finding.bodyKind}:${finding.issueNumber}:${finding.issueId}:${finding.commentId ?? ""}`; + const list = groups.get(locatorKey); + if (list === undefined) groups.set(locatorKey, [finding]); + else list.push(finding); + } + + const suggestions: EscapeCleanupSuggestion[] = []; + for (const group of groups.values()) { + const first = group[0]; + const suspected = group.some((finding) => finding.classification === "suspected-pollution"); + const bodyRisk = group.some((finding) => finding.classification === "risk"); + suggestions.push({ + type: first.bodyKind, + bodyId: first.bodyId, + issueNumber: first.issueNumber, + issueId: first.issueId, + ...(first.commentId === undefined ? {} : { commentId: first.commentId }), + url: first.url, + classification: suspected ? "suspected-pollution" : "risk", + action: first.bodyKind === "issue-body" + ? (suspected ? "rewrite-issue-body-with-body-stdin" : "review-body-length") + : "review-comment-manually", + reason: suspected + ? "suspected shell escape pollution should be rewritten from heredoc/stdin" + : bodyRisk + ? "body length/null risk should be reviewed before any write" + : "no cleanup needed", + findings: group.map((finding) => ({ + kind: finding.kind, + classification: finding.classification, + severity: finding.severity, + lineNumber: finding.lineNumber, + column: finding.column, + snippet: finding.snippet, + match: finding.match, + })), + ...(first.bodyKind === "issue-body" + ? { + plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-stdin --dry-run <<'EOF'\n<cleaned body>\nEOF`, + bodyInputHint: "pass cleaned Markdown through --body-stdin with a quoted heredoc; --body-file is only for reusable files", + preview: { + before: first.cleanupPreview?.before ?? first.snippet, + after: first.cleanupPreview?.after ?? first.snippet, + }, + } + : { + preview: { + before: first.cleanupPreview?.before ?? first.snippet, + after: first.cleanupPreview?.after ?? first.snippet, + }, + }), + }); + } + return suggestions; +} + +export async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise<GitHubCommandResult> { + const issues = await listIssues(token, repo, "all", limit); + if (isGitHubError(issues)) return commandError(commandName, repo, issues); + const issueOnly = issues.items; + + const patterns = textFindingPatterns(); + const findings: EscapeScanEntry[] = []; + let scannedComments = 0; + for (const issue of issueOnly) { + findings.push(...bodyRiskFindings("issue-body", issue.number, issue.id, issue.body, issue.html_url)); + findings.push(...scanText(issue.body ?? "", patterns, issue.number, issue.id, "issue-body", issue.html_url)); + const comments = await listIssueComments(token, repo, issue.number); + if (isGitHubError(comments)) { + findings.push({ + type: "comment-scan-error", + bodyKind: "comment-body", + bodyId: escapeBodyId("comment-body", issue.number, issue.id), + issueNumber: issue.number, + issueId: issue.id, + url: issue.html_url, + degradedReason: comments.degradedReason, + runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason), + details: comments, + }); + continue; + } + for (const comment of comments) { + scannedComments += 1; + findings.push(...bodyRiskFindings("comment-body", issue.number, issue.id, comment.body, comment.html_url, comment.id)); + findings.push(...scanText(comment.body ?? "", patterns, issue.number, issue.id, "comment-body", comment.html_url, comment.id)); + } + } + const cleanupSuggestions = summarizeCleanupSuggestion(findings); + const summary = findings.reduce((acc, finding) => { + if (finding.type === "comment-scan-error") { + acc.commentScanErrors += 1; + return acc; + } + if (finding.classification === "suspected-pollution") acc.suspectedPollution += 1; + if (finding.classification === "explanatory-mention") acc.explanatoryMention += 1; + if (finding.classification === "risk") acc.risk += 1; + if (finding.kind === "null-body" || finding.kind === "literal-null-body" || finding.kind === "blank-body" || finding.kind === "short-body") acc.bodyRisks += 1; + return acc; + }, { suspectedPollution: 0, explanatoryMention: 0, risk: 0, bodyRisks: 0, commentScanErrors: 0 }); + return { + ok: true, + command: commandName, + repo, + dryRun, + planned: true, + scannedIssues: issueOnly.length, + rawIssues: issues.rawCount, + pagination: issueListPaginationSummary(issues), + scannedComments, + findings, + cleanupSuggestions, + summary: { + findings: findings.length, + suggestions: cleanupSuggestions.length, + ...summary, + }, + note: dryRun + ? "Dry-run cleanup planning only; no issue or comment content was modified." + : "Read-only scan; no issue or comment content was modified.", + }; +} diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts new file mode 100644 index 00000000..fcfc7fb8 --- /dev/null +++ b/scripts/src/gh/help.ts @@ -0,0 +1,195 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:8158-8346. + +import { positionalArgs } from "./options"; +import { DEFAULT_REPO } from "./types"; + +export function ghHelp(): unknown { + return { + command: "gh", + output: "json", + usage: [ + "bun scripts/cli.ts gh auth status [--repo owner/name]", + "bun scripts/cli.ts gh repo view <owner/repo>|--repo owner/name", + "bun scripts/cli.ts gh repo create <owner/repo>|--repo owner/name [--private|--public] [--description text] [--auto-init] [--dry-run]", + "bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--title-prefix 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 view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments,commentCount] [--raw|--full]", + "bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]", + "bun scripts/cli.ts gh issue attachment list <number|url|owner/repo#number> [--repo owner/name] [--number N compat]", + "bun scripts/cli.ts gh issue attachment download <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--attachment index|assetId|url] [--output path] [--dry-run]", + "bun scripts/cli.ts gh issue create --title <title> (--body-stdin|--body-file <file|->) [--label label[,label...]]... [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue update <number> --mode replace|append (--body-stdin|--body-file <file|->) [--title title] [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", + "bun scripts/cli.ts gh issue patch <number|url|owner/repo#number> --body-patch-stdin [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", + "bun scripts/cli.ts gh issue edit <number> (--body-stdin|--body-file <file|->) [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]", + "bun scripts/cli.ts gh issue edit 24 --body-stdin --notify-claudeqq-brief-diff [--dry-run]", + "bun scripts/cli.ts gh issue comment create <number> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh issue comment update <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh issue comment patch <commentId> --body-patch-stdin [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue comment edit <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for issue comment update]", + "bun scripts/cli.ts gh issue comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--number N compat] [--comment <short-text>|--comment-stdin|--comment-file <file|->] [--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]", + "bun scripts/cli.ts gh issue board-audit [--repo owner/name] [--board-issue 20] [--limit N] [--known-meta-issue N[,N...]] [--ignore-issue N[,N...]] [--dry-run]", + "bun scripts/cli.ts gh issue board-row list [--repo owner/name] --board-issue 20 [--state open|closed|all] [--dry-run]", + "bun scripts/cli.ts gh issue board-row get <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20", + "bun scripts/cli.ts gh issue board-row update <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --field progress|status|validation|branch|tasks|focus --value <text> [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue board-row add <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed --row-file <markdown-row-file> [--dry-run] [--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue board-row upsert <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --section open|closed [--category text] --branch <branch> --tasks <task> --summary <text> --focus <text> --validation <text> --progress <text> [--status OPEN|CLOSED] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue board-row move <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 --to open|closed [--status OPEN|CLOSED] [--dry-run] [--expect-body-sha sha256]", + "bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", + "bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [compatibility alias for gh pr preflight]", + "bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]", + "bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]", + "bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]", + "bun scripts/cli.ts gh pr view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]", + "bun scripts/cli.ts gh pr read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for pr view]", + "bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options]", + "bun scripts/cli.ts gh pr closeout <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options; compatibility alias for pr preflight]", + "bun scripts/cli.ts gh pr create --title <title> (--body-stdin|--body-file <file|->|--body <text>) --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]", + "bun scripts/cli.ts gh pr edit <number> [--title title] [--body-stdin|--body-file <file|->|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-stdin|--body-file <file|->|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr comment create <number> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr comment update <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr comment edit <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]", + "bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch] [--dry-run]", + "bun scripts/cli.ts gh pr delete <number> [unsupported: use close]", + ], + defaults: { repo: DEFAULT_REPO }, + notes: [ + "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.", + "repo view/create use GitHub REST through the same token path. repo create defaults to private repositories, preflights existing repos, supports --dry-run, and refuses duplicate creation.", + "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. --title-prefix filters the bounded listed issues locally by exact title startsWith, useful for [FEEDBACK] dedupe, and reports titleFilter input/output counts. 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 view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection; explicit --json fields limit output even with --raw/--full; full body is included only when requested with --json body or when --json is omitted and --full/--raw requests all fields. Unsupported fields fail structurally.", + "issue attachment list/download scan issue body and comments for GitHub user attachment URLs (`https://github.com/user-attachments/assets/...`). list is read-only and returns bounded attachment metadata. download writes the selected attachment to --output or /tmp/unidesk-gh-attachments, returns bytes/SHA-256/content-type/path, redacts redirected signed URL query parameters, and never prints binary bytes.", + "--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch and gh pr list/read/view. For read/view commands, an explicit --json field list narrows the disclosure and prevents accidental full body/comment expansion. 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.", + "CLI output larger than config/unidesk-cli.yaml output.maxStdoutBytes is automatically written to /tmp/unidesk-cli-output; stdout stays bounded with outputTruncated=true, warning text, dump file metadata, and drill-down read commands.", + "issue create accepts --body-stdin or --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-stdin is the first-class heredoc/stdin source for Markdown bodies. Use quoted heredoc syntax such as bun scripts/cli.ts gh issue comment create 1 --body-stdin <<'EOF' so real newlines, backticks, and tables are read as stdin bytes instead of shell arguments.", + "update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.", + "issue edit is a compatibility alias for issue update --mode replace.", + "issue update accepts --body-stdin or --body-file <file|->, 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 patch reads the current GitHub issue body, applies a Codex apply_patch envelope against virtual file issue.md from --body-patch-stdin or --body-patch-file <file|->, then runs the same issue body guard before PATCH. It returns old/new bodySha, updatedAt, patch summary, and bounded previews; context mismatch fails with redacted diagnostics and no GitHub write.", + "issue comment create/update/edit accept --body-stdin or --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. Use comment update/edit to correct existing wording in place; delete remains for intentional removal.", + "issue comment patch reads the current issue comment by commentId, applies a Codex apply_patch envelope against virtual file comment.md, then PATCHes only that comment. It returns comment id, old/new bodySha, updatedAt, patch summary, and redacted mismatch diagnostics without echoing the full comment body.", + "issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text>, --comment-stdin, or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-stdin is the first-class heredoc path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view 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, prefer quoted heredoc stdin: bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin <<'EOF' ... EOF or gh issue comment create <number> --body-stdin <<'EOF' ... EOF. --body-file <file|-> remains available for reusable files and pipes.", + "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-stdin for heredoc/stdin or --body-file <file|-> for reusable files.", + "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.", + "issue board-audit is read-only and defaults to repo pikasTech/unidesk plus board issue #20. It reads only the board issue body, returns body size/SHA and parsed Markdown board sections, and no longer validates GitHub open/closed issue coverage against OPEN/CLOSED tables. The legacy coverage fields remain present as empty arrays/zero counts for compatibility.", + "issue board-row list/get reuse the board-audit table parser and are read-only. board-row update changes one table cell by issue number, returns old/new row, body SHA, body guard and request plan, and defaults to dry-run unless --expect-updated-at or --expect-body-sha is supplied for the guarded PATCH. Field aliases map status and validation to the 验收状态 column, tasks to 相关 Code Queue 任务, and focus to 当前关注点.", + "issue board-row upsert updates an existing row when the issue is already present, or generates a complete row in --section open|closed when missing. It returns operation=update or operation=add, defaults to dry-run, requires --expect-body-sha or --expect-updated-at before PATCH, and refuses section migration; use board-row move for OPEN/CLOSED migration.", + "issue board-row add/move/delete are row-scoped #20 table mutations. add validates a one-line --row-file against the target table column count, Issue column, and GitHub 状态 column; move refuses duplicate/ambiguous rows and can update GitHub 状态 via --status; delete removes only the matched row. All three default to dry-run and require --expect-body-sha or --expect-updated-at before PATCH. add/move/delete return old/new row, body SHA, and line/section plan details for the parsed table mutation, and the shared #20 guard warns about HWLAB product/user issue routing in favor of pikasTech/HWLAB without refusing the write.", + "issue edit 24 --notify-claudeqq-brief-diff remains the legacy #24 notification helper: it reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.", + "Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.", + "comment update/edit PATCHes /repos/{owner}/{repo}/issues/comments/{comment_id} and preserves the comment id/timeline; comment delete is supported because GitHub supports deleting issue comments, but routine wording fixes should use update/edit. issue/pr hard delete is unsupported and close is the lifecycle alternative.", + "PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.", + "PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.", + "PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment update/edit/delete treat --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; explicit --json fields limit output even with --raw/--full. Mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --json is omitted and --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.", + "PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.", + "PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.", + "PR preflight is a low-noise read-only closeout helper for explicit diagnosis only. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. It is not a required step before gh pr merge. Use --full or --raw to include all fetched status contexts.", + "PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Use --dry-run to see the exact merge plan without writing.", + ], + }; +} + +export function isGhHelpRequest(args: string[]): boolean { + if (args.length === 0) return true; + if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") return true; + if (args.includes("--help") || args.includes("-h")) return true; + const cleaned = args.filter((arg) => arg !== "--help" && arg !== "-h"); + return positionalArgs(cleaned).includes("help"); +} + +export function ghHelpCommandTokens(args: string[]): string[] { + const cleaned = args.filter((arg) => arg !== "--help" && arg !== "-h"); + const positionals = positionalArgs(cleaned); + if (positionals[0] === "help") positionals.shift(); + if (positionals[positionals.length - 1] === "help") positionals.pop(); + return positionals; +} + +export function ghHelpUsageLines(): string[] { + const help = ghHelp() as { usage?: unknown }; + if (!Array.isArray(help.usage)) return []; + return help.usage.filter((line): line is string => typeof line === "string"); +} + +export function ghUsageCommandTokens(usageLine: string): string[] { + const prefix = "bun scripts/cli.ts gh "; + if (!usageLine.startsWith(prefix)) return []; + const tokens: string[] = []; + for (const token of usageLine.slice(prefix.length).split(/\s+/u)) { + if (token.length === 0) continue; + if (token.startsWith("[") || token.startsWith("<") || token.startsWith("--")) break; + tokens.push(token); + } + return tokens; +} + +export function ghUsageMatchesCommandTokens(usageLine: string, requestedTokens: string[]): boolean { + const usageTokens = ghUsageCommandTokens(usageLine); + if (requestedTokens.length === 0 || requestedTokens.length > usageTokens.length) return false; + return requestedTokens.every((requested, index) => usageTokens[index]?.split("|").includes(requested) === true); +} + +export function ghScopedHelpNotes(tokens: string[]): string[] { + const key = tokens.join(" "); + const notes = [ + "Scoped help is bounded to the requested command or command group; use `bun scripts/cli.ts gh --help` for the full top-level command index.", + ]; + if (key === "issue comment" || key.startsWith("issue comment ")) { + notes.push("Issue comments use `--body-stdin` or `--body-file <file|->` for Markdown bodies; inline `--body` is only for short single-line comments."); + notes.push("Use `issue comment update/edit` for wording fixes, `issue comment patch` for apply_patch-style local edits, and `issue comment delete` only for intentional removal."); + } else if (key === "issue close" || key === "issue reopen") { + notes.push("Issue close/reopen can post a lifecycle comment with `--comment`, `--comment-stdin`, or `--comment-file <file|->` before changing state."); + notes.push("For long closeout evidence, prefer `--comment-stdin` with a quoted heredoc."); + } else if (key === "pr comment" || key.startsWith("pr comment ")) { + notes.push("PR comments are GitHub issue comments under the hood; use comment id targets for update/edit/delete."); + } else if (key === "pr merge") { + notes.push("PR merge is one-command guarded: it performs the readiness check itself; `gh pr preflight` is optional read-only diagnosis, not a required first step."); + notes.push("When GitHub reports mergeability as UNKNOWN/null, merge automatically retries with YAML-configured exponential backoff and shows retry attempts as N/M."); + } + return notes; +} + +export function ghScopedHelp(args: string[]): unknown | null { + if (!isGhHelpRequest(args)) return null; + const requestedTokens = ghHelpCommandTokens(args); + if (requestedTokens.length === 0) return ghHelp(); + const usageLines = ghHelpUsageLines(); + for (let length = requestedTokens.length; length >= 1; length -= 1) { + const scopedTokens = requestedTokens.slice(0, length); + const usage = usageLines.filter((line) => ghUsageMatchesCommandTokens(line, scopedTokens)); + if (usage.length === 0) continue; + return { + command: `gh ${scopedTokens.join(" ")}`, + output: "json", + scoped: true, + requestedCommand: requestedTokens.join(" "), + matchedCommand: scopedTokens.join(" "), + usage, + notes: ghScopedHelpNotes(scopedTokens), + fullHelpCommand: "bun scripts/cli.ts gh --help", + }; + } + return { + command: `gh ${requestedTokens.join(" ")}`, + output: "json", + scoped: true, + requestedCommand: requestedTokens.join(" "), + usage: [], + message: "No scoped help entry matched this gh command; use the top-level help for the full command index.", + fullHelpCommand: "bun scripts/cli.ts gh --help", + }; +} diff --git a/scripts/src/gh/index.ts b/scripts/src/gh/index.ts new file mode 100644 index 00000000..e8afa81d --- /dev/null +++ b/scripts/src/gh/index.ts @@ -0,0 +1,522 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:8348-8845. + +import { issueAttachmentDownload, issueAttachmentList } from "./attachments"; +import { resolveToken } from "./auth-and-safety"; +import { authStatus, prFiles, prList, prRead, prView } from "./auth-pr-read"; +import { issueBoardAudit } from "./board-audit"; +import { issueBoardRowAdd, issueBoardRowDelete, issueBoardRowGet, issueBoardRowList, issueBoardRowMove, issueBoardRowUpdate, issueBoardRowUpsert } from "./board-commands"; +import { authRequired, unsupportedCommand, validationError } from "./client"; +import { commentDelete, commentPatch, commentUpdate, issueComment, issueState } from "./comments-and-state"; +import { issueScanEscape } from "./escape-scan"; +import { ghHelp, ghScopedHelp } from "./help"; +import { issueList } from "./issue-list"; +import { issueRead, issueView } from "./issue-read"; +import { repoCreate, repoView } from "./issue-summary"; +import { issueCreate, issueEdit, issuePatch } from "./issue-write"; +import { allowsNumberTargetAlias, isIssueReadCommand, isPrReadCommand, optionValue, optionWasProvided, parseOptions } from "./options"; +import { prComment, prCreate, prState, prUpdate } from "./pr-commands"; +import { prMerge, prPreflight } from "./pr-merge"; +import { isGitHubCommandResult, issueReadJsonFields, prReadJsonFields, readDisclosureOptions, readViewSupportedJsonFields, resolvePositionalIssueReference, resolvePositionalNumberReference, resolvePositionalPrReference, resolveReadViewNumberReference, unknownGhOptionDetails, withNumberOptionHint } from "./refs"; +import { issueStaleClose } from "./stale-close"; +import { DEFAULT_REPO } from "./types"; +import type { GitHubCommandResult, GitHubOptions } from "./types"; + +export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> { + const scopedHelp = ghScopedHelp(args); + if (scopedHelp !== null) return scopedHelp; + const [top, sub, third] = args; + if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp(); + let options: GitHubOptions; + try { + options = parseOptions(args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + const repo = optionValue(args, "--repo") ?? DEFAULT_REPO; + const unknownOption = /^unknown gh option:\s+(.+)$/u.exec(message)?.[1]; + return unknownOption !== undefined + ? unsupportedCommand(command, repo, message, unknownGhOptionDetails(args, unknownOption)) + : validationError(command, repo, message); + } + if (options.notifyClaudeQqBriefDiff && !(top === "issue" && sub === "edit")) { + 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 (optionWasProvided(args, "--state") && !(top === "issue" && (sub === "list" || sub === "board-row" && third === "list") || top === "pr" && sub === "list")) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--state is only supported by gh issue list, gh issue board-row list, and gh pr list"); + } + if (optionWasProvided(args, "--json") && !(top === "issue" && (isIssueReadCommand(sub) || sub === "list"))) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + if (!(top === "pr" && (isPrReadCommand(sub) || sub === "list"))) { + 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 === "list" || sub === "update" || sub === "edit" || sub === "patch")) || 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 list/read/view/update/edit/patch, 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 view owner/name#<number> --raw", + "bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state,comments", + "bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin --full <<'EOF'\n<reviewed body>\nEOF", + "bun scripts/cli.ts gh issue patch <number> --repo owner/name --body-patch-stdin --full <<'PATCH'\n*** Begin Patch\n*** Update File: issue.md\n@@\n-old\n+new\n*** End Patch\nPATCH", + "bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full", + "bun scripts/cli.ts gh pr view owner/name#<number> --raw", + `bun scripts/cli.ts gh pr view <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`, + "bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full", + ], + }); + } + if (optionWasProvided(args, "--attachment") && !(top === "issue" && sub === "attachment" && third === "download")) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--attachment is only supported by gh issue attachment download"); + } + if (optionWasProvided(args, "--output") && !(top === "issue" && sub === "attachment" && third === "download")) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--output is only supported by gh issue attachment download"); + } + if (optionWasProvided(args, "--stat") && !(top === "pr" && (sub === "diff" || sub === "files"))) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--stat is only supported by gh pr diff <number> --stat.", { + supportedCommands: [ + "bun scripts/cli.ts gh pr files <number> --repo owner/name --limit 30", + "bun scripts/cli.ts gh pr diff <number> --stat --repo owner/name --limit 30", + ], + }); + } + if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view"; + return validationError(command, options.repo, `--number is only a compatibility alias for single numeric target commands; standard ${standardViewCommand} uses a positional number or URL target.`, { + supportedCommands: [ + "bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state", + "bun scripts/cli.ts gh issue view https://github.com/owner/name/issues/<number> --raw", + "bun scripts/cli.ts gh pr view <number> --repo owner/name --json body,title,state,head,base", + "bun scripts/cli.ts gh pr view https://github.com/owner/name/pull/<number> --raw", + ], + rejectedOption: "--number", + }); + } + if (optionWasProvided(args, "--mode") && !((top === "issue" && (sub === "update" || sub === "edit")) || (top === "pr" && (sub === "update" || sub === "edit")))) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--mode is only supported by gh issue update/edit and gh pr update/edit"); + } + const issueBodyGuardCommand = top === "issue" && (sub === "edit" || sub === "update" || sub === "patch" || sub === "board-row" && ["update", "add", "move", "delete", "upsert"].includes(third ?? "")); + const issueCommentPatchCommand = top === "issue" && sub === "comment" && third === "patch"; + if ((options.allowShortBody || optionWasProvided(args, "--body-profile")) && !issueBodyGuardCommand) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--allow-short-body and --body-profile are only supported by gh issue update/edit/patch and gh issue board-row update/add/move/delete/upsert"); + } + if ((options.expectUpdatedAt !== undefined || options.expectBodySha !== undefined) && !(issueBodyGuardCommand || issueCommentPatchCommand)) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--expect-updated-at and --expect-body-sha are only supported by gh issue update/edit/patch, gh issue comment patch, and gh issue board-row update/add/move/delete/upsert"); + } + if ((optionWasProvided(args, "--body-patch-file") || optionWasProvided(args, "--body-patch-stdin")) && !(top === "issue" && (sub === "patch" || sub === "comment" && third === "patch"))) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--body-patch-file and --body-patch-stdin are only supported by gh issue patch and gh issue comment patch", { + supportedCommands: [ + "bun scripts/cli.ts gh issue patch <number> --repo owner/name --body-patch-stdin", + "bun scripts/cli.ts gh issue comment patch <commentId> --repo owner/name --body-patch-stdin", + ], + }); + } + if ((optionWasProvided(args, "--board-issue") || optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && (sub === "board-audit" || sub === "board-row"))) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--board-issue, --known-meta-issue, and --ignore-issue are only supported by gh issue board-audit and --board-issue by gh issue board-row"); + } + if ((optionWasProvided(args, "--known-meta-issue") || optionWasProvided(args, "--ignore-issue")) && !(top === "issue" && sub === "board-audit")) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--known-meta-issue and --ignore-issue are only supported by gh issue board-audit"); + } + if ((optionWasProvided(args, "--field") || optionWasProvided(args, "--value")) && !(top === "issue" && sub === "board-row" && third === "update")) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--field and --value are only supported by gh issue board-row update"); + } + if (optionWasProvided(args, "--row-file") && !(top === "issue" && sub === "board-row" && third === "add")) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--row-file is only supported by gh issue board-row add"); + } + if (optionWasProvided(args, "--section") && !(top === "issue" && sub === "board-row" && (third === "add" || third === "upsert"))) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--section is only supported by gh issue board-row add/upsert"); + } + if (optionWasProvided(args, "--to") && !(top === "issue" && sub === "board-row" && third === "move")) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--to is only supported by gh issue board-row move"); + } + if (optionWasProvided(args, "--status") && !(top === "issue" && sub === "board-row" && (third === "move" || third === "upsert"))) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--status is only supported by gh issue board-row move/upsert"); + } + if ((optionWasProvided(args, "--category") || optionWasProvided(args, "--branch") || optionWasProvided(args, "--tasks") || optionWasProvided(args, "--summary") || optionWasProvided(args, "--focus") || optionWasProvided(args, "--validation") || optionWasProvided(args, "--progress")) && !(top === "issue" && sub === "board-row" && third === "upsert")) { + 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" || 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, 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, "--title-prefix") && !(top === "issue" && sub === "list")) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--title-prefix 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 (optionWasProvided(args, "--comment") && !(top === "issue" && (sub === "close" || sub === "reopen"))) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--comment/--comment-file is only supported by gh issue close/reopen; use gh issue comment create for standalone comments"); + } + if ((optionWasProvided(args, "--description") || optionWasProvided(args, "--private") || optionWasProvided(args, "--public") || optionWasProvided(args, "--auto-init")) && !(top === "repo" && sub === "create")) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--description, --private, --public, and --auto-init are only supported by gh repo create"); + } + + if (top === "auth" && sub === "status") return authStatus(options.repo); + + if (top === "repo") { + if (sub !== "view" && sub !== "create") { + return unsupportedCommand(`repo ${sub ?? ""}`.trim(), options.repo, "repo supported commands are view and create.", { + supportedCommands: [ + `bun scripts/cli.ts gh repo view ${options.repo}`, + `bun scripts/cli.ts gh repo create ${options.repo} --private --dry-run`, + ], + }); + } + if (sub === "create" && options.dryRun) return repoCreate(options.repo, "", options); + const { token, probe } = resolveToken(true); + const commandName = `repo ${sub}`; + const missing = authRequired(options.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + if (sub === "view") return repoView(options.repo, token); + return repoCreate(options.repo, token, options); + } + + if (top === "preflight") { + const resolved = resolvePositionalPrReference(args, 1, "preflight", options); + if (isGitHubCommandResult(resolved)) return resolved; + return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, "preflight", options.full || options.raw), resolved); + } + + if (top === "issue") { + if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics."); + if (sub === "attachment") { + const action = third; + const commandName = `issue attachment ${action ?? ""}`.trim(); + if (action !== "list" && action !== "download") { + return unsupportedCommand(commandName, options.repo, "issue attachment supported commands are list and download.", { + supportedCommands: [ + "bun scripts/cli.ts gh issue attachment list <number> --repo owner/name", + "bun scripts/cli.ts gh issue attachment download <number> --repo owner/name --attachment 1 --output /tmp/issue-attachment.png", + ], + }); + } + const resolved = resolvePositionalIssueReference(args, 3, commandName, options); + if (isGitHubCommandResult(resolved)) return resolved; + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + if (action === "list") return withNumberOptionHint(issueAttachmentList(resolved.repo, token, resolved.number), resolved); + return withNumberOptionHint(issueAttachmentDownload(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); + } + if (sub === "comment" && third === "delete") { + const resolved = resolvePositionalNumberReference("issue", args, 3, "issue comment delete", options); + if (isGitHubCommandResult(resolved)) return resolved; + const commentId = resolved.number; + if (typeof commentId !== "number") return commentId; + if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "issue", commentId, true), resolved); + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, "issue comment delete", probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue comment delete", { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(commentDelete(resolved.repo, token, "issue", commentId, false), resolved); + } + if (sub === "comment" && third === "patch") { + const commandName = "issue comment patch"; + const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options); + if (isGitHubCommandResult(resolved)) return resolved; + const commentId = resolved.number; + if (typeof commentId !== "number") return commentId; + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(commentPatch(resolved.repo, token, "issue", commentId, { ...options, repo: resolved.repo }, commandName), resolved); + } + if (sub === "comment" && (third === "update" || third === "edit")) { + const commandName = `issue comment ${third}`; + const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options); + if (isGitHubCommandResult(resolved)) return resolved; + const commentId = resolved.number; + if (typeof commentId !== "number") return commentId; + const updateOptions = { ...options, repo: resolved.repo }; + if (options.dryRun) return withNumberOptionHint(commentUpdate(resolved.repo, "", "issue", commentId, updateOptions, commandName), resolved); + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(commentUpdate(resolved.repo, token, "issue", commentId, updateOptions, commandName), resolved); + } + if (sub === "comment" && third === "create") { + const resolved = resolvePositionalIssueReference(args, 3, "issue comment create", options); + if (isGitHubCommandResult(resolved)) return resolved; + const issueNumber = resolved.number; + if (typeof issueNumber !== "number") return issueNumber; + if (options.dryRun) return withNumberOptionHint(issueComment(resolved.repo, "", issueNumber, { ...options, repo: resolved.repo }), resolved); + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, "issue comment create", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue comment create", { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(issueComment(resolved.repo, token, issueNumber, { ...options, repo: resolved.repo }), resolved); + } + if (sub === "scan-escape" || sub === "cleanup-plan") { + const { token, probe } = resolveToken(true); + const commandName = sub === "cleanup-plan" ? "issue cleanup-plan" : "issue scan-escape"; + const missing = authRequired(options.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + return issueScanEscape(options.repo, token, options.limit, options.dryRun || sub === "cleanup-plan", commandName); + } + if (sub === "board-audit") { + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "issue board-audit", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "issue board-audit", { present: false, source: null, ghFallbackAttempted: true }); + return issueBoardAudit(options.repo, token, options); + } + if (sub === "board-row") { + const action = third; + const commandName = `issue board-row ${action ?? ""}`.trim(); + if (action !== "list" && action !== "get" && action !== "update" && action !== "add" && action !== "move" && action !== "delete" && action !== "upsert") { + return unsupportedCommand(commandName, options.repo, "board-row supported commands are list, get, update, add, move, delete, and upsert."); + } + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + if (action === "list") return issueBoardRowList(options.repo, token, options); + const resolvedBoardRow = resolvePositionalIssueReference(args, 3, commandName, options); + if (isGitHubCommandResult(resolvedBoardRow)) return resolvedBoardRow; + const issueNumber = resolvedBoardRow.number; + if (typeof issueNumber !== "number") return issueNumber; + const boardRowOptions = { ...options, repo: resolvedBoardRow.repo }; + if (action === "get") return withNumberOptionHint(issueBoardRowGet(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + if (action === "update") return withNumberOptionHint(issueBoardRowUpdate(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + if (action === "add") return withNumberOptionHint(issueBoardRowAdd(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + if (action === "upsert") return withNumberOptionHint(issueBoardRowUpsert(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + if (action === "move") return withNumberOptionHint(issueBoardRowMove(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + return withNumberOptionHint(issueBoardRowDelete(resolvedBoardRow.repo, token, issueNumber, boardRowOptions), resolvedBoardRow); + } + if (sub === "patch") { + const resolved = resolvePositionalIssueReference(args, 2, "issue patch", options); + if (isGitHubCommandResult(resolved)) return resolved; + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, "issue patch", probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "issue patch", { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(issuePatch(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); + } + if (options.dryRun) { + if (sub === "create") return issueCreate(options.repo, "", options); + if (sub === "edit") { + const issueEditRef = resolvePositionalIssueReference(args, 2, "issue edit", options); + if (isGitHubCommandResult(issueEditRef)) return issueEditRef; + const issueNumber = issueEditRef.number; + const { token } = resolveToken(false); + return withNumberOptionHint(issueEdit(issueEditRef.repo, token ?? "", issueNumber, { ...options, repo: issueEditRef.repo }), issueEditRef); + } + if (sub === "update") { + const issueUpdateRef = resolvePositionalIssueReference(args, 2, "issue update", options); + if (isGitHubCommandResult(issueUpdateRef)) return issueUpdateRef; + const issueNumber = issueUpdateRef.number; + const { token } = resolveToken(false); + return withNumberOptionHint(issueEdit(issueUpdateRef.repo, token ?? "", issueNumber, { ...options, repo: issueUpdateRef.repo }, "issue update"), issueUpdateRef); + } + if (sub === "comment") { + const r = resolvePositionalIssueReference(args, 2, "issue comment", options); + if (isGitHubCommandResult(r)) return r; + return withNumberOptionHint(issueComment(r.repo, "", r.number, { ...options, repo: r.repo }), r); + } + if (sub === "close") { + const r = resolvePositionalIssueReference(args, 2, "issue close", options); + if (isGitHubCommandResult(r)) return r; + return withNumberOptionHint(issueState(r.repo, "", r.number, "closed", true, { ...options, repo: r.repo }), r); + } + if (sub === "reopen") { + const r = resolvePositionalIssueReference(args, 2, "issue reopen", options); + if (isGitHubCommandResult(r)) return r; + return withNumberOptionHint(issueState(r.repo, "", r.number, "open", true, { ...options, repo: r.repo }), r); + } + } + if (sub === "read" || sub === "view") { + const resolved = resolveReadViewNumberReference("issue", sub, third, options, args); + if (isGitHubCommandResult(resolved)) return resolved; + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, `issue ${sub}`, probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); + const disclosure = readDisclosureOptions(options, resolved.shorthand); + if (sub === "read") return withNumberOptionHint(issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure, { includeFullCommentBodies: options.raw || options.full }), resolved); + return withNumberOptionHint(issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure, { includeFullCommentBodies: options.raw || options.full }), resolved); + } + const { token, probe } = resolveToken(true); + 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, options.labels, options.raw || options.full, options.titlePrefix); + if (sub === "stale-close") return issueStaleClose(options.repo, token, options); + if (sub === "create") return issueCreate(options.repo, token, options); + if (sub === "edit") { + const r = resolvePositionalIssueReference(args, 2, "issue edit", options); + if (isGitHubCommandResult(r)) return r; + return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }), r); + } + if (sub === "update") { + const r = resolvePositionalIssueReference(args, 2, "issue update", options); + if (isGitHubCommandResult(r)) return r; + return withNumberOptionHint(issueEdit(r.repo, token, r.number, { ...options, repo: r.repo }, "issue update"), r); + } + if (sub === "comment") { + const r = resolvePositionalIssueReference(args, 2, "issue comment", options); + if (isGitHubCommandResult(r)) return r; + return withNumberOptionHint(issueComment(r.repo, token, r.number, { ...options, repo: r.repo }), r); + } + if (sub === "close") { + const r = resolvePositionalIssueReference(args, 2, "issue close", options); + if (isGitHubCommandResult(r)) return r; + return withNumberOptionHint(issueState(r.repo, token, r.number, "closed", options.dryRun, { ...options, repo: r.repo }), r); + } + if (sub === "reopen") { + const r = resolvePositionalIssueReference(args, 2, "issue reopen", options); + if (isGitHubCommandResult(r)) return r; + return withNumberOptionHint(issueState(r.repo, token, r.number, "open", options.dryRun, { ...options, repo: r.repo }), r); + } + } + + if (top === "pr") { + if (sub === "diff") { + const resolved = resolvePositionalPrReference(args, 2, "pr diff", options); + if (isGitHubCommandResult(resolved)) return resolved; + if (!optionWasProvided(args, "--stat")) { + return unsupportedCommand("pr diff", options.repo, "Raw PR diff output is intentionally unsupported by UniDesk CLI; use gh pr diff <number> --stat or gh pr files for a bounded REST file/stat summary.", { + rawDiffIncluded: false, + supportedCommands: [ + `bun scripts/cli.ts gh pr files ${resolved.number} --repo ${resolved.repo} --limit 30`, + `bun scripts/cli.ts gh pr diff ${resolved.number} --stat --repo ${resolved.repo} --limit 30`, + ], + }); + } + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, "pr diff --stat", probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"), resolved); + } + if (sub === "files") { + const resolved = resolvePositionalPrReference(args, 2, "pr files", options); + if (isGitHubCommandResult(resolved)) return resolved; + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, "pr files", probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr files", { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr files"), resolved); + } + if (sub === "delete") return unsupportedCommand("pr delete", options.repo, "GitHub REST does not support hard-deleting pull requests; use gh pr close for lifecycle deletion semantics."); + if (sub === "preflight" || sub === "closeout") { + const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options); + if (isGitHubCommandResult(resolved)) return resolved; + return withNumberOptionHint(prPreflight(resolved.repo, resolved.number, sub === "closeout" ? "pr closeout" : "pr preflight", options.full || options.raw), resolved); + } + if (sub === "comment" && third === "delete") { + const resolved = resolvePositionalNumberReference("pr", args, 3, "pr comment delete", options); + if (isGitHubCommandResult(resolved)) return resolved; + if (options.dryRun) return withNumberOptionHint(commentDelete(resolved.repo, "", "pr", resolved.number, true), resolved); + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, "pr comment delete", probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(commentDelete(resolved.repo, token, "pr", resolved.number, false), resolved); + } + if (sub === "comment" && (third === "update" || third === "edit")) { + const commandName = `pr comment ${third}`; + const resolved = resolvePositionalNumberReference("pr", args, 3, commandName, options); + if (isGitHubCommandResult(resolved)) return resolved; + const updateOptions = { ...options, repo: resolved.repo }; + if (options.dryRun) return withNumberOptionHint(commentUpdate(resolved.repo, "", "pr", resolved.number, updateOptions, commandName), resolved); + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(commentUpdate(resolved.repo, token, "pr", resolved.number, updateOptions, commandName), resolved); + } + if (sub === "comment" && third === "create") { + const resolved = resolvePositionalPrReference(args, 3, "pr comment create", options); + if (isGitHubCommandResult(resolved)) return resolved; + if (options.dryRun) return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved); + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, "pr comment create", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment create", { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); + } + if (sub === "create") { + if (options.dryRun) return prCreate(options.repo, "", options); + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr create", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr create", { present: false, source: null, ghFallbackAttempted: true }); + return prCreate(options.repo, token, options); + } + if (sub === "comment") { + if (options.dryRun) { + const resolved = resolvePositionalPrReference(args, 2, "pr comment", options); + if (isGitHubCommandResult(resolved)) return resolved; + return withNumberOptionHint(prComment(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }), resolved); + } + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr comment", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true }); + const resolved = resolvePositionalPrReference(args, 2, "pr comment", options); + if (isGitHubCommandResult(resolved)) return resolved; + return withNumberOptionHint(prComment(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); + } + if (sub === "update" || sub === "edit") { + const commandName = `pr ${sub}`; + const resolved = resolvePositionalPrReference(args, 2, commandName, options); + if (isGitHubCommandResult(resolved)) return resolved; + if (options.dryRun && options.mode === "replace") return withNumberOptionHint(prUpdate(resolved.repo, "", resolved.number, { ...options, repo: resolved.repo }, commandName), resolved); + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(prUpdate(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }, commandName), resolved); + } + if (sub === "close" || sub === "reopen") { + const resolved = resolvePositionalPrReference(args, 2, `pr ${sub}`, options); + if (isGitHubCommandResult(resolved)) return resolved; + if (options.dryRun) return withNumberOptionHint(prState(resolved.repo, "", resolved.number, sub === "close" ? "closed" : "open", true), resolved); + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, `pr ${sub}`, probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(prState(resolved.repo, token, resolved.number, sub === "close" ? "closed" : "open", false), resolved); + } + if (sub === "merge") { + const resolved = resolvePositionalPrReference(args, 2, "pr merge", options); + if (isGitHubCommandResult(resolved)) return resolved; + const { token, probe } = resolveToken(true); + const missing = authRequired(options.repo, "pr merge", probe); + if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr merge", { present: false, source: null, ghFallbackAttempted: true }); + return withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved); + } + if (sub !== "list" && !isPrReadCommand(sub)) { + return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/update/edit/delete, and unsupported delete."); + } + if (sub === "read" || sub === "view") { + const resolved = resolveReadViewNumberReference("pr", sub, third, options, args); + if (isGitHubCommandResult(resolved)) return resolved; + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, `pr ${sub}`, probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true }); + const disclosure = readDisclosureOptions(options, resolved.shorthand); + if (sub === "read") return withNumberOptionHint(prRead(resolved.repo, token, resolved.number, prReadJsonFields(options), "pr read", disclosure), resolved); + return withNumberOptionHint(prView(resolved.repo, token, resolved.number, prReadJsonFields(options), disclosure), resolved); + } + 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, options.raw || options.full); + } + + return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() }); +} diff --git a/scripts/src/gh/issue-list.ts b/scripts/src/gh/issue-list.ts new file mode 100644 index 00000000..212dafae --- /dev/null +++ b/scripts/src/gh/issue-list.ts @@ -0,0 +1,143 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:6534-6664. + +import path from "node:path"; +import { commandError, isGitHubError, validationError } from "./client"; +import { githubSearchLabelQualifier, issueListPaginationSummary, listIssues } from "./issue-read"; +import { issueListSummary } from "./issue-summary"; +import { isRecord } from "./notify-claudeqq"; +import { ghShort, ghTable, ghText } from "./render"; +import { GITHUB_REST_PAGE_SIZE, ISSUE_LIST_JSON_FIELDS, MAX_ISSUE_LIST_LIMIT } from "./types"; +import type { GitHubCommandResult, IssueListJsonField, IssueListState } from "./types"; + +export function shellWord(value: string): string { + return JSON.stringify(value); +} + +export function issueListNextCommand(repo: string, state: IssueListState, limit: number, search: string, labels: string[], titlePrefix: 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)); + if (titlePrefix.length > 0) parts.push("--title-prefix", shellWord(titlePrefix)); + for (const label of labels) parts.push("--label", shellWord(label)); + return parts.join(" "); +} + +export function issueListLabelText(value: unknown): string { + if (!Array.isArray(value)) return ghText(value); + const labels = value + .map((item) => typeof item === "string" ? item : isRecord(item) ? ghText(item.name) : ghText(item)) + .filter((item) => item !== "-"); + return labels.length === 0 ? "-" : labels.join(","); +} + +export function withIssueListRendered(result: GitHubCommandResult): GitHubCommandResult { + return { + ...result, + contentType: "text/plain", + renderedText: renderIssueListTable(result), + }; +} + +export function renderIssueListTable(result: GitHubCommandResult): string { + const issues = Array.isArray(result.issues) ? result.issues.map((item) => isRecord(item) ? item : {}) : []; + const rows = issues.map((issue) => [ + issue.number === undefined ? "-" : `#${ghText(issue.number)}`, + ghText(issue.state), + ghShort(ghText(issue.updatedAt), 19), + ghShort(issueListLabelText(issue.labels), 32), + ghShort(ghText(issue.title), 96), + ]); + const next = isRecord(result.next) ? result.next : {}; + const lines = [ + "gh issue list (observed)", + "", + ghTable(["ISSUE", "STATE", "UPDATED", "LABELS", "TITLE"], rows), + "", + "Summary:", + ` repo=${ghText(result.repo)} state=${ghText(result.state)} count=${ghText(result.count)} raw=${ghText(result.rawCount)} hasMore=${ghText(result.hasMore)}`, + ` search=${ghText(result.search)} labels=${issueListLabelText(result.labels)} titlePrefix=${ghText(result.titlePrefix)}`, + ]; + if (result.searchTotalCount !== undefined) lines.push(` searchTotal=${ghText(result.searchTotalCount)} incomplete=${ghText(result.searchIncomplete)}`); + if (typeof next.command === "string" && next.command.length > 0) { + lines.push("", "Next:", ` ${next.command}`); + } + lines.push("", "Disclosure:"); + lines.push(" default view is a bounded table; use --json for selected fields or --full/--raw for structured pagination and request metadata."); + return lines.join("\n"); +} + +export async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string, labels: string[] = [], noDump = false, titlePrefix?: string): Promise<GitHubCommandResult> { + const normalizedSearch = String(search ?? "").trim(); + const normalizedTitlePrefix = String(titlePrefix ?? "").trim(); + if (titlePrefix !== undefined && normalizedTitlePrefix.length === 0) return validationError("issue list", repo, "--title-prefix requires a non-empty value"); + const result = await listIssues(token, repo, state, limit, normalizedSearch, labels); + if (isGitHubError(result)) return commandError("issue list", repo, result, { state, limit, search: normalizedSearch || null, titlePrefix: normalizedTitlePrefix || null, labels }); + const listedIssues = result.items; + const issues = normalizedTitlePrefix.length > 0 + ? listedIssues.filter((issue) => issue.title.startsWith(normalizedTitlePrefix)) + : listedIssues; + const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice(); + const payload: GitHubCommandResult = { + ok: true, + command: "issue list", + repo, + ...(noDump ? { noDump: true } : {}), + state, + limit, + search: normalizedSearch || null, + titlePrefix: normalizedTitlePrefix || null, + ...(normalizedTitlePrefix.length > 0 + ? { + titleFilter: { + field: "title", + operator: "startsWith", + prefix: normalizedTitlePrefix, + scope: "bounded-listed-issues", + inputCount: listedIssues.length, + outputCount: issues.length, + filteredOut: listedIssues.length - issues.length, + }, + } + : {}), + labels, + count: issues.length, + 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, normalizedTitlePrefix), + 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}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE } + : { state, labels, per_page: GITHUB_REST_PAGE_SIZE }, + localTitlePrefixFilter: normalizedTitlePrefix || null, + }, + note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.", + }; + return jsonFields === undefined && !noDump ? withIssueListRendered(payload) : payload; +} diff --git a/scripts/src/gh/issue-read.ts b/scripts/src/gh/issue-read.ts new file mode 100644 index 00000000..3e03f6e0 --- /dev/null +++ b/scripts/src/gh/issue-read.ts @@ -0,0 +1,153 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:5949-6089. + +import path from "node:path"; +import { repoParts } from "./auth-and-safety"; +import { codeQueueBoardCommanderBriefHint } from "./body-profiles"; +import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, issueCommentReadCommands } from "./client"; +import { issueSummary } from "./issue-summary"; +import { commentSummary, compactCommentSummary, compactIssueViewCommentSummary } from "./render"; +import { GITHUB_REST_PAGE_SIZE } from "./types"; +import type { GitHubCommandResult, GitHubComment, GitHubErrorPayload, GitHubIssue, GitHubIssueListPage, GitHubIssueListResult, GitHubIssueSearchResponse, IssueListState, IssueViewJsonField } from "./types"; + +export async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`); +} + +export function githubSearchLabelQualifier(label: string): string { + if (/^[A-Za-z0-9_.:-]+$/u.test(label)) return `label:${label}`; + return `label:"${label.replace(/["\\]/gu, "\\$&")}"`; +} + +export function issueListPaginationSummary(result: GitHubIssueListResult): Record<string, unknown> { + return { + pageSize: result.pageSize, + fetchedPages: result.fetchedPages, + exhausted: result.exhausted, + hasMore: result.hasMore, + pages: result.pages, + }; +} + +export 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(); + 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 hasMore = collected.length > limit || !exhausted; + return { + items: collected.slice(0, limit), + rawCount, + fetchedPages, + pageSize, + exhausted, + hasMore, + pages, + searchTotalCount, + searchIncomplete, + }; +} + +export 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}`); +} + +export async function getIssueComment(token: string, repo: string, commentId: number): Promise<GitHubComment | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubComment>(token, "GET", `/repos/${owner}/${name}/issues/comments/${commentId}`); +} + +export function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined, options: { includeFullCommentBodies?: boolean } = {}): Record<string, unknown> | null { + if (fields === undefined) return null; + const summary = issueSummary(issue); + const selected: Record<string, unknown> = {}; + for (const field of fields) { + if (field === "comments") { + selected.comments = (comments ?? []).map(options.includeFullCommentBodies === true ? commentSummary : compactIssueViewCommentSummary); + } else { + selected[field] = summary[field]; + } + } + return selected; +} + +export async function issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read", disclosure: Record<string, unknown> | null = null, options: { includeFullCommentBodies?: boolean } = {}): Promise<GitHubCommandResult> { + const issue = await getIssue(token, repo, issueNumber); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); + const needsComments = jsonFields === undefined || jsonFields.includes("comments"); + const includeBody = jsonFields === undefined || jsonFields.includes("body"); + const includeFullCommentBodies = options.includeFullCommentBodies === true; + const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null; + if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue, { includeBody, includePreview: false }) }); + return { + ok: true, + command: commandName, + repo, + ...(disclosure === null ? {} : { disclosure }), + issue: issueSummary(issue, { includeBody, includePreview: false }), + codeQueueBoardHint: codeQueueBoardCommanderBriefHint(issueNumber, issue.body ?? ""), + ...(comments === null || jsonFields !== undefined ? {} : { comments: comments.map(includeFullCommentBodies ? commentSummary : compactCommentSummary) }), + ...(jsonFields === undefined ? {} : { + jsonFields, + json: selectedIssueJson(issue, comments, jsonFields, { includeFullCommentBodies }), + compatibility: { + legacyJsonBodyPath: includeBody ? ".data.issue.body" : null, + bodyOmitted: !includeBody, + commentsCompacted: comments !== null && !includeFullCommentBodies, + commentBodiesOmitted: comments !== null && !includeFullCommentBodies, + fullCommentBodiesIncluded: comments !== null && includeFullCommentBodies, + commentsPath: comments === null ? null : ".data.json.comments", + readCommands: { + ...(includeBody ? {} : issueBodyReadCommands(repo, issueNumber)), + ...(comments !== null && !includeFullCommentBodies ? issueCommentReadCommands(repo, issueNumber) : {}), + }, + }, + }), + }; +} + +export async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, disclosure: Record<string, unknown> | null = null, options: { includeFullCommentBodies?: boolean } = {}): Promise<GitHubCommandResult> { + return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure, options); +} diff --git a/scripts/src/gh/issue-summary.ts b/scripts/src/gh/issue-summary.ts new file mode 100644 index 00000000..276a8cdb --- /dev/null +++ b/scripts/src/gh/issue-summary.ts @@ -0,0 +1,201 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:2463-2672. + +import path from "node:path"; +import { bodySha, preview, previewLines, repoParts } from "./auth-and-safety"; +import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, validationError } from "./client"; +import { repoSummary } from "./repo-compare"; +import { ISSUE_LIFECYCLE_PREVIEW_LIMIT } from "./types"; +import type { GitHubAuthenticatedUser, GitHubCommandResult, GitHubIssue, GitHubOptions, GitHubRepository, IssueListJsonField } from "./types"; + +export function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record<string, unknown> { + const body = issue.body ?? ""; + const includeBody = options.includeBody ?? true; + const includePreview = options.includePreview ?? true; + const summary: Record<string, unknown> = { + id: issue.id, + 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, + updatedAt: issue.updated_at ?? null, + commentCount: issue.comments ?? null, + bodyChars: body.length, + bodySha: bodySha(body), + }; + if (includeBody) { + summary.body = body; + } else { + summary.bodyOmitted = true; + summary.fullBodyIncluded = false; + if (includePreview) { + summary.bodyPreview = preview(body); + summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8); + } + } + return summary; +} + +export async function repoView(repo: string, token: string): Promise<GitHubCommandResult> { + const { owner, name } = repoParts(repo); + const result = await githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`); + if (isGitHubError(result)) return commandError("repo view", repo, result); + return { + ok: true, + command: "repo view", + repo, + repository: repoSummary(result), + request: { + method: "GET", + path: `/repos/${owner}/${name}`, + }, + rest: true, + }; +} + +export async function repoCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { + const { owner, name } = repoParts(repo); + const visibility = options.repoVisibility ?? "private"; + const viewer = options.dryRun ? null : await githubRequest<GitHubAuthenticatedUser>(token, "GET", "/user"); + if (isGitHubError(viewer)) return commandError("repo create", repo, viewer, { phase: "viewer" }); + const viewerLogin = viewer?.login ?? null; + const createPath = viewerLogin !== null && viewerLogin.toLowerCase() === owner.toLowerCase() + ? "/user/repos" + : `/orgs/${owner}/repos`; + const payload: Record<string, unknown> = { + name, + private: visibility === "private", + auto_init: options.repoAutoInit, + }; + if (options.repoDescription !== undefined) payload.description = options.repoDescription; + const planned = { + repo, + owner, + name, + visibility, + description: options.repoDescription ?? null, + autoInit: options.repoAutoInit, + request: { + method: "POST", + path: createPath, + body: { + name, + private: visibility === "private", + descriptionChars: options.repoDescription?.length ?? 0, + auto_init: options.repoAutoInit, + }, + }, + }; + if (options.dryRun) { + return { + ok: true, + command: "repo create", + repo, + dryRun: true, + planned, + note: "Dry-run only; no GitHub repository was created.", + }; + } + const existing = await githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`); + if (!isGitHubError(existing)) { + return validationError("repo create", repo, "repository already exists; refusing duplicate create", { + repository: repoSummary(existing), + planned, + next: { + command: `bun scripts/cli.ts gh repo view ${repo}`, + }, + }); + } + if (existing.degradedReason !== "repo-not-found") { + return commandError("repo create", repo, existing, { phase: "preflight-existing-repo", planned }); + } + const created = await githubRequest<GitHubRepository>(token, "POST", createPath, payload); + if (isGitHubError(created)) return commandError("repo create", repo, created, { planned }); + return { + ok: true, + command: "repo create", + repo, + repository: repoSummary(created), + planned, + rest: true, + }; +} + +export function issueLifecycleSummary(issue: GitHubIssue): Record<string, unknown> { + return { + id: issue.id, + 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, + updatedAt: issue.updated_at ?? null, + commentCount: issue.comments ?? null, + labels: issueLabelNames(issue), + bodyChars: (issue.body ?? "").length, + bodySha: bodySha(issue.body ?? ""), + bodyOmitted: true, + fullBodyIncluded: false, + }; +} + +export 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 view --json body when full text is needed.", + readCommands: issueBodyReadCommands(repo, issueNumber), + }; +} + +export 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), + }; +} + +export 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, + }; +} + +export function issueLabelNames(issue: GitHubIssue): string[] { + return (issue.labels ?? []).map((label) => typeof label === "string" ? label : label.name ?? "").filter((label) => label.length > 0); +} + +export function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Record<string, unknown> { + const summary: Record<IssueListJsonField, unknown> = { + 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, + 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; +} diff --git a/scripts/src/gh/issue-write.ts b/scripts/src/gh/issue-write.ts new file mode 100644 index 00000000..7e631017 --- /dev/null +++ b/scripts/src/gh/issue-write.ts @@ -0,0 +1,406 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:6758-7146. + +import path from "node:path"; +import { bodySha, preview, previewLines, repoParts } from "./auth-and-safety"; +import { assertConcurrencyOptions, issueEditGuardSummary, issueProfileNeedsMetadata, validateIssueBodyGuard, validateIssueConcurrency } from "./body-guards"; +import { applyVirtualBodyPatch, bodyPatchErrorSummary, readBodyPatch, readMarkdownBody, readMarkdownBodyFileOrStdin } from "./body-input"; +import { commanderBriefDiff, dryRunBody } from "./body-profiles"; +import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, issueWriteDisclosure, runnerDisposition, validationError, writeBodyPlan } from "./client"; +import { getIssue } from "./issue-read"; +import { issueLabelNames, issueSummary } from "./issue-summary"; +import { commanderBriefClaudeQqConfig, commanderBriefNotificationPlan, isRecord, maskedTarget, sendCommanderBriefClaudeQq } from "./notify-claudeqq"; +import { ghShort, ghTable, ghText } from "./render"; +import { bodyUpdatePlan } from "./repo-compare"; +import { COMMANDER_BRIEF_TARGET_ISSUE } from "./types"; +import type { ClaudeQqSendResult, CommanderBriefDiff, GitHubCommandResult, GitHubIssue, GitHubOptions, IssueProfileValidationContext } from "./types"; + +export async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { + if (options.title === undefined) return withIssueCreateRendered(validationError("issue create", repo, "issue create requires --title <title>")); + if (options.body !== undefined) { + return withIssueCreateRendered(validationError("issue create", repo, "issue create does not support --body; use --body-stdin with a quoted heredoc for Markdown", { + title: options.title, + bodySource: "inline", + })); + } + const { body, bodySource } = readMarkdownBody({ + ...options, + body: undefined, + }, "issue create"); + const labels = options.labels; + if (options.dryRun) { + return withIssueCreateRendered({ + ok: true, + command: "issue create", + repo, + dryRun: true, + planned: true, + title: options.title, + labels, + ...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title, labels }), + }); + } + const { owner, name } = repoParts(repo); + const payload: Record<string, unknown> = { title: options.title, body }; + if (labels.length > 0) payload.labels = labels; + const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, payload); + if (isGitHubError(issue)) return withIssueCreateRendered(commandError("issue create", repo, issue, { title: options.title, labels })); + const appliedLabels = issueLabelNames(issue); + const missingLabels = labels.filter((label) => !appliedLabels.includes(label)); + if (missingLabels.length > 0) { + return withIssueCreateRendered(validationError("issue create", repo, "GitHub created the issue but did not return all requested labels; refusing to report silent label success", { + issue: issueSummary(issue), + requestedLabels: labels, + appliedLabels, + missingLabels, + })); + } + return withIssueCreateRendered({ ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true }); +} + +export function withIssueCreateRendered(result: GitHubCommandResult): GitHubCommandResult { + return { + ...result, + contentType: "text/plain", + renderedText: renderIssueCreateTable(result), + }; +} + +export function renderIssueCreateTable(result: GitHubCommandResult): string { + const issue = isRecord(result.issue) ? result.issue : {}; + const number = ghText(issue.number ?? result.number); + const title = ghText(issue.title ?? result.title); + const ok = result.ok !== false; + const details = isRecord(result.details) ? result.details : {}; + const status = ok ? (result.dryRun === true ? "dry-run" : "created") : "failed"; + const reason = ok ? "-" : ghText(result.degradedReason ?? details.degradedReason ?? "validation-failed"); + const message = ghText(result.message ?? details.message); + const labels = Array.isArray(result.labels) ? result.labels.map(String).join(",") : ghText(result.labels); + const lines = [ + `gh issue create (${status})`, + "", + ghTable(["ISSUE", "STATUS", "REASON", "LABELS", "TITLE"], [[ + number === "-" ? "-" : `#${number}`, + status, + ghShort(reason, 32), + ghShort(labels, 40), + ghShort(title, 96), + ]]), + "", + "Summary:", + ` repo=${ghText(result.repo)} url=${ghText(issue.url ?? result.url)}`, + "", + "Next:", + ]; + if (!ok) { + const nextIndex = lines.indexOf("Next:"); + if (nextIndex >= 0) lines.splice(nextIndex, 0, ` message=${ghShort(message, 160)}`, ""); + lines.push(" use --body-stdin with a quoted heredoc for Markdown issue bodies"); + } else if (status === "created" && number !== "-") { + lines.push(` bun scripts/cli.ts gh issue view ${number} --repo ${ghText(result.repo)}`); + } else { + lines.push(" rerun without --dry-run to create the issue"); + } + lines.push("", "Disclosure:"); + lines.push(" default view is a bounded table; use gh issue view --full for structured metadata."); + return lines.join("\n"); +} + +export async function issuePatch(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const commandName = "issue patch"; + const concurrencyOptionError = assertConcurrencyOptions(options, commandName); + if (concurrencyOptionError !== null) return concurrencyOptionError; + let patchInput: { patch: string; patchSource: Record<string, unknown> }; + try { + patchInput = readBodyPatch(options, commandName); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber }); + } + const { patch, patchSource } = patchInput; + const oldIssue = await getIssue(token, repo, issueNumber); + if (isGitHubError(oldIssue)) return commandError(commandName, repo, oldIssue, { issueNumber, phase: "read-before-patch" }); + const concurrencyError = validateIssueConcurrency(repo, issueNumber, oldIssue, options, commandName); + if (concurrencyError !== null) return concurrencyError; + const oldBody = oldIssue.body ?? ""; + let patched: { newBody: string; patchSummary: Record<string, unknown> }; + try { + patched = applyVirtualBodyPatch("issue.md", oldBody, patch); + } catch (error) { + return validationError(commandName, repo, "body patch failed; no GitHub write performed", { + issueNumber, + patchSource, + patchError: bodyPatchErrorSummary(error), + oldBody: { + bodyChars: oldBody.length, + bodySha: bodySha(oldBody), + updatedAt: oldIssue.updated_at ?? null, + }, + noWrite: true, + }); + } + const profileContext: IssueProfileValidationContext = { issueTitle: oldIssue.title, issueBody: oldIssue.body, issueMetadataFetched: true }; + const guard = validateIssueBodyGuard(repo, issueNumber, patched.newBody, options, profileContext, commandName, patchSource); + if (guard !== null) return guard; + const guardSummary = issueEditGuardSummary(issueNumber, patched.newBody, options, profileContext); + const bodyChange = { + oldBodyChars: oldBody.length, + newBodyChars: patched.newBody.length, + deltaChars: patched.newBody.length - oldBody.length, + oldBodySha: bodySha(oldBody), + newBodySha: bodySha(patched.newBody), + oldUpdatedAt: oldIssue.updated_at ?? null, + }; + const concurrency = { + checked: true, + oldIssueUpdatedAt: oldIssue.updated_at ?? null, + oldBodySha: bodySha(oldBody), + expectUpdatedAt: options.expectUpdatedAt ?? null, + expectBodySha: options.expectBodySha ?? null, + }; + if (options.dryRun) { + return { + ok: true, + command: commandName, + repo, + dryRun: true, + planned: true, + issueNumber, + patchSource, + patch: patched.patchSummary, + ...dryRunBody(repo, undefined, patched.newBody), + disclosure: issueWriteDisclosure(options, repo, issueNumber, true), + readCommands: issueBodyReadCommands(repo, issueNumber), + guard: guardSummary, + concurrency: { ...concurrency, dryRunNoWrite: true }, + bodyChange, + wouldPatch: { + issueNumber, + bodyChars: patched.newBody.length, + bodySha: bodySha(patched.newBody), + method: "PATCH", + path: `/repos/{owner}/{repo}/issues/${issueNumber}`, + }, + }; + } + const { owner, name } = repoParts(repo); + const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { body: patched.newBody }); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); + return { + ok: true, + command: commandName, + repo, + issue: issueSummary(issue, { includeBody: options.raw || options.full }), + patchSource, + patch: patched.patchSummary, + bodyChange: { + ...bodyChange, + newUpdatedAt: issue.updated_at ?? null, + }, + guard: guardSummary, + concurrency, + disclosure: issueWriteDisclosure(options, repo, issueNumber, false), + readCommands: issueBodyReadCommands(repo, issueNumber), + request: { + method: "PATCH", + path: `/repos/${owner}/${name}/issues/${issueNumber}`, + bodyChars: patched.newBody.length, + }, + rest: true, + }; +} + +export async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions, commandName = "issue edit"): Promise<GitHubCommandResult> { + if (options.bodyFile === undefined) { + return validationError(commandName, repo, `${commandName} requires --body-stdin or --body-file <file|->`, { issueNumber }); + } + let bodyInput: { body: string; bodySource: Record<string, unknown> }; + try { + bodyInput = readMarkdownBodyFileOrStdin(options.bodyFile); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { issueNumber }); + } + const { body, bodySource } = bodyInput; + const needsProfileMetadata = issueProfileNeedsMetadata(issueNumber, options.bodyProfile); + if (options.mode === "replace" && !needsProfileMetadata) { + const bodyGuard = validateIssueBodyGuard(repo, issueNumber, body, options); + if (bodyGuard !== null) return bodyGuard; + } + const concurrencyOptionError = assertConcurrencyOptions(options, commandName); + if (concurrencyOptionError !== null) return concurrencyOptionError; + let oldIssue: GitHubIssue | null = null; + let briefDiff: CommanderBriefDiff | null = null; + let profileContext: IssueProfileValidationContext = {}; + const claudeQqConfig = commanderBriefClaudeQqConfig(); + if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) { + return validationError(commandName, repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber }); + } + const needsReadBeforeEdit = options.mode === "append" + || needsProfileMetadata && token.length > 0 + || !options.dryRun; + if (needsReadBeforeEdit) { + const issue = await getIssue(token, repo, issueNumber); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber, phase: "read-before-update" }); + oldIssue = issue; + profileContext = { issueTitle: issue.title, issueBody: issue.body, issueMetadataFetched: true }; + const concurrencyError = validateIssueConcurrency(repo, issueNumber, issue, options); + if (concurrencyError !== null) return concurrencyError; + if (options.notifyClaudeQqBriefDiff) briefDiff = commanderBriefDiff(issue.body ?? "", options.mode === "append" ? `${issue.body ?? ""}${body}` : body); + } + const finalBody = options.mode === "append" ? `${oldIssue?.body ?? ""}${body}` : body; + if (options.mode === "append" || needsProfileMetadata) { + const bodyGuard = validateIssueBodyGuard(repo, issueNumber, finalBody, options, profileContext); + if (bodyGuard !== null) return bodyGuard; + } + if (options.dryRun) { + const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", finalBody) : null; + const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext); + let dryRunOldBody: Record<string, unknown> = { + fetched: false, + bodyChars: null, + bodySha: null, + updatedAt: null, + reason: token.length > 0 ? "not-requested" : "no token supplied to dry-run", + }; + if (oldIssue !== null) { + const oldBody = oldIssue.body ?? ""; + dryRunOldBody = { + fetched: true, + bodyChars: oldBody.length, + bodySha: bodySha(oldBody), + updatedAt: oldIssue.updated_at ?? null, + }; + } else if (token.length > 0) { + const issue = await getIssue(token, repo, issueNumber); + if (isGitHubError(issue)) { + dryRunOldBody = { + fetched: false, + bodyChars: null, + bodySha: null, + updatedAt: null, + readError: { + degradedReason: issue.degradedReason, + runnerDisposition: issue.runnerDisposition, + message: issue.message, + status: issue.status ?? null, + }, + }; + } else { + const oldBody = issue.body ?? ""; + dryRunOldBody = { + fetched: true, + bodyChars: oldBody.length, + bodySha: bodySha(oldBody), + updatedAt: issue.updated_at ?? null, + }; + } + } + return { + ok: true, + command: commandName, + repo, + dryRun: true, + issueNumber, + mode: options.mode, + ...dryRunBody(repo, options.title, finalBody), + disclosure: issueWriteDisclosure(options, repo, issueNumber, true), + readCommands: issueBodyReadCommands(repo, issueNumber), + guard, + update: bodyUpdatePlan(commandName, repo, issueNumber, options.mode, body, bodySource, oldIssue?.body ?? null), + bodyOnlySafety: { + oldBody: dryRunOldBody, + newBody: { + bodyChars: finalBody.length, + bodySha: bodySha(finalBody), + bodyPreview: preview(finalBody), + bodyPreviewLines: previewLines(finalBody), + }, + }, + concurrency: { + dryRunNoWrite: true, + expectUpdatedAt: options.expectUpdatedAt ?? null, + expectBodySha: options.expectBodySha ?? null, + note: "non-dry-run checks --expect-updated-at and --expect-body-sha against the current GitHub issue before PATCH", + }, + wouldPatch: { + issueNumber, + title: options.title ?? null, + bodySource, + mode: options.mode, + bodyChars: finalBody.length, + bodySha: bodySha(finalBody), + }, + ...(options.notifyClaudeQqBriefDiff + ? { + commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, finalBody, dryRunDiff ?? commanderBriefDiff("", finalBody), claudeQqConfig), + dryRunOldBodySource: "not-fetched", + dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body", + dryRunNoWrite: true, + dryRunNoClaudeQqSend: true, + } + : {}), + }; + } + const { owner, name } = repoParts(repo); + const payload: Record<string, unknown> = { body: finalBody }; + if (options.title !== undefined) payload.title = options.title; + const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); + const guard = issueEditGuardSummary(issueNumber, finalBody, options, profileContext); + const concurrency = oldIssue === null + ? { checked: false, expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null } + : { checked: true, oldIssueUpdatedAt: oldIssue.updated_at ?? null, oldBodySha: bodySha(oldIssue.body ?? ""), expectUpdatedAt: options.expectUpdatedAt ?? null, expectBodySha: options.expectBodySha ?? null }; + if (!options.notifyClaudeQqBriefDiff) { + return { + ok: true, + command: commandName, + repo, + issue: issueSummary(issue, { includeBody: options.raw || options.full }), + mode: options.mode, + bodySource, + guard, + concurrency, + disclosure: issueWriteDisclosure(options, repo, issueNumber, false), + readCommands: issueBodyReadCommands(repo, issueNumber), + rest: true, + }; + } + + const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", finalBody); + const claudeqq = diff.ok + ? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message) + : { + ok: true, + attempted: false, + skipped: true, + skippedReason: diff.skippedReason ?? "no commander brief diff to send", + target: maskedTarget(claudeQqConfig), + } satisfies ClaudeQqSendResult; + return { + ok: true, + command: commandName, + repo, + issue: issueSummary(issue, { includeBody: options.raw || options.full }), + guard, + concurrency, + disclosure: issueWriteDisclosure(options, repo, issueNumber, false), + readCommands: issueBodyReadCommands(repo, issueNumber), + rest: true, + commanderBriefNotification: { + issueNumber, + oldIssueUpdatedAt: oldIssue?.updated_at ?? null, + diff: { + ok: diff.ok, + mode: diff.mode, + chars: diff.chars, + sectionCount: diff.sectionCount, + skippedReason: diff.skippedReason ?? null, + preview: diff.ok ? preview(diff.message) : "", + previewLines: diff.ok ? previewLines(diff.message) : [], + containsLiteralBackslashN: diff.message.includes("\\n"), + containsBackticks: diff.message.includes("`"), + containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message), + }, + }, + claudeqq, + }; +} diff --git a/scripts/src/gh/notify-claudeqq.ts b/scripts/src/gh/notify-claudeqq.ts new file mode 100644 index 00000000..f75a3630 --- /dev/null +++ b/scripts/src/gh/notify-claudeqq.ts @@ -0,0 +1,204 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:1891-2085. + +import path from "node:path"; +import { coreInternalFetch } from "../microservices"; +import { preview, previewLines } from "./auth-and-safety"; +import { DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL, DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID } from "./types"; +import type { ClaudeQqConfig, ClaudeQqEndpointResult, ClaudeQqSendResult, ClaudeQqTargetType, CommanderBriefDiff } from "./types"; + +export function envEnabled(name: string, defaultValue: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined || raw.length === 0) return defaultValue; + return !["0", "false", "no", "off", "disabled"].includes(raw.toLowerCase()); +} + +export function positiveEnvInteger(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.length === 0) return defaultValue; + const value = Number(raw); + return Number.isInteger(value) && value > 0 ? value : defaultValue; +} + +export function commanderBriefClaudeQqConfig(): ClaudeQqConfig { + const targetTypeRaw = (process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE ?? "private").toLowerCase(); + const targetType: ClaudeQqTargetType = targetTypeRaw === "group" ? "group" : "private"; + return { + enabled: envEnabled("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED", true), + baseUrl: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL, + targetType, + userId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, + groupId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID, + timeoutMs: positiveEnvInteger("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS", 15_000), + }; +} + +export function maskedTarget(config: ClaudeQqConfig): Record<string, unknown> { + if (config.targetType === "group") return { targetType: "group", groupId: config.groupId === undefined ? null : maskId(config.groupId) }; + return { targetType: "private", userId: config.userId === undefined ? null : maskId(config.userId) }; +} + +export function maskId(value: string): string { + if (value.length <= 4) return "*".repeat(value.length); + return `${value.slice(0, 2)}***${value.slice(-2)}`; +} + +export function sanitizeUrlForOutput(value: string): string { + try { + const parsed = new URL(value); + parsed.username = parsed.username.length > 0 ? "***" : ""; + parsed.password = parsed.password.length > 0 ? "***" : ""; + parsed.search = parsed.search.length > 0 ? "?..." : ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return value.includes("?") ? `${value.split("?")[0]}?...` : value; + } +} + +export function claudeQqPayload(config: ClaudeQqConfig, message: string): Record<string, unknown> { + if (config.targetType === "group") return { targetType: "group", groupId: config.groupId ?? "", message }; + return { targetType: "private", userId: config.userId ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, message }; +} + +export function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function proxiedClaudeQqBasePath(baseUrl: string): string | null { + try { + const parsed = new URL(baseUrl); + const path = parsed.pathname.replace(/\/$/u, ""); + if (parsed.hostname === "backend-core" && path === "/api/microservices/claudeqq/proxy") return path; + if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.port === "8080" && path === "/api/microservices/claudeqq/proxy") return path; + return null; + } catch { + return baseUrl === "/api/microservices/claudeqq/proxy" ? baseUrl : null; + } +} + +export function normalizeClaudeQqEndpoint(basePath: string, endpoint: string): string { + return `${basePath.replace(/\/$/u, "")}${endpoint}`; +} + +export function claudeQqResponseOk(response: unknown): boolean { + if (!isRecord(response)) return false; + if (response.ok === false) return false; + if (typeof response.status === "number" && (response.status < 200 || response.status >= 300)) return false; + const body = response.body; + if (isRecord(body) && (body.ok === false || body.success === false)) return false; + return response.ok === true || typeof response.status === "number"; +} + +export function summarizeClaudeQqProxyFailure(response: unknown, endpoint: string): ClaudeQqEndpointResult { + if (!isRecord(response)) return { ok: false, endpoint, degradedReason: "invalid-response", message: "ClaudeQQ proxy returned a non-object response", response }; + const status = typeof response.status === "number" ? response.status : undefined; + const stdoutTail = typeof response.stdoutTail === "string" ? response.stdoutTail : ""; + let parsedTail: unknown = null; + if (stdoutTail.length > 0) { + try { + parsedTail = JSON.parse(stdoutTail.trim()) as unknown; + } catch { + parsedTail = null; + } + } + return { + ok: false, + endpoint, + status, + degradedReason: response.ok === false ? "microservice-proxy-failed" : "invalid-response", + message: typeof response.error === "string" + ? response.error + : typeof response.stderrTail === "string" && response.stderrTail.length > 0 + ? response.stderrTail.slice(-500) + : "ClaudeQQ proxy request failed", + response: parsedTail ?? response, + }; +} + +export async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, payload: Record<string, unknown>): Promise<ClaudeQqEndpointResult> { + const basePath = proxiedClaudeQqBasePath(config.baseUrl); + if (basePath !== null) { + const response = coreInternalFetch(normalizeClaudeQqEndpoint(basePath, endpoint), { + method: "POST", + body: payload, + maxResponseBytes: 240_000, + timeoutMs: config.timeoutMs, + }); + if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response }; + return summarizeClaudeQqProxyFailure(response, endpoint); + } + + return { + ok: false, + endpoint, + degradedReason: "notification-path-unavailable", + message: "ClaudeQQ notifications must use backend-core /api/microservices/claudeqq/proxy; local skill, powershell, direct host, and ad-hoc ClaudeQQ URLs are not supported.", + response: { + baseUrl: sanitizeUrlForOutput(config.baseUrl), + requiredBaseUrl: DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL, + recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw", + }, + }; +} + +export async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise<ClaudeQqSendResult> { + const target = maskedTarget(config); + if (!config.enabled) { + return { ok: true, attempted: false, skipped: true, skippedReason: "UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED disabled", target }; + } + if (config.targetType === "group" && (config.groupId === undefined || config.groupId.length === 0)) { + return { ok: false, attempted: false, skipped: true, skippedReason: "group target requires UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID", target }; + } + const payload = claudeQqPayload(config, message); + const first = await sendClaudeQqEndpoint(config, "/api/push/text", payload); + if (first.ok) return { ok: true, attempted: true, endpoint: first.endpoint, status: first.status, response: first.response, target }; + const second = await sendClaudeQqEndpoint(config, "/api/send/text", payload); + if (second.ok) return { ok: true, attempted: true, endpoint: second.endpoint, status: second.status, response: second.response, target }; + return { + ok: false, + attempted: true, + endpoint: second.endpoint, + status: second.status ?? first.status, + degradedReason: second.degradedReason ?? first.degradedReason ?? "claudeqq-send-failed", + message: second.message ?? first.message ?? "ClaudeQQ send failed", + response: { attempts: [first, second] }, + target, + }; +} + +export function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: CommanderBriefDiff, config: ClaudeQqConfig): Record<string, unknown> { + const proxyBasePath = proxiedClaudeQqBasePath(config.baseUrl); + return { + enabled: config.enabled, + issueNumber, + bodyChars: body.length, + diff: { + ok: diff.ok, + mode: diff.mode, + chars: diff.chars, + sectionCount: diff.sectionCount, + skippedReason: diff.skippedReason ?? null, + preview: diff.ok ? preview(diff.message) : "", + previewLines: diff.ok ? previewLines(diff.message) : [], + containsLiteralBackslashN: diff.message.includes("\\n"), + containsBackticks: diff.message.includes("`"), + containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message), + }, + claudeqq: { + dryRun: true, + wouldSend: config.enabled && diff.ok && proxyBasePath !== null, + baseUrl: sanitizeUrlForOutput(config.baseUrl), + proxyBasePath, + servicePath: proxyBasePath === null ? null : normalizeClaudeQqEndpoint(proxyBasePath, "/api/push/text"), + target: maskedTarget(config), + timeoutMs: config.timeoutMs, + ...(proxyBasePath === null + ? { + blockedReason: "notification-path-unavailable", + recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw", + } + : {}), + }, + }; +} diff --git a/scripts/src/gh/options.ts b/scripts/src/gh/options.ts new file mode 100644 index 00000000..7ec2e3a4 --- /dev/null +++ b/scripts/src/gh/options.ts @@ -0,0 +1,371 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:614-978. + +import { BOARD_GITHUB_STATUSES, BOARD_MUTATION_SECTIONS, BOARD_ROW_FIELDS, BODY_UPDATE_MODES, CODE_QUEUE_BOARD_TARGET_ISSUE, DEFAULT_REPO, DEFAULT_STALE_CLOSE_INACTIVE_HOURS, GH_FLAG_OPTIONS, GH_VALUE_OPTIONS, ISSUE_LIST_JSON_FIELDS, ISSUE_LIST_STATES, ISSUE_VIEW_JSON_FIELDS, MAX_ISSUE_LIST_LIMIT, MAX_PR_FILES_LIMIT, MAX_STALE_CLOSE_INACTIVE_HOURS, PR_CLOSEOUT_JSON_FIELDS, PR_CLOSEOUT_VIEW_JSON, PR_LIST_JSON_FIELDS, PR_READ_JSON_FIELDS } from "./types"; +import type { BoardGithubStatus, BoardMutationSection, BoardRowField, BoardRowUpsertValues, BodyUpdateMode, GitHubOptions, IssueBodyProfileOption, IssueListJsonField, IssueListState, IssueViewJsonField, PrListJsonField, PrListState, PrReadJsonField, PullRequestMergeMethod, RepoVisibility } from "./types"; + +export function optionValue(args: string[], name: string): string | undefined { + const index = args.indexOf(name); + if (index === -1) return undefined; + const value = args[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +export function optionValues(args: string[], name: string): string[] { + const values: string[] = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] !== name) continue; + const value = args[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + values.push(value); + index += 1; + } + return values; +} + +export function hasFlag(args: string[], name: string): boolean { + return args.includes(name); +} + +export function optionWasProvided(args: string[], name: string): boolean { + return args.includes(name); +} + +export function commaListOption(args: string[], name: string): string[] | undefined { + const raw = optionValue(args, name); + if (raw === undefined) return undefined; + const values = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); + if (values.length === 0) throw new Error(`${name} requires at least one comma-separated field`); + return values; +} + +export function labelsOption(args: string[]): string[] { + const rawValues = optionValues(args, "--label"); + const labels: string[] = []; + const seen = new Set<string>(); + for (const raw of rawValues) { + const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); + if (parts.length === 0) throw new Error("--label requires at least one non-empty label"); + for (const label of parts) { + if (seen.has(label)) continue; + labels.push(label); + seen.add(label); + } + } + return labels; +} + +export function positiveIntegerValuesOption(args: string[], name: string): number[] { + const values: number[] = []; + const seen = new Set<number>(); + for (const raw of optionValues(args, name)) { + const parts = raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0); + if (parts.length === 0) throw new Error(`${name} requires at least one positive integer`); + for (const part of parts) { + const value = Number(part.replace(/^#/u, "")); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} values must be positive issue numbers`); + if (seen.has(value)) continue; + values.push(value); + seen.add(value); + } + } + return values; +} + +export function positiveIntegerSingleOption(args: string[], name: string, defaultValue: number): number { + const raw = optionValue(args, name); + if (raw === undefined) return defaultValue; + const value = Number(raw.replace(/^#/u, "")); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive issue number`); + return value; +} + +export function positiveIntegerOption(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.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return Math.min(value, maxValue); +} + +export 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); +} + +export 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(",")}`); +} + +export 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>(allowedFields); + const unsupported = requested.filter((field) => !allowed.has(field)); + if (unsupported.length > 0) { + throw new Error(`unsupported ${command} --json field(s): ${unsupported.join(", ")}; supported fields: ${allowedFields.join(",")}`); + } + return requested as T[]; +} + +export function parseIssueViewJsonFields(requested: string[] | undefined): IssueViewJsonField[] | undefined { + return validateJsonFields("gh issue read/view", requested, ISSUE_VIEW_JSON_FIELDS); +} + +export function parseIssueListJsonFields(requested: string[] | undefined): IssueListJsonField[] | undefined { + return validateJsonFields("gh issue list", requested, ISSUE_LIST_JSON_FIELDS); +} + +export function parsePrListJsonFields(requested: string[] | undefined): PrListJsonField[] | undefined { + if (requested !== undefined) { + const listFields = new Set<string>(PR_LIST_JSON_FIELDS); + const closeoutFields = requested.filter((field) => (PR_CLOSEOUT_JSON_FIELDS as readonly string[]).includes(field)); + const unsupported = requested.filter((field) => !listFields.has(field)); + if (closeoutFields.length > 0) { + throw new Error(`unsupported gh pr list --json closeout field(s): ${closeoutFields.join(", ")}; use gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON} for mergeability/statusCheckRollup metadata; pr list supported fields: ${PR_LIST_JSON_FIELDS.join(",")}`); + } + if (unsupported.length > 0) { + throw new Error(`unsupported gh pr list --json field(s): ${unsupported.join(", ")}; supported fields: ${PR_LIST_JSON_FIELDS.join(",")}; closeout metadata requires gh pr view <number> --json ${PR_CLOSEOUT_VIEW_JSON}`); + } + } + return validateJsonFields("gh pr list", requested, PR_LIST_JSON_FIELDS); +} + +export function parsePrReadJsonFields(requested: string[] | undefined): PrReadJsonField[] | undefined { + return validateJsonFields("gh pr read/view", requested, PR_READ_JSON_FIELDS); +} + +export function isIssueReadCommand(sub: string | undefined): boolean { + return sub === "read" || sub === "view"; +} + +export function isPrReadCommand(sub: string | undefined): boolean { + return sub === "read" || sub === "view"; +} + +export function allowsNumberTargetAlias(top: string | undefined, sub: string | undefined, third: string | undefined): boolean { + if (top === "preflight") return true; + if (top === "issue") { + if (sub === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "patch" || sub === "close" || sub === "reopen" || sub === "delete") return true; + if (sub === "comment") return true; + if (sub === "attachment" && (third === "list" || third === "download")) return true; + if (sub === "board-row" && ["get", "update", "add", "move", "delete", "upsert"].includes(third ?? "")) return true; + return false; + } + if (top === "pr") { + if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true; + if (sub === "comment") return true; + } + return false; +} + +export 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(",")}`); +} + +export function parsePrListState(args: string[]): PrListState { + const raw = optionValue(args, "--state") ?? "all"; + if ((ISSUE_LIST_STATES as readonly string[]).includes(raw)) return raw as PrListState; + throw new Error(`unsupported gh pr list --state ${raw}; supported states: ${ISSUE_LIST_STATES.join(",")}`); +} + +export function parseBodyUpdateMode(args: string[]): BodyUpdateMode { + const raw = optionValue(args, "--mode") ?? "replace"; + if ((BODY_UPDATE_MODES as readonly string[]).includes(raw)) return raw as BodyUpdateMode; + throw new Error(`unsupported --mode ${raw}; supported modes: ${BODY_UPDATE_MODES.join(",")}`); +} + +export function parsePullRequestMergeMethod(args: string[]): PullRequestMergeMethod { + const selected = ["merge", "squash", "rebase"].filter((method) => hasFlag(args, `--${method}`)); + if (selected.length > 1) throw new Error("choose only one PR merge method flag: --merge, --squash, or --rebase"); + return (selected[0] ?? "merge") as PullRequestMergeMethod; +} + +export function parseIssueBodyProfile(args: string[]): IssueBodyProfileOption { + const raw = optionValue(args, "--body-profile") ?? "auto"; + if (raw === "auto" || raw === "code-queue-board" || raw === "commander-brief") return raw; + throw new Error(`unsupported --body-profile ${raw}; supported profiles: auto, code-queue-board, commander-brief`); +} + +export function parseBoardMutationSection(args: string[], name: "--section" | "--to"): BoardMutationSection | undefined { + const raw = optionValue(args, name); + if (raw === undefined) return undefined; + return validateEnumValue(name, raw, BOARD_MUTATION_SECTIONS); +} + +export function parseBoardGithubStatus(args: string[]): BoardGithubStatus | undefined { + const raw = optionValue(args, "--status"); + if (raw === undefined) return undefined; + return validateEnumValue("--status", raw, BOARD_GITHUB_STATUSES); +} + +export function parseBoardRowField(args: string[]): BoardRowField | undefined { + const raw = optionValue(args, "--field"); + if (raw === undefined) return undefined; + return validateEnumValue("--field", raw, BOARD_ROW_FIELDS); +} + +export function parseBoardRowUpsertValues(args: string[]): BoardRowUpsertValues { + return { + category: optionValue(args, "--category"), + branch: optionValue(args, "--branch"), + tasks: optionValue(args, "--tasks"), + summary: optionValue(args, "--summary"), + focus: optionValue(args, "--focus"), + validation: optionValue(args, "--validation"), + progress: optionValue(args, "--progress"), + status: parseBoardGithubStatus(args), + }; +} + +export function parseRepoVisibility(args: string[]): RepoVisibility | undefined { + const privateRequested = hasFlag(args, "--private"); + const publicRequested = hasFlag(args, "--public"); + if (privateRequested && publicRequested) throw new Error("gh repo create accepts either --private or --public, not both"); + if (privateRequested) return "private"; + if (publicRequested) return "public"; + return undefined; +} + +export function validateKnownOptions(args: string[]): void { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg.startsWith("--")) continue; + if (GH_FLAG_OPTIONS.has(arg)) continue; + if (GH_VALUE_OPTIONS.has(arg)) { + index += 1; + continue; + } + throw new Error(`unknown gh option: ${arg}`); + } +} + +export function positionalArgs(args: string[]): string[] { + const positionals: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (arg.startsWith("--")) { + if (GH_VALUE_OPTIONS.has(arg)) index += 1; + continue; + } + positionals.push(arg); + } + return positionals; +} + +export function isOwnerRepo(value: string): boolean { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value); +} + +export function resolveRepoOption(args: string[]): string { + const [top, sub] = args; + const explicitRepo = optionValue(args, "--repo"); + if (top === "repo" && (sub === "view" || sub === "create")) { + const positionals = positionalArgs(args.slice(2)); + if (positionals.length > 1) { + throw new Error(`gh repo ${sub} accepts at most one positional owner/repo target`); + } + const positionalRepo = positionals[0]; + if (positionalRepo !== undefined) { + if (!isOwnerRepo(positionalRepo)) { + throw new Error(`gh repo ${sub} positional argument must be owner/repo`); + } + if (explicitRepo !== undefined && explicitRepo !== positionalRepo) { + throw new Error(`gh repo ${sub} received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target`); + } + return positionalRepo; + } + } + if ((top === "issue" || top === "pr") && sub === "list") { + const positionals = positionalArgs(args.slice(2)); + if (positionals.length > 1) { + throw new Error(`gh ${top} list accepts at most one positional owner/repo; use --repo owner/name`); + } + const positionalRepo = positionals[0]; + if (positionalRepo !== undefined) { + if (!isOwnerRepo(positionalRepo)) { + throw new Error(`gh ${top} list positional argument must be owner/repo; use --repo owner/name`); + } + if (explicitRepo !== undefined && explicitRepo !== positionalRepo) { + throw new Error(`gh ${top} list received positional repo ${positionalRepo} and --repo ${explicitRepo}; use one repo target, for example --repo ${positionalRepo}`); + } + return positionalRepo; + } + } + return explicitRepo ?? DEFAULT_REPO; +} + +export function stdinAliasFileOption(args: string[], fileOption: string, stdinFlag: string): string | undefined { + const file = optionValue(args, fileOption); + const stdin = hasFlag(args, stdinFlag); + if (file !== undefined && stdin) { + throw new Error(`${fileOption} and ${stdinFlag} are mutually exclusive; use one body source`); + } + return stdin ? "-" : file; +} + +export 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 + : 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 : 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"), + draft: hasFlag(args, "--draft"), + notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"), + allowShortBody: hasFlag(args, "--allow-short-body"), + labels: labelsOption(args), + search: optionValue(args, "--search"), + titlePrefix: optionValue(args, "--title-prefix")?.trim(), + title: optionValue(args, "--title"), + body: optionValue(args, "--body"), + bodyFile: stdinAliasFileOption(args, "--body-file", "--body-stdin"), + bodyPatchFile: stdinAliasFileOption(args, "--body-patch-file", "--body-patch-stdin"), + comment: optionValue(args, "--comment"), + commentFile: stdinAliasFileOption(args, "--comment-file", "--comment-stdin"), + repoDescription: optionValue(args, "--description"), + repoVisibility: parseRepoVisibility(args), + repoAutoInit: hasFlag(args, "--auto-init"), + base: optionValue(args, "--base"), + head: optionValue(args, "--head"), + jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined, + issueListJsonFields: top === "issue" && sub === "list" ? parseIssueListJsonFields(requestedJsonFields) : undefined, + prListJsonFields: top === "pr" && sub === "list" ? parsePrListJsonFields(requestedJsonFields) : undefined, + prJsonFields: top === "pr" && isPrReadCommand(sub) ? parsePrReadJsonFields(requestedJsonFields) : undefined, + listState: top === "issue" && (sub === "list" || sub === "board-row" && args[2] === "list") ? parseIssueListState(args) : "open", + prListState: top === "pr" && sub === "list" ? parsePrListState(args) : "all", + mode: parseBodyUpdateMode(args), + expectUpdatedAt: optionValue(args, "--expect-updated-at"), + expectBodySha: optionValue(args, "--expect-body-sha"), + bodyProfile: parseIssueBodyProfile(args), + boardRowField: parseBoardRowField(args), + boardRowValue: optionValue(args, "--value"), + boardRowFile: optionValue(args, "--row-file"), + boardSection: parseBoardMutationSection(args, "--section"), + boardMoveTo: parseBoardMutationSection(args, "--to"), + boardGithubStatus: parseBoardGithubStatus(args), + boardRowUpsertValues: parseBoardRowUpsertValues(args), + mergeMethod: parsePullRequestMergeMethod(args), + deleteBranch: hasFlag(args, "--delete-branch"), + attachmentSelector: optionValue(args, "--attachment"), + outputPath: optionValue(args, "--output"), + }; +} diff --git a/scripts/src/gh/pr-commands.ts b/scripts/src/gh/pr-commands.ts new file mode 100644 index 00000000..093b780c --- /dev/null +++ b/scripts/src/gh/pr-commands.ts @@ -0,0 +1,279 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:5682-5947. + +import path from "node:path"; +import { bodySafetySignals, bodySha, preview, repoParts } from "./auth-and-safety"; +import { readMarkdownBody, readOptionalMarkdownBody } from "./body-input"; +import { commandError, githubRequest, isGitHubError, validationError } from "./client"; +import { isRecord } from "./notify-claudeqq"; +import { prSummary } from "./pr-summary"; +import { commentSummary, ghShort, ghTable, ghText } from "./render"; +import { branchInfo, branchSummary, compareBranches, compareSummary, prCommentPlannedOperation, prCreatePlannedOperation, prUrl, repoInfo, repoSummary } from "./repo-compare"; +import type { BodyUpdateMode, GitHubCommandResult, GitHubComment, GitHubOptions, GitHubPullRequest } from "./types"; + +export function prEditBodyPlan(mode: BodyUpdateMode, input: { body: string; bodySource: Record<string, unknown> } | null, finalBody: string | undefined, existingBody: string | null): Record<string, unknown> | null { + if (input === null || finalBody === undefined) return null; + return { + mode, + bodySource: input.bodySource, + input: bodySafetySignals(input.body), + existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) }, + finalBody: bodySafetySignals(finalBody), + }; +} + +export async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> { + if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>"); + if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>"); + if (options.head === undefined) return validationError("pr create", repo, "pr create requires --head <branch>"); + let bodySource: { body: string; bodySource: Record<string, unknown> }; + try { + bodySource = readMarkdownBody(options, "pr create"); + } catch (error) { + return validationError("pr create", repo, error instanceof Error ? error.message : String(error)); + } + const body = bodySource.body; + const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource); + if (options.dryRun) { + return withPrCreateRendered({ + ok: true, + command: "pr create", + repo, + dryRun: true, + planned: true, + draft: options.draft, + ...planned, + }); + } + + const repoResult = await repoInfo(token, repo); + if (isGitHubError(repoResult)) return commandError("pr create", repo, repoResult, { planned }); + const baseBranch = await branchInfo(token, repo, options.base); + if (isGitHubError(baseBranch)) return commandError("pr create", repo, baseBranch, { base: options.base, planned }); + const headBranch = await branchInfo(token, repo, options.head); + if (isGitHubError(headBranch)) return commandError("pr create", repo, headBranch, { head: options.head, planned }); + const compare = await compareBranches(token, repo, options.base, options.head); + if (isGitHubError(compare)) return commandError("pr create", repo, compare, { base: options.base, head: options.head, planned }); + const aheadBy = typeof compare.ahead_by === "number" ? compare.ahead_by : null; + if (aheadBy === null || aheadBy <= 0) { + return validationError("pr create", repo, "head branch must be ahead of base branch before creating a PR", { + base: options.base, + head: options.head, + compare: compareSummary(compare), + }); + } + + const { owner, name } = repoParts(repo); + const payload: Record<string, unknown> = { + title: options.title, + base: options.base, + head: options.head, + draft: options.draft, + body, + }; + const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload); + if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned }); + return withPrCreateRendered({ + ok: true, + command: "pr create", + repo, + pr: prSummary(pr), + validation: { + repo: repoSummary(repoResult), + base: branchSummary(baseBranch), + head: branchSummary(headBranch), + compare: compareSummary(compare), + bodySource: bodySource.bodySource, + }, + request: { + method: "POST", + path: `/repos/${owner}/${name}/pulls`, + title: options.title, + base: options.base, + head: options.head, + draft: options.draft, + bodyChars: body.length, + }, + rest: true, + }); +} + +export function withPrCreateRendered(result: GitHubCommandResult): GitHubCommandResult { + return { + ...result, + contentType: "text/plain", + renderedText: renderPrCreateTable(result), + }; +} + +export function renderPrCreateTable(result: GitHubCommandResult): string { + const pr = isRecord(result.pr) ? result.pr : {}; + const request = isRecord(result.request) ? result.request : {}; + const number = ghText(pr.number ?? result.number ?? result.pr); + const title = ghText(pr.title ?? request.title ?? result.title); + const base = ghText(isRecord(pr.base) ? pr.base.ref : request.base ?? result.base); + const head = ghText(isRecord(pr.head) ? pr.head.ref : request.head ?? result.head); + const status = result.dryRun === true ? "dry-run" : "created"; + const rows = [[ + number === "-" ? "-" : `#${number}`, + status, + base, + head, + ghText(request.draft ?? result.draft), + ghShort(title, 80), + ]]; + const lines = [ + `gh pr create (${status})`, + "", + ghTable(["PR", "STATUS", "BASE", "HEAD", "DRAFT", "TITLE"], rows), + "", + "Summary:", + ` repo=${ghText(result.repo)} url=${ghText(pr.url ?? pr.html_url ?? result.url)}`, + "", + "Next:", + ]; + if (status === "created" && number !== "-") { + lines.push(` bun scripts/cli.ts gh pr merge ${number} --repo ${ghText(result.repo)} --squash --delete-branch`); + lines.push(` bun scripts/cli.ts gh pr preflight ${number} --repo ${ghText(result.repo)} --full`); + } else { + lines.push(" rerun without --dry-run to create the PR"); + } + lines.push("", "Disclosure:"); + lines.push(" default view is a bounded table; use gh pr view/preflight --full for structured metadata."); + return lines.join("\n"); +} + +export async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { + let bodySource: { body: string; bodySource: Record<string, unknown> }; + try { + bodySource = readMarkdownBody(options, "pr comment"); + } catch (error) { + return validationError("pr comment", repo, error instanceof Error ? error.message : String(error), { issueNumber }); + } + const body = bodySource.body; + const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource); + if (options.dryRun) { + return { + ok: true, + command: "pr comment", + repo, + dryRun: true, + planned: true, + issueNumber, + ...planned, + }; + } + + const repoResult = await repoInfo(token, repo); + if (isGitHubError(repoResult)) return commandError("pr comment", repo, repoResult, { issueNumber, planned }); + const { owner, name } = repoParts(repo); + const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`); + if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned }); + const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body }); + if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned }); + return { + ok: true, + command: "pr comment create", + repo, + issueNumber, + pr: prSummary(prResult), + comment: commentSummary(comment), + bodySource: bodySource.bodySource, + source: String(bodySource.bodySource.kind ?? "unknown"), + bodyChars: body.length, + bodySha: bodySha(body), + bodyPreview: preview(body), + request: { + method: "POST", + path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`, + bodyChars: body.length, + }, + validation: { + repo: repoSummary(repoResult), + pr: prSummary(prResult), + bodySource: bodySource.bodySource, + }, + rest: true, + }; +} + +export async function prUpdate(repo: string, token: string, number: number, options: GitHubOptions, commandName = "pr update"): Promise<GitHubCommandResult> { + let bodySource: { body: string; bodySource: Record<string, unknown> } | null; + try { + bodySource = readOptionalMarkdownBody(options, commandName); + } catch (error) { + return validationError(commandName, repo, error instanceof Error ? error.message : String(error), { number }); + } + if (bodySource === null && options.title === undefined) { + return validationError(commandName, repo, `${commandName} requires --title <title>, --body-stdin, --body-file <file|->, or --body <text>`, { number }); + } + if (options.mode === "append" && bodySource === null) { + return validationError(commandName, repo, `${commandName} --mode append requires a body source`, { number }); + } + const { owner, name } = repoParts(repo); + let oldPr: GitHubPullRequest | null = null; + if (token.length > 0 && options.mode === "append") { + const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, phase: "read-before-update" }); + oldPr = pr; + } + const finalBody = bodySource === null ? undefined : (options.mode === "append" ? `${oldPr?.body ?? ""}${bodySource.body}` : bodySource.body); + const changedFields = [ + ...(options.title === undefined ? [] : ["title"]), + ...(bodySource === null ? [] : ["body"]), + ]; + const bodyPlan = prEditBodyPlan(options.mode, bodySource, finalBody, oldPr?.body ?? null); + const request = { + method: "PATCH", + path: `/repos/{owner}/{repo}/pulls/${number}`, + changedFields, + body: { + ...(options.title === undefined ? {} : { title: "provided" }), + ...(finalBody === undefined ? {} : { bodyChars: finalBody.length, bodySha: bodySha(finalBody) }), + }, + }; + if (options.dryRun) { + return { + ok: true, + command: commandName, + repo, + dryRun: true, + planned: true, + number, + url: prUrl(repo, number), + changedFields, + ...(bodyPlan === null ? {} : { body: bodyPlan }), + request, + graphQl: false, + projectsClassic: false, + }; + } + const payload: Record<string, unknown> = {}; + if (finalBody !== undefined) payload.body = finalBody; + if (options.title !== undefined) payload.title = options.title; + const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, payload); + if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, planned: { changedFields, body: bodyPlan, request } }); + return { + ok: true, + command: commandName, + repo, + pr: pr.number ?? number, + number: pr.number ?? number, + changedFields, + url: pr.html_url ?? prUrl(repo, number), + ...(bodyPlan === null ? {} : { body: bodyPlan }), + request: { ...request, path: `/repos/${owner}/${name}/pulls/${number}` }, + rest: true, + graphQl: false, + projectsClassic: false, + }; +} + +export async function prState(repo: string, token: string, number: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> { + const command = state === "closed" ? "pr close" : "pr reopen"; + if (dryRun) return { ok: true, command, repo, dryRun: true, number, wouldPatch: { state } }; + const { owner, name } = repoParts(repo); + const pr = await githubRequest<GitHubPullRequest>(token, "PATCH", `/repos/${owner}/${name}/pulls/${number}`, { state }); + if (isGitHubError(pr)) return commandError(command, repo, pr, { number }); + return { ok: true, command, repo, number, pullRequest: prSummary(pr), rest: true }; +} diff --git a/scripts/src/gh/pr-merge.ts b/scripts/src/gh/pr-merge.ts new file mode 100644 index 00000000..25423bef --- /dev/null +++ b/scripts/src/gh/pr-merge.ts @@ -0,0 +1,308 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:5233-5528. + +import { repoParts, resolveToken } from "./auth-and-safety"; +import { authStatus } from "./auth-pr-read"; +import { authRequired, commandError, errorPayload, githubRequest, isGitHubError, runnerDisposition, validationError } from "./client"; +import { isRecord } from "./notify-claudeqq"; +import { compactAuthCapability, deleteHeadBranchAfterMerge, loadPrMergeUnknownRetryConfig, mergeabilityHasUnknownPending, nextPrMergeRetryDelayMs, prCloseoutMetadata, prCloseoutSummary, preflightPullRequestSummary, prGraphqlMetadata, prMergeRetryCommand, prMetadataSummary, prPreflightPolicy, prSummary, sleepMs, statusRollupSummary } from "./pr-summary"; +import { ghShort, ghTable, ghText } from "./render"; +import { UNIDESK_CLI_CONFIG_PATH } from "./types"; +import type { GitHubCommandResult, GitHubErrorPayload, GitHubOptions, GitHubPullRequest, GitHubPullRequestGraphqlMetadata, PrMergeUnknownRetryConfig } from "./types"; + +export async function prMerge(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> { + const { owner, name } = repoParts(repo); + let retryConfig: PrMergeUnknownRetryConfig; + try { + retryConfig = loadPrMergeUnknownRetryConfig(); + } catch (error) { + return validationError("pr merge", repo, error instanceof Error ? error.message : String(error), { number, configPath: UNIDESK_CLI_CONFIG_PATH }); + } + const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" }); + const summary = prSummary(pr); + if (summary.merged === true) { + return withPrMergeRendered({ + ok: true, + command: "pr merge", + repo, + number, + method: options.mergeMethod, + alreadyMerged: true, + pullRequest: summary, + branchDeletion: { attempted: false, skippedReason: "already-merged" }, + rest: true, + }); + } + let metadata: GitHubPullRequestGraphqlMetadata | GitHubErrorPayload | null = null; + let statusChecks: Record<string, unknown> = {}; + let mergeability: Record<string, unknown> = {}; + let delayMs = retryConfig.initialDelayMs; + const retryAttempts: Array<Record<string, unknown>> = []; + for (let attempt = 1; attempt <= retryConfig.maxAttempts; attempt += 1) { + metadata = await prGraphqlMetadata(repo, token, number); + if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, retry: { attempt, maxAttempts: retryConfig.maxAttempts } }); + statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, false); + mergeability = prCloseoutSummary(summary, prMetadataSummary(metadata), statusChecks); + const shouldRetryUnknown = mergeability.readyForCommanderMerge !== true && mergeabilityHasUnknownPending(mergeability); + const waitMs = shouldRetryUnknown && attempt < retryConfig.maxAttempts ? delayMs : 0; + retryAttempts.push({ + attempt, + maxAttempts: retryConfig.maxAttempts, + mergeable: metadata.mergeable ?? null, + mergeStateStatus: metadata.mergeStateStatus ?? null, + statusCheckRollup: metadata.statusCheckRollup?.state ?? null, + conclusion: mergeability.conclusion ?? null, + blockers: Array.isArray(mergeability.blockers) ? mergeability.blockers : [], + pending: Array.isArray(mergeability.pending) ? mergeability.pending : [], + waitMs, + }); + if (mergeability.readyForCommanderMerge === true || !shouldRetryUnknown || attempt >= retryConfig.maxAttempts) break; + await sleepMs(delayMs); + delayMs = nextPrMergeRetryDelayMs(retryConfig, delayMs); + } + if (metadata === null || isGitHubError(metadata)) return commandError("pr merge", repo, metadata ?? errorPayload("invalid-response", "PR merge metadata was not collected"), { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary }); + const retry = { + reason: "github-mergeability-unknown", + maxAttempts: retryConfig.maxAttempts, + attempts: retryAttempts.length, + exhausted: mergeability.readyForCommanderMerge !== true && mergeabilityHasUnknownPending(mergeability) && retryAttempts.length >= retryConfig.maxAttempts, + config: retryConfig, + attemptsLog: retryAttempts, + }; + if (mergeability.readyForCommanderMerge !== true) { + const details = { + number, + method: options.mergeMethod, + deleteBranch: options.deleteBranch, + pullRequest: preflightPullRequestSummary(summary), + mergeability, + statusChecks, + closeoutMetadata: prCloseoutMetadata(metadata), + retry, + }; + return withPrMergeRendered(validationError("pr merge", repo, retry.exhausted ? "PR mergeability is still UNKNOWN after automatic retry; no merge performed" : "PR is not ready for merge; blockers or pending states remain", details), details); + } + if (options.dryRun) { + return withPrMergeRendered({ + ok: true, + command: "pr merge", + repo, + number, + dryRun: true, + wouldMerge: true, + method: options.mergeMethod, + deleteBranch: options.deleteBranch, + pullRequest: preflightPullRequestSummary(summary), + mergeability, + statusChecks, + retry, + }); + } + const merged = await githubRequest<Record<string, unknown>>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, { + merge_method: options.mergeMethod, + }); + if (isGitHubError(merged)) return commandError("pr merge", repo, merged, { number, phase: "merge", method: options.mergeMethod, pullRequest: summary }); + const after = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged }); + const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "delete-branch-not-requested" }; + return withPrMergeRendered({ + ok: true, + command: "pr merge", + repo, + number, + method: options.mergeMethod, + mergeResult: merged, + pullRequest: prSummary(after), + mergeability, + statusChecks, + retry, + branchDeletion, + rest: true, + }); +} + +export function withPrMergeRendered(result: GitHubCommandResult, fallbackDetails?: Record<string, unknown>): GitHubCommandResult { + return { + ...result, + contentType: "text/plain", + renderedText: renderPrMergeTable(result, fallbackDetails), + }; +} + +export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails?: Record<string, unknown>): string { + const details = fallbackDetails ?? {}; + const pullRequest = isRecord(result.pullRequest) + ? result.pullRequest + : isRecord(details.pullRequest) + ? details.pullRequest + : {}; + const mergeability = isRecord(result.mergeability) + ? result.mergeability + : isRecord(details.mergeability) + ? details.mergeability + : {}; + const statusChecks = isRecord(result.statusChecks) + ? result.statusChecks + : isRecord(details.statusChecks) + ? details.statusChecks + : {}; + const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; + const branchDeletion = isRecord(result.branchDeletion) ? result.branchDeletion : {}; + const retry = isRecord(result.retry) + ? result.retry + : isRecord(details.retry) + ? details.retry + : {}; + const mergeMethod = result.method ?? result.mergeMethod ?? details.method; + const deleteBranch = result.deleteBranch ?? details.deleteBranch; + const status = result.ok === true + ? result.alreadyMerged === true + ? "already-merged" + : result.dryRun === true + ? "dry-run" + : "merged" + : ghText(mergeability.conclusion) === "pending" + ? "pending" + : "blocked"; + const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; + const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map(String) : []; + const rows = [[ + `#${ghText(result.number ?? details.number)}`, + status, + ghText(mergeMethod), + ghText(mergeability.mergeable), + ghText(mergeability.mergeStateStatus), + retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)}${retry.exhausted === true ? " exhausted" : ""}` : "-", + `${ghText(statusChecks.totalContexts)} total ${ghText(counts.success)} ok ${ghText(counts.failure)} fail ${ghText(counts.pending)} pending`, + ]]; + const lines = [ + `gh pr merge (${status})`, + "", + ghTable(["PR", "STATUS", "METHOD", "MERGEABLE", "MERGE_STATE", "RETRY", "CHECKS"], rows), + "", + "Summary:", + ` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`, + ` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`, + ` retry=${retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)} exhausted=${ghText(retry.exhausted)}` : "-"}`, + ` branchDeletion=${ghText(branchDeletion.ok ?? branchDeletion.skippedReason ?? branchDeletion.attempted)}`, + "", + "Next:", + ]; + if (result.ok === true && result.alreadyMerged !== true && result.dryRun !== true) { + lines.push(` bun scripts/cli.ts gh pr view ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)}`); + } else { + lines.push(` ${prMergeRetryCommand(ghText(result.repo), result.number ?? details.number, mergeMethod, deleteBranch)}`); + lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)} --full`); + } + lines.push("", "Disclosure:"); + lines.push(" default view is a bounded table; use --full or --raw on read/preflight commands for structured metadata."); + return lines.join("\n"); +} + +export async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> { + const auth = await authStatus(repo); + const authCapability = compactAuthCapability(auth); + const policy = prPreflightPolicy(repo, number); + if (auth.ok === false) { + return { + ok: false, + command: commandName, + repo, + number, + degradedReason: auth.degradedReason, + runnerDisposition: auth.runnerDisposition, + authCapability, + policy, + details: auth, + }; + } + const { token, probe } = resolveToken(true); + const missing = authRequired(repo, commandName, probe); + if (missing !== null || token === null) { + const missingResult = missing ?? authRequired(repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + return { + ...(missingResult ?? commandError(commandName, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required"))), + command: commandName, + number, + authCapability, + policy, + } as GitHubCommandResult; + } + const { owner, name } = repoParts(repo); + const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number, authCapability, policy }); + const metadata = await prGraphqlMetadata(repo, token, number); + const summary = prSummary(pr); + const pullRequest = preflightPullRequestSummary(summary); + if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest, authCapability, policy }); + const metadataSummary = prMetadataSummary(metadata); + const statusChecks = statusRollupSummary(repo, number, metadata.statusCheckRollup, includeRaw); + const closeout = prCloseoutSummary(summary, metadataSummary, statusChecks); + const result: GitHubCommandResult = { + ok: true, + command: commandName, + canonicalCommand: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo}`, + aliases: ["bun scripts/cli.ts gh preflight <number>", "bun scripts/cli.ts gh pr closeout <number>"], + repo, + number, + readOnly: true, + writesRemote: false, + valuesPrinted: false, + authCapability, + pullRequest, + mergeability: { + mergeable: metadataSummary.mergeable, + mergeStateStatus: metadataSummary.mergeStateStatus, + ...closeout, + }, + statusChecks, + policy, + ...(includeRaw ? { raw: { authStatus: auth, pullRequest, closeoutMetadata: metadataSummary } } : {}), + }; + if (includeRaw) return result; + return { + ...result, + contentType: "text/plain", + renderedText: renderPrPreflightTable(result), + }; +} + +export function renderPrPreflightTable(result: GitHubCommandResult): string { + const pullRequest = isRecord(result.pullRequest) ? result.pullRequest : {}; + const mergeability = isRecord(result.mergeability) ? result.mergeability : {}; + const statusChecks = isRecord(result.statusChecks) ? result.statusChecks : {}; + const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; + const policy = isRecord(result.policy) ? result.policy : {}; + const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; + const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map(String) : []; + const conclusion = ghText(mergeability.conclusion); + const rows = [[ + `#${ghText(result.number)}`, + ghText(pullRequest.stateDetail ?? pullRequest.state), + ghText(mergeability.mergeable), + ghText(mergeability.mergeStateStatus), + `${ghText(statusChecks.totalContexts)} total ${ghText(counts.success)} ok ${ghText(counts.failure)} fail ${ghText(counts.pending)} pending`, + conclusion, + ]]; + const lines = [ + `gh pr preflight (${conclusion})`, + "", + ghTable(["PR", "STATE", "MERGEABLE", "MERGE_STATE", "CHECKS", "CONCLUSION"], rows), + "", + "Summary:", + ` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`, + ` head=${ghText(isRecord(pullRequest.head) ? pullRequest.head.ref : null)} base=${ghText(isRecord(pullRequest.base) ? pullRequest.base.ref : null)}`, + ` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`, + "", + "Next:", + ]; + const mergeCommand = ghText(policy.mergeCommand); + if (mergeability.readyForCommanderMerge === true && mergeCommand !== "-") { + lines.push(` ${mergeCommand}`); + } + lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number)} --repo ${ghText(result.repo)} --full`); + lines.push("", "Disclosure:"); + lines.push(" default view is a bounded table; use --full or --raw for structured PR/check metadata."); + return lines.join("\n"); +} diff --git a/scripts/src/gh/pr-summary.ts b/scripts/src/gh/pr-summary.ts new file mode 100644 index 00000000..03533f1a --- /dev/null +++ b/scripts/src/gh/pr-summary.ts @@ -0,0 +1,516 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:4730-5231. + +import path from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { bodySha, preview, previewLines, repoParts } from "./auth-and-safety"; +import { errorPayload, githubGraphqlRequest, githubRequest, isGitHubError, runnerDisposition } from "./client"; +import { issueRead } from "./issue-read"; +import { isRecord } from "./notify-claudeqq"; +import { prState } from "./pr-commands"; +import { ghText } from "./render"; +import { PR_CLOSEOUT_JSON_FIELDS, UNIDESK_CLI_CONFIG_PATH } from "./types"; +import type { GitHubCommandResult, GitHubErrorPayload, GitHubPullRequest, GitHubPullRequestFile, GitHubPullRequestGraphqlMetadata, GitHubPullRequestGraphqlStatusCheckRollup, GitHubPullRequestGraphqlStatusContext, PrMergeUnknownRetryConfig } from "./types"; + +export function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" { + if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged"; + return pr.state === "closed" ? "closed" : "open"; +} + +export function prSummary(pr: GitHubPullRequest, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record<string, unknown> { + const stateDetail = prStateDetail(pr); + const closedAt = pr.closed_at ?? pr.merged_at ?? null; + const mergedAt = pr.merged_at ?? null; + const mergeCommitSha = pr.merge_commit_sha ?? null; + const body = pr.body ?? ""; + const includeBody = options.includeBody ?? true; + const includePreview = options.includePreview ?? true; + const summary: Record<string, unknown> = { + id: pr.id, + number: pr.number, + title: pr.title, + state: pr.state, + stateDetail, + draft: pr.draft ?? false, + url: pr.html_url, + author: pr.user?.login ?? null, + head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null }, + base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null }, + headRefName: pr.head?.ref ?? null, + baseRefName: pr.base?.ref ?? null, + createdAt: pr.created_at ?? null, + updatedAt: pr.updated_at ?? null, + closed: stateDetail !== "open", + closedAt, + merged: stateDetail === "merged", + mergedAt, + mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null, + bodyChars: body.length, + bodySha: bodySha(body), + }; + if (includeBody) { + summary.body = body; + } else { + summary.bodyOmitted = true; + summary.fullBodyIncluded = false; + if (includePreview) { + summary.bodyPreview = preview(body); + summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8); + } + } + return summary; +} + +export function numberOrNull(value: number | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function prCompactSummary(pr: GitHubPullRequest): Record<string, unknown> { + return { + id: pr.id, + number: pr.number, + title: pr.title, + state: pr.state, + draft: pr.draft ?? false, + url: pr.html_url, + author: pr.user?.login ?? null, + head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null }, + base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null }, + createdAt: pr.created_at ?? null, + updatedAt: pr.updated_at ?? null, + }; +} + +export function prFileSummary(file: GitHubPullRequestFile): Record<string, unknown> { + return { + filename: file.filename, + status: file.status ?? null, + additions: numberOrNull(file.additions), + deletions: numberOrNull(file.deletions), + changes: numberOrNull(file.changes), + previousFilename: file.previous_filename ?? null, + sha: file.sha ?? null, + blobUrl: file.blob_url ?? null, + contentsUrl: file.contents_url ?? null, + }; +} + +export function sumPrFileStats(files: GitHubPullRequestFile[]): { additions: number; deletions: number; changes: number } { + return files.reduce((accumulator, file) => { + accumulator.additions += file.additions ?? 0; + accumulator.deletions += file.deletions ?? 0; + accumulator.changes += file.changes ?? ((file.additions ?? 0) + (file.deletions ?? 0)); + return accumulator; + }, { additions: 0, deletions: 0, changes: 0 }); +} + +export function selectedPrJson(summary: Record<string, unknown>, fields: readonly string[]): Record<string, unknown> { + const selected: Record<string, unknown> = {}; + for (const field of fields) selected[field] = summary[field]; + return selected; +} + +export function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> { + return { + mergeable: metadata.mergeable ?? null, + mergeStateStatus: metadata.mergeStateStatus ?? null, + statusCheckRollup: metadata.statusCheckRollup ?? null, + headRefName: metadata.headRefName ?? null, + baseRefName: metadata.baseRefName ?? null, + }; +} + +export function prMergeBoundary(): Record<string, unknown> { + return { + runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization"], + ordinaryRunnerFinalActionAllowed: true, + commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"], + hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"], + unideskCliMergeSupported: true, + }; +} + +export function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> { + const summary = prMetadataSummary(metadata); + const missingOrUnknownFields = PR_CLOSEOUT_JSON_FIELDS.filter((field) => { + const value = summary[field]; + if (value === null || value === undefined) return true; + return typeof value === "string" && value.toUpperCase() === "UNKNOWN"; + }); + return { + ok: missingOrUnknownFields.length === 0, + source: "github-graphql", + missingOrUnknownFields, + advice: missingOrUnknownFields.length === 0 + ? "Closeout GraphQL metadata is present; still review checks, branch scope, and task boundary before final action." + : "Some closeout GraphQL metadata is missing or unknown; retry or cross-check with system gh/GitHub UI before treating the PR as merge-ready.", + mergeBoundary: prMergeBoundary(), + }; +} + +export function prCloseoutMetadataError(error: GitHubErrorPayload): Record<string, unknown> { + return { + ok: false, + source: "github-graphql", + degradedReason: error.degradedReason, + runnerDisposition: error.runnerDisposition, + message: error.message, + advice: "Closeout GraphQL metadata was not available; retry or use a read-only system gh/GitHub UI cross-check before deciding merge readiness.", + mergeBoundary: prMergeBoundary(), + }; +} + +export function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean { + if (fields === undefined) return false; + return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup"); +} + +export async function prGraphqlMetadata(repo: string, token: string, number: number): Promise<GitHubPullRequestGraphqlMetadata | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + const query = ` + query PullRequestCloseoutMetadata($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + mergeable + mergeStateStatus + headRefName + baseRefName + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + status + conclusion + } + ... on StatusContext { + context + state + targetUrl + description + } + } + } + } + } + } + } + `; + const response = await githubGraphqlRequest<{ repository?: { pullRequest?: GitHubPullRequestGraphqlMetadata | null } }>(token, query, { owner, name, number }); + if (isGitHubError(response)) return response; + const pullRequest = response.repository?.pullRequest; + if (pullRequest === null || pullRequest === undefined) { + return errorPayload("invalid-response", "GitHub GraphQL PR metadata response was missing repository.pullRequest", { details: response }); + } + return pullRequest; +} + +export function statusContextName(context: GitHubPullRequestGraphqlStatusContext): string { + return context.name ?? context.context ?? "(unnamed status check)"; +} + +export function statusContextBucket(context: GitHubPullRequestGraphqlStatusContext): string { + const type = context.__typename ?? "StatusContext"; + const status = (context.status ?? "").toUpperCase(); + const conclusion = (context.conclusion ?? "").toUpperCase(); + const state = (context.state ?? "").toUpperCase(); + if (type === "CheckRun") { + if (status.length > 0 && status !== "COMPLETED") return "pending"; + if (conclusion === "SUCCESS") return "success"; + if (conclusion === "NEUTRAL") return "neutral"; + if (conclusion === "SKIPPED") return "skipped"; + if (conclusion === "CANCELLED") return "cancelled"; + if (conclusion === "TIMED_OUT") return "timed-out"; + if (["FAILURE", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(conclusion)) return "failure"; + return "unknown"; + } + if (state === "SUCCESS") return "success"; + if (state === "PENDING" || state === "EXPECTED") return "pending"; + if (state === "FAILURE" || state === "ERROR") return "failure"; + return "unknown"; +} + +export function compactStatusContext(context: GitHubPullRequestGraphqlStatusContext): Record<string, unknown> { + const bucket = statusContextBucket(context); + return { + name: statusContextName(context), + type: context.__typename ?? "StatusContext", + bucket, + status: context.status ?? null, + conclusion: context.conclusion ?? null, + state: context.state ?? null, + targetUrl: context.targetUrl ?? null, + description: context.description === undefined || context.description === null ? null : preview(context.description), + }; +} + +export function statusRollupSummary( + repo: string, + number: number, + rollup: GitHubPullRequestGraphqlStatusCheckRollup | null | undefined, + includeRaw: boolean, +): Record<string, unknown> { + const contexts = rollup?.contexts?.nodes ?? []; + const compactContexts = contexts.map(compactStatusContext); + const counts: Record<string, number> = { + success: 0, + failure: 0, + pending: 0, + neutral: 0, + skipped: 0, + cancelled: 0, + "timed-out": 0, + unknown: 0, + }; + for (const context of compactContexts) { + const bucket = String(context.bucket ?? "unknown"); + counts[bucket] = (counts[bucket] ?? 0) + 1; + } + const nonSuccess = compactContexts.filter((context) => context.bucket !== "success"); + const failing = compactContexts.filter((context) => context.bucket === "failure" || context.bucket === "cancelled" || context.bucket === "timed-out"); + const pending = compactContexts.filter((context) => context.bucket === "pending" || context.bucket === "unknown"); + const neutral = compactContexts.filter((context) => context.bucket === "neutral" || context.bucket === "skipped"); + return { + state: rollup?.state ?? null, + totalContexts: compactContexts.length, + counts, + failingContexts: failing.slice(0, 10), + pendingContexts: pending.slice(0, 10), + neutralContexts: neutral.slice(0, 10), + nonSuccessPreview: nonSuccess.slice(0, 12), + omittedNonSuccessContexts: Math.max(0, nonSuccess.length - 12), + rawOmitted: !includeRaw, + ...(includeRaw + ? { contexts: compactContexts, raw: rollup ?? null } + : { fullHint: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo} --full` }), + }; +} + +export function compactProbeOk(value: unknown): boolean | null { + if (value === "ok") return true; + if (value === "skipped") return null; + if (isRecord(value) && value.ok === true) return true; + if (isRecord(value) && value.ok === false) return false; + return null; +} + +export function compactAuthCapability(auth: GitHubCommandResult): Record<string, unknown> { + const token = isRecord(auth.token) ? auth.token : {}; + const gh = isRecord(auth.gh) ? auth.gh : {}; + const probes = isRecord(auth.probes) ? auth.probes : {}; + return { + ok: auth.ok === true, + degradedReason: auth.ok === false ? auth.degradedReason ?? null : null, + runnerDisposition: auth.ok === false ? auth.runnerDisposition ?? null : null, + tokenPresent: token.present === true, + tokenSource: typeof token.source === "string" ? token.source : null, + ghFallbackAttempted: token.ghFallbackAttempted === true, + ghBinaryFound: gh.binaryFound === true, + restApi: compactProbeOk(probes.restApi), + repoVisible: compactProbeOk(probes.repo), + issueRead: compactProbeOk(probes.issueRead), + valuesPrinted: false, + }; +} + +export function prPreflightPolicy(repo: string, number: number): Record<string, unknown> { + return { + readOnly: true, + writesRemote: false, + createsPr: false, + comments: false, + mergesPr: false, + mergeCommandSupported: true, + mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`, + unideskCliMergeSupported: true, + ordinaryRunnerFinalActionAllowed: true, + note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks for diagnosis; gh pr merge performs its own guarded readiness check and does not require this preflight step.", + }; +} + +export function preflightPullRequestSummary(summary: Record<string, unknown>): Record<string, unknown> { + return { + number: summary.number ?? null, + title: summary.title ?? null, + state: summary.state ?? null, + stateDetail: summary.stateDetail ?? null, + draft: summary.draft ?? null, + url: summary.url ?? null, + author: summary.author ?? null, + head: summary.head ?? null, + base: summary.base ?? null, + headRefName: summary.headRefName ?? null, + baseRefName: summary.baseRefName ?? null, + closed: summary.closed ?? null, + merged: summary.merged ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: summary.updatedAt ?? null, + bodyOmitted: true, + }; +} + +export function prCloseoutSummary( + pr: Record<string, unknown>, + metadata: Record<string, unknown>, + statusChecks: Record<string, unknown>, +): Record<string, unknown> { + const blockers: string[] = []; + const pending: string[] = []; + const prState = typeof pr.state === "string" ? pr.state.toLowerCase() : ""; + if (prState !== "open") blockers.push(`state:${pr.state ?? "unknown"}`); + if (pr.draft === true) blockers.push("draft:true"); + const mergeable = typeof metadata.mergeable === "string" ? metadata.mergeable.toUpperCase() : null; + if (mergeable === "MERGEABLE") { + // Ready signal, combined with mergeStateStatus and checks below. + } else if (mergeable === "CONFLICTING" || mergeable === "NOT_MERGEABLE") { + blockers.push(`mergeable:${mergeable}`); + } else { + pending.push(`mergeable:${mergeable ?? "unknown"}`); + } + const mergeStateStatus = typeof metadata.mergeStateStatus === "string" ? metadata.mergeStateStatus.toUpperCase() : null; + if (mergeStateStatus === "CLEAN") { + // Ready signal. + } else if (mergeStateStatus === "UNKNOWN" || mergeStateStatus === null) { + pending.push(`mergeStateStatus:${mergeStateStatus ?? "unknown"}`); + } else { + blockers.push(`mergeStateStatus:${mergeStateStatus}`); + } + const rollupState = typeof statusChecks.state === "string" ? statusChecks.state.toUpperCase() : null; + const totalContexts = typeof statusChecks.totalContexts === "number" ? statusChecks.totalContexts : 0; + const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {}; + const failureCount = Number(counts.failure ?? 0) + Number(counts.cancelled ?? 0) + Number(counts["timed-out"] ?? 0); + const pendingCount = Number(counts.pending ?? 0) + Number(counts.unknown ?? 0); + if (failureCount > 0) blockers.push(`statusChecks:failure:${failureCount}`); + if (pendingCount > 0) pending.push(`statusChecks:pending:${pendingCount}`); + if (rollupState === "SUCCESS" || rollupState === null && totalContexts === 0) { + // Ready signal. + } else if (rollupState === "PENDING" || rollupState === "EXPECTED" || rollupState === null) { + pending.push(`statusCheckRollup:${rollupState ?? "unknown"}`); + } else if (rollupState !== null) { + blockers.push(`statusCheckRollup:${rollupState}`); + } + const readyForCommanderMerge = blockers.length === 0 && pending.length === 0; + return { + readyForCommanderMerge, + conclusion: readyForCommanderMerge ? "ready" : blockers.length > 0 ? "blocked" : "pending", + blockers, + pending, + commanderAction: readyForCommanderMerge + ? "review and merge through bun scripts/cli.ts gh pr merge when task boundaries allow" + : "resolve blockers or rerun after GitHub finishes computing mergeability/status checks", + }; +} + +export function stripYamlComment(value: string): string { + const index = value.indexOf("#"); + return (index >= 0 ? value.slice(0, index) : value).trim(); +} + +export function yamlScalarByPath(text: string, keyPath: string[]): string | null { + const stack: Array<{ indent: number; key: string }> = []; + for (const line of text.split(/\r?\n/u)) { + if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue; + const match = /^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$/u.exec(line); + if (match === null) continue; + const indent = match[1].length; + while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop(); + const key = match[2]; + const rawValue = stripYamlComment(match[3] ?? ""); + const currentPath = [...stack.map((item) => item.key), key]; + if (currentPath.length === keyPath.length && currentPath.every((item, index) => item === keyPath[index])) { + return rawValue.length === 0 ? null : rawValue; + } + stack.push({ indent, key }); + } + return null; +} + +export function yamlNumberByPath(text: string, keyPath: string[], configPath: string): number { + const value = yamlScalarByPath(text, keyPath); + if (value === null) throw new Error(`${configPath} missing numeric config ${keyPath.join(".")}`); + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${configPath} config ${keyPath.join(".")} must be a positive number`); + return parsed; +} + +export function loadPrMergeUnknownRetryConfig(): PrMergeUnknownRetryConfig { + const configPath = path.resolve(process.cwd(), UNIDESK_CLI_CONFIG_PATH); + if (!existsSync(configPath)) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} is required for gh pr merge retry policy`); + const text = readFileSync(configPath, "utf8"); + const prefix = ["github", "prMerge", "unknownRetry"]; + const config = { + maxAttempts: Math.trunc(yamlNumberByPath(text, [...prefix, "maxAttempts"], UNIDESK_CLI_CONFIG_PATH)), + initialDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "initialDelayMs"], UNIDESK_CLI_CONFIG_PATH)), + maxDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "maxDelayMs"], UNIDESK_CLI_CONFIG_PATH)), + factor: yamlNumberByPath(text, [...prefix, "factor"], UNIDESK_CLI_CONFIG_PATH), + }; + if (config.maxAttempts < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxAttempts must be >= 1`); + if (config.maxDelayMs < config.initialDelayMs) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxDelayMs must be >= initialDelayMs`); + if (config.factor < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.factor must be >= 1`); + return config; +} + +export function sleepMs(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function mergeabilityHasUnknownPending(mergeability: Record<string, unknown>): boolean { + const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : []; + if (blockers.length > 0) return false; + const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map((item) => item.toLowerCase()) : []; + return pending.some((item) => item.includes("unknown")); +} + +export function nextPrMergeRetryDelayMs(config: PrMergeUnknownRetryConfig, previousDelayMs: number): number { + return Math.min(config.maxDelayMs, Math.max(1, Math.ceil(previousDelayMs * config.factor))); +} + +export function prMergeMethodFlag(method: unknown): string { + if (method === "squash") return "--squash"; + if (method === "rebase") return "--rebase"; + return "--merge"; +} + +export function prMergeRetryCommand(repo: string, number: unknown, method: unknown, deleteBranch: unknown): string { + return [ + "bun scripts/cli.ts gh pr merge", + ghText(number), + "--repo", + repo, + prMergeMethodFlag(method), + deleteBranch === true ? "--delete-branch" : "", + ].filter((item) => item.length > 0).join(" "); +} + +export async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise<Record<string, unknown>> { + const { owner, name } = repoParts(repo); + const headRepo = pr.head?.repo?.full_name ?? null; + const headRef = pr.head?.ref ?? null; + if (headRepo !== repo || headRef === null || headRef.length === 0) { + return { + attempted: false, + skippedReason: "head-repo-differs-or-ref-missing", + headRepo, + headRef, + }; + } + const encodedRef = encodeURIComponent(`heads/${headRef}`); + const deleted = await githubRequest<unknown>(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`); + if (isGitHubError(deleted)) { + return { + attempted: true, + ok: false, + headRepo, + headRef, + error: deleted, + }; + } + return { + attempted: true, + ok: true, + headRepo, + headRef, + }; +} diff --git a/scripts/src/gh/refs.ts b/scripts/src/gh/refs.ts new file mode 100644 index 00000000..12d9b91e --- /dev/null +++ b/scripts/src/gh/refs.ts @@ -0,0 +1,373 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:980-1344. + +import { validationError } from "./client"; +import { optionValue, optionWasProvided, positionalArgs } from "./options"; +import { ISSUE_VIEW_JSON_FIELDS, PR_READ_JSON_FIELDS } from "./types"; +import type { GitHubCommandResult, GitHubOptions, GitHubResolvedNumberReference, GitHubShorthandReference, IssueViewJsonField, PrReadJsonField } from "./types"; + +export function parseNumber(raw: string | undefined, label: string): number { + if (raw === undefined) throw new Error(`${label} requires a number`); + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`); + return value; +} + +export function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult { + try { + return parseNumber(raw, label); + } catch (error) { + return validationError(label, repo, error instanceof Error ? error.message : String(error)); + } +} + +export function parsePositionalNumberForCommand(repo: string, args: string[], startIndex: number, label: string): number | GitHubCommandResult { + const targets = positionalArgs(args.slice(startIndex)); + if (targets.length !== 1) { + return validationError(label, repo, `${label} requires exactly one positive integer positional argument`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${repo}`, + `bun scripts/cli.ts gh ${label} --repo ${repo} <number>`, + ], + }); + } + return parseNumberForCommand(repo, targets[0], label); +} + +export function resolvedNumberOptionReference(kind: "issue" | "pr", command: string, repo: string, raw: string): GitHubResolvedNumberReference | GitHubCommandResult { + const number = parseNumberForCommand(repo, raw, command); + if (typeof number !== "number") return number; + return { + repo, + number, + shorthand: { + input: `--number ${number}`, + repo, + number, + source: "number-option", + standardCommand: `bun scripts/cli.ts gh ${command} ${number} --repo ${repo}`, + urlKind: kind, + }, + }; +} + +export function resolvePositionalNumberReference(kind: "issue" | "pr", args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { + const targets = positionalArgs(args.slice(startIndex)); + const numberOption = optionValue(args, "--number"); + if (targets.length > 0 && numberOption !== undefined) { + return validationError(label, options.repo, `${label} accepts either a positional numeric target or --number, not both`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + if (numberOption !== undefined) return resolvedNumberOptionReference(kind, label, options.repo, numberOption); + if (targets.length !== 1) { + return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + const shorthand = parseOwnerRepoNumberShorthand(targets[0]); + if (shorthand !== null) { + const explicitRepo = optionValue(args, "--repo"); + if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { + return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { + shorthand, + explicitRepo, + }); + } + return { repo: shorthand.repo, number: shorthand.number, shorthand }; + } + const number = parseNumberForCommand(options.repo, targets[0], label); + if (typeof number !== "number") return number; + return { repo: options.repo, number }; +} + +export function resolvePositionalPrReference(args: string[], startIndex: number, label: string, options: GitHubOptions): GitHubResolvedNumberReference | GitHubCommandResult { + const targets = positionalArgs(args.slice(startIndex)); + const prOption = optionValue(args, "--pr"); + const numberOption = optionValue(args, "--number"); + if (prOption !== undefined && numberOption !== undefined) { + return validationError(label, options.repo, `${label} accepts either --pr or --number as a compatibility alias, not both`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + if (targets.length > 0 && (prOption !== undefined || numberOption !== undefined)) { + return validationError(label, options.repo, `${label} accepts either a positional PR target or a compatibility number option, not both`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + if (numberOption !== undefined) return resolvedNumberOptionReference("pr", label, options.repo, numberOption); + const effectiveTargets = prOption !== undefined ? [prOption] : targets; + if (effectiveTargets.length !== 1) { + return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --pr <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + const shorthand = parseOwnerRepoNumberShorthand(effectiveTargets[0]); + if (shorthand !== null) { + const explicitRepo = optionValue(args, "--repo"); + if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { + return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { + shorthand, + explicitRepo, + }); + } + return { repo: shorthand.repo, number: shorthand.number, shorthand }; + } + const number = parseNumberForCommand(options.repo, effectiveTargets[0], label); + if (typeof number !== "number") return number; + return { repo: options.repo, number }; +} + +export function resolvePositionalIssueReference(args: string[], startIndex: number, label: string, options: GitHubOptions, allowNumberOption = true): GitHubResolvedNumberReference | GitHubCommandResult { + const targets = positionalArgs(args.slice(startIndex)); + const numberOption = allowNumberOption ? optionValue(args, "--number") : undefined; + if (targets.length > 0 && numberOption !== undefined) { + return validationError(label, options.repo, `${label} accepts either a positional issue target or --number, not both`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`, + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + if (numberOption !== undefined) return resolvedNumberOptionReference("issue", label, options.repo, numberOption); + if (targets.length !== 1) { + return validationError(label, options.repo, `${label} requires exactly one positive integer or owner/repo#number positional argument`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${label} <number> --repo ${options.repo}`, + ...(allowNumberOption ? [`bun scripts/cli.ts gh ${label} --number <number> --repo ${options.repo}`] : []), + `bun scripts/cli.ts gh ${label} ${options.repo}#<number>`, + ], + }); + } + const shorthand = parseOwnerRepoNumberShorthand(targets[0]); + if (shorthand !== null) { + const explicitRepo = optionValue(args, "--repo"); + if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { + return validationError(label, explicitRepo, `${label} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided.`, { + shorthand, + explicitRepo, + }); + } + return { repo: shorthand.repo, number: shorthand.number, shorthand }; + } + const number = parseNumberForCommand(options.repo, targets[0], label); + if (typeof number !== "number") return number; + return { repo: options.repo, number }; +} + +export function parseOwnerRepoNumberShorthand(raw: string | undefined): GitHubShorthandReference | null { + if (raw === undefined) return null; + const match = /^([^/#\s]+)\/([^/#\s]+)#([1-9]\d*)$/u.exec(raw); + if (match === null) return null; + return { + input: raw, + repo: `${match[1]}/${match[2]}`, + number: Number(match[3]), + source: "owner-repo-number", + }; +} + +export function parseGitHubIssueOrPrUrl(raw: string | undefined): GitHubShorthandReference | null { + if (raw === undefined) return null; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + if (parsed.hostname.toLowerCase() !== "github.com" && parsed.hostname.toLowerCase() !== "www.github.com") return null; + const parts = parsed.pathname.split("/").filter((part) => part.length > 0); + if (parts.length < 4) return null; + const [owner, repoName, kind, numberRaw] = parts; + if (!owner || !repoName || (kind !== "issues" && kind !== "pull")) return null; + if (!/^[1-9]\d*$/u.test(numberRaw ?? "")) return null; + return { + input: raw, + repo: `${owner}/${repoName}`, + number: Number(numberRaw), + source: "github-url", + urlKind: kind === "issues" ? "issue" : "pr", + }; +} + +export function parseReadViewTarget(raw: string | undefined): GitHubShorthandReference | null { + return parseOwnerRepoNumberShorthand(raw) ?? parseGitHubIssueOrPrUrl(raw); +} + +export function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: number): string[] { + return [ + `bun scripts/cli.ts gh ${kind} view ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)}`, + `bun scripts/cli.ts gh ${kind} view ${repo}#${number} --raw`, + `bun scripts/cli.ts gh ${kind} read ${number} --repo ${repo} --json ${readViewSupportedJsonFields(kind)} [compatibility alias]`, + ]; +} + +export function readViewSupportedJsonFields(kind: "issue" | "pr"): string { + return kind === "issue" + ? ISSUE_VIEW_JSON_FIELDS.join(",") + : "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"; +} + +export function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "view", _raw: string | undefined, options: GitHubOptions, args: string[]): GitHubResolvedNumberReference | GitHubCommandResult { + const command = `${kind} ${sub}`; + const targets = positionalArgs(args.slice(2)); + if (targets.length > 1) { + return validationError(command, options.repo, `${command} accepts one positional target: number, GitHub ${kind} URL, or owner/repo#number`, { + supportedCommands: [ + `bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, + `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`, + `bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`, + ], + }); + } + const raw = targets[0]; + if (optionWasProvided(args, "--number")) { + const numberAliasRaw = optionValue(args, "--number"); + const aliasNumber = parseNumberForCommand(options.repo, numberAliasRaw, command); + if (typeof aliasNumber !== "number") return aliasNumber; + if (raw !== undefined) { + const parsedRaw = parseNumberForCommand(options.repo, raw, command); + if (typeof parsedRaw !== "number") return parsedRaw; + if (parsedRaw !== aliasNumber) { + return validationError(command, options.repo, `${command} positional number ${parsedRaw} conflicts with --number ${aliasNumber}; use one target number.`, { + positionalNumber: parsedRaw, + numberAlias: aliasNumber, + supportedCommands: readViewSupportedCommands(kind, options.repo, aliasNumber), + }); + } + } + return { + repo: options.repo, + number: aliasNumber, + shorthand: { + input: `--number ${aliasNumber}`, + repo: options.repo, + number: aliasNumber, + source: "number-option", + standardCommand: `bun scripts/cli.ts gh ${kind} view ${aliasNumber} --repo ${options.repo}`, + }, + }; + } + const shorthand = parseReadViewTarget(raw); + if (shorthand !== null) { + if (shorthand.urlKind !== undefined && shorthand.urlKind !== kind) { + return validationError(command, shorthand.repo, `${command} target ${shorthand.input} is a GitHub ${shorthand.urlKind} URL, not a ${kind} URL.`, { + shorthand, + supportedCommands: readViewSupportedCommands(shorthand.urlKind, shorthand.repo, shorthand.number), + }); + } + const explicitRepo = optionValue(args, "--repo"); + if (explicitRepo !== undefined && explicitRepo !== shorthand.repo) { + const message = `${command} target ${shorthand.input} resolves to repo ${shorthand.repo}, but --repo ${explicitRepo} was also provided. Use either the shorthand or a matching --repo, not both.`; + return validationError(command, explicitRepo, message, { + message, + shorthand, + explicitRepo, + supportedCommands: readViewSupportedCommands(kind, shorthand.repo, shorthand.number), + }); + } + return { repo: shorthand.repo, number: shorthand.number, shorthand }; + } + const parsed = parseNumberForCommand(options.repo, raw, command); + if (typeof parsed !== "number") { + return { + ...parsed, + supportedCommands: [ + `bun scripts/cli.ts gh ${kind} view <number> --repo owner/name --json ${readViewSupportedJsonFields(kind)}`, + `bun scripts/cli.ts gh ${kind} view https://github.com/owner/name/${kind === "issue" ? "issues" : "pull"}/<number> --raw`, + `bun scripts/cli.ts gh ${kind} view owner/name#<number> --raw`, + ], + }; + } + return { repo: options.repo, number: parsed }; +} + +export function issueReadJsonFields(options: GitHubOptions): IssueViewJsonField[] | undefined { + return options.jsonFields ?? (options.raw || options.full ? ISSUE_VIEW_JSON_FIELDS.slice() : undefined); +} + +export function prReadJsonFields(options: GitHubOptions): PrReadJsonField[] | undefined { + return options.prJsonFields ?? (options.raw || options.full ? PR_READ_JSON_FIELDS.slice() : undefined); +} + +export function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthandReference | undefined): Record<string, unknown> | null { + if (!options.raw && !options.full && shorthand === undefined) return null; + return { + ...(options.raw ? { raw: true } : {}), + ...(options.full ? { full: true } : {}), + fullDisclosure: options.raw || options.full, + shorthand: shorthand ?? null, + ...(shorthand?.source === "number-option" ? { + compatibilityHint: "--number is accepted for low-friction compatibility; standard gh syntax uses a positional number or URL.", + standardCommand: shorthand.standardCommand, + } : {}), + }; +} + +export function numberOptionCompatibilityHint(resolved: GitHubResolvedNumberReference): Record<string, unknown> | null { + if (resolved.shorthand?.source !== "number-option") return null; + return { + acceptedOption: "--number", + compatibility: true, + message: "--number is accepted for low-friction compatibility; prefer the standard positional number target shown in standardCommand.", + standardCommand: resolved.shorthand.standardCommand ?? null, + }; +} + +export async function withNumberOptionHint(result: GitHubCommandResult | Promise<GitHubCommandResult>, resolved: GitHubResolvedNumberReference): Promise<GitHubCommandResult> { + const output = await result; + const hint = numberOptionCompatibilityHint(resolved); + if (hint === null) return output; + return { + ...output, + standardSyntaxHint: hint, + }; +} + +export function isGitHubCommandResult(value: unknown): value is GitHubCommandResult { + return typeof (value as { ok?: unknown }).ok === "boolean"; +} + +export function unknownGhOptionDetails(args: string[], option: string): Record<string, unknown> { + const [top, sub, third] = args; + const details: Record<string, unknown> = { + unsupportedOption: option, + helpCommand: "bun scripts/cli.ts gh help", + }; + if ((top === "issue" || top === "pr") && (sub === "read" || sub === "view")) { + const shorthand = parseReadViewTarget(third); + const repo = shorthand?.repo ?? optionValue(args, "--repo") ?? "owner/name"; + const number = shorthand?.number ?? (third !== undefined && /^\d+$/u.test(third) ? Number(third) : 0); + details.supportedCommands = number > 0 + ? readViewSupportedCommands(top, repo, number) + : [ + `bun scripts/cli.ts gh ${top} view <number> --repo owner/name --json ${readViewSupportedJsonFields(top)}`, + `bun scripts/cli.ts gh ${top} view owner/name#<number> --raw`, + ]; + } + return details; +} diff --git a/scripts/src/gh/render.ts b/scripts/src/gh/render.ts new file mode 100644 index 00000000..5cc61e07 --- /dev/null +++ b/scripts/src/gh/render.ts @@ -0,0 +1,71 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:4686-5550. + +import { bodySha, preview, previewLines } from "./auth-and-safety"; +import type { GitHubComment } from "./types"; + +export function commentSummary(comment: GitHubComment): Record<string, unknown> { + return { + id: comment.id, + body: comment.body ?? "", + url: comment.html_url, + author: comment.user?.login ?? null, + createdAt: comment.created_at ?? null, + updatedAt: comment.updated_at ?? null, + }; +} + +export function compactCommentSummary(comment: GitHubComment): Record<string, unknown> { + const body = comment.body ?? ""; + return { + id: comment.id, + url: comment.html_url, + author: comment.user?.login ?? null, + createdAt: comment.created_at ?? null, + updatedAt: comment.updated_at ?? null, + bodyChars: body.length, + bodySha: bodySha(body), + bodyPreview: preview(body), + bodyPreviewLines: previewLines(body, 4), + bodyOmitted: true, + fullBodyIncluded: false, + }; +} + +export function compactIssueViewCommentSummary(comment: GitHubComment): Record<string, unknown> { + const body = comment.body ?? ""; + return { + id: comment.id, + url: comment.html_url, + author: comment.user?.login ?? null, + createdAt: comment.created_at ?? null, + updatedAt: comment.updated_at ?? null, + bodyChars: body.length, + bodySha: bodySha(body), + bodyPreview: ghShort(preview(body).replace(/\s+/gu, " "), 200), + bodyOmitted: true, + fullBodyIncluded: false, + }; +} + +export function ghTable(headers: string[], rows: string[][]): string { + const normalizedRows = rows.map((row) => headers.map((_, index) => row[index] ?? "")); + const widths = headers.map((header, index) => Math.max(header.length, ...normalizedRows.map((row) => row[index].length))); + return [ + headers.map((header, index) => header.padEnd(widths[index])).join(" ").trimEnd(), + ...normalizedRows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()), + ].join("\n"); +} + +export function ghText(value: unknown): string { + if (value === null || value === undefined || value === "") return "-"; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +export function ghShort(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + if (maxLength <= 1) return value.slice(0, maxLength); + return `${value.slice(0, maxLength - 1)}~`; +} diff --git a/scripts/src/gh/repo-compare.ts b/scripts/src/gh/repo-compare.ts new file mode 100644 index 00000000..d84ec389 --- /dev/null +++ b/scripts/src/gh/repo-compare.ts @@ -0,0 +1,137 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:5552-5680. + +import path from "node:path"; +import { bodySafetySignals, bodySha, preview, previewLines, repoParts } from "./auth-and-safety"; +import { githubRequest } from "./client"; +import type { BodyUpdateMode, GitHubBranch, GitHubCompare, GitHubErrorPayload, GitHubOptions, GitHubRepository } from "./types"; + +export function repoSummary(repo: GitHubRepository): Record<string, unknown> { + return { + id: repo.id ?? null, + fullName: repo.full_name ?? null, + private: repo.private ?? null, + defaultBranch: repo.default_branch ?? null, + permissions: repo.permissions ?? null, + }; +} + +export function branchSummary(branch: GitHubBranch): Record<string, unknown> { + return { + name: branch.name ?? null, + sha: branch.commit?.sha ?? null, + }; +} + +export function compareSummary(compare: GitHubCompare): Record<string, unknown> { + return { + status: compare.status ?? null, + aheadBy: compare.ahead_by ?? null, + behindBy: compare.behind_by ?? null, + totalCommits: compare.total_commits ?? null, + htmlUrl: compare.html_url ?? null, + baseSha: compare.base_commit?.sha ?? null, + mergeBaseSha: compare.merge_base_commit?.sha ?? null, + }; +} + +export function prUrl(repo: string, number: number): string { + return `https://github.com/${repo}/pull/${number}`; +} + +export function encodePathPart(value: string): string { + return encodeURIComponent(value); +} + +export async function repoInfo(token: string, repo: string): Promise<GitHubRepository | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`); +} + +export async function branchInfo(token: string, repo: string, branch: string): Promise<GitHubBranch | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubBranch>(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`); +} + +export async function compareBranches(token: string, repo: string, base: string, head: string): Promise<GitHubCompare | GitHubErrorPayload> { + const { owner, name } = repoParts(repo); + return githubRequest<GitHubCompare>(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`); +} + +export function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record<string, unknown>): Record<string, unknown> { + return { + repo, + title: options.title, + base: options.base, + head: options.head, + draft: options.draft, + bodyChars: body.length, + bodyPreview: preview(body), + bodyPreviewLines: body.split(/\r?\n/).slice(0, 12), + bodySource, + preservesRawNewlines: body.includes("\n"), + containsLiteralBackslashN: body.includes("\\n"), + containsBackticks: body.includes("`"), + request: { + method: "POST", + path: "/repos/{owner}/{repo}/pulls", + body: { + title: options.title, + base: options.base, + head: options.head, + draft: options.draft, + bodyChars: body.length, + }, + }, + validation: { + repo: "required", + base: "required", + head: "required", + title: "required", + body: body.length > 0 ? "optional" : "empty", + }, + }; +} + +export function prCommentPlannedOperation(repo: string, issueNumber: number, body: string, bodySource: Record<string, unknown>): Record<string, unknown> { + return { + repo, + issueNumber, + bodyChars: body.length, + bodyPreview: preview(body), + bodyPreviewLines: body.split(/\r?\n/).slice(0, 12), + bodySource, + preservesRawNewlines: body.includes("\n"), + containsLiteralBackslashN: body.includes("\\n"), + containsBackticks: body.includes("`"), + request: { + method: "POST", + path: `/repos/{owner}/{repo}/issues/${issueNumber}/comments`, + body: { bodyChars: body.length }, + }, + validation: { + repo: "required", + issueNumber: "required", + body: "required", + }, + }; +} + +export function bodyUpdatePlan(command: string, repo: string, number: number, mode: BodyUpdateMode, body: string, bodySource: Record<string, unknown>, existingBody: string | null): Record<string, unknown> { + const finalBody = mode === "append" ? `${existingBody ?? ""}${body}` : body; + return { + command, + repo, + number, + mode, + bodySource, + input: bodySafetySignals(body), + existingBody: existingBody === null ? { fetched: false } : { fetched: true, bodyChars: existingBody.length, bodySha: bodySha(existingBody) }, + finalBody: { + bodyChars: finalBody.length, + bodyPreview: preview(finalBody), + bodyPreviewLines: previewLines(finalBody), + ...bodySafetySignals(finalBody), + }, + }; +} diff --git a/scripts/src/gh/stale-close.ts b/scripts/src/gh/stale-close.ts new file mode 100644 index 00000000..01a99e25 --- /dev/null +++ b/scripts/src/gh/stale-close.ts @@ -0,0 +1,142 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:7474-7605. + +import { repoParts } from "./auth-and-safety"; +import { commandError, githubRequest, isGitHubError, runnerDisposition } from "./client"; +import { issueListPaginationSummary, listIssues } from "./issue-read"; +import { issueLifecycleBatchSummary } from "./issue-summary"; +import { MAX_ISSUE_LIST_LIMIT } from "./types"; +import type { GitHubCommandResult, GitHubDegradedReason, GitHubErrorPayload, GitHubIssue, GitHubOptions, RunnerDisposition } from "./types"; + +export function parseGitHubTimestamp(value: string | undefined): number | null { + if (value === undefined) return null; + const millis = Date.parse(value); + return Number.isFinite(millis) ? millis : null; +} + +export function inactiveIssueCandidates(issues: GitHubIssue[], cutoffMs: number): GitHubIssue[] { + return issues.filter((issue) => { + const updatedAtMs = parseGitHubTimestamp(issue.updated_at); + return updatedAtMs !== null && updatedAtMs < cutoffMs; + }); +} + +export 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" }); +} + +export 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.", + }; +} diff --git a/scripts/src/gh/types.ts b/scripts/src/gh/types.ts new file mode 100644 index 00000000..36406254 --- /dev/null +++ b/scripts/src/gh/types.ts @@ -0,0 +1,678 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split +// Moved mechanically from scripts/src/gh.ts:8-612. + +import path from "node:path"; +import { preview } from "./auth-and-safety"; +import { runnerDisposition } from "./client"; + +export const DEFAULT_REPO = "pikasTech/unidesk"; + +export const GITHUB_API = (process.env.UNIDESK_GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/u, ""); + +export const USER_AGENT = "unidesk-cli-gh"; + +export const PREVIEW_CHARS = 240; + +export const REQUEST_TIMEOUT_MS = 20_000; + +export const MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES = 25 * 1024 * 1024; + +export const MIN_SAFE_ISSUE_BODY_CHARS = 20; + +export const MAX_INLINE_ISSUE_COMMENT_BODY_CHARS = 1000; + +export const GITHUB_REST_PAGE_SIZE = 100; + +export const MAX_ISSUE_LIST_LIMIT = 1000; + +export const DEFAULT_STALE_CLOSE_INACTIVE_HOURS = 48; + +export const MAX_STALE_CLOSE_INACTIVE_HOURS = 24 * 365 * 10; + +export const ISSUE_LIFECYCLE_PREVIEW_LIMIT = 80; + +export const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL = "http://backend-core:8080/api/microservices/claudeqq/proxy"; + +export const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593"; + +export const CODE_QUEUE_BOARD_TARGET_ISSUE = 20; + +export const COMMANDER_BRIEF_TARGET_ISSUE = 24; + +export const MAX_PR_FILES_LIMIT = 3000; + +export const DEFAULT_BOARD_KNOWN_META_ISSUES = [CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDER_BRIEF_TARGET_ISSUE] as const; + +export const BOARD_AUDIT_REQUIRED_COLUMNS = ["branch", "acceptance", "relatedTask", "progress"] as const; + +export const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks", "focus"] as const; + +export const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const; + +export const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "closed", "closedAt", "comments", "commentCount", "number", "url", "author", "createdAt", "updatedAt"] as const; + +export const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "closed", "closedAt", "url", "updatedAt", "createdAt", "author", "labels"] as const; + +export const PR_LIST_JSON_FIELDS = [ + "body", + "title", + "state", + "number", + "url", + "author", + "head", + "base", + "draft", + "createdAt", + "updatedAt", + "closedAt", + "merged", + "mergedAt", + "mergeCommit", + "headRefName", + "baseRefName" +] as const; + +export const PR_READ_JSON_FIELDS = ["body", "title", "state", "stateDetail", "number", "url", "author", "head", "base", "draft", "createdAt", "updatedAt", "closed", "closedAt", "merged", "mergedAt", "mergeCommit", "headRefName", "baseRefName", "mergeable", "mergeStateStatus", "statusCheckRollup"] as const; + +export const PR_CLOSEOUT_JSON_FIELDS = ["mergeable", "mergeStateStatus", "statusCheckRollup"] as const; + +export const PR_CLOSEOUT_VIEW_JSON = "headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup"; + +export const ISSUE_LIST_STATES = ["open", "closed", "all"] as const; + +export const BODY_UPDATE_MODES = ["replace", "append"] as const; + +export const BOARD_MUTATION_SECTIONS = ["open", "closed"] as const; + +export const BOARD_GITHUB_STATUSES = ["OPEN", "CLOSED"] as const; + +export const GH_VALUE_OPTIONS = new Set([ + "--repo", "--limit", "--board-issue", "--known-meta-issue", "--ignore-issue", "--title", + "--body-file", "--body-patch-file", "--body", "--base", "--head", "--json", "--state", "--mode", + "--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", "--title-prefix", "--inactive-hours", "--comment", "--comment-file", "--description", + "--attachment", "--output", +]); + +export const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]); + +export const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS; + +export const ISSUE_SCAN_MAX_FINDINGS = 60; + +export const ISSUE_BODY_PROFILES = { + "code-queue-board": { + label: "Code Queue long board issue #20", + issueNumber: CODE_QUEUE_BOARD_TARGET_ISSUE, + requiredHeadings: ["## 看板(OPEN)"], + }, + "commander-brief": { + label: "Commander brief body-only issue (#24 legacy or daily rolling brief)", + legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE, + requiredHeadings: ["## 常驻观察与长期建议"], + }, +} as const; + +export const DAILY_COMMANDER_BRIEF_TITLE_PATTERN = /^\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)$/u; + +export const DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+\d{4}-\d{2}-\d{2}\s+指挥简报(北京时间)\s*$/u; + +export const LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN = /^#\s+指挥简报\s*$/u; + +export const COMMANDER_BRIEF_UPDATE_HEADING_PATTERNS = [ + /^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u, + /^##\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间指挥更新\s*$/u, + /^###\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+CST[::].*$/u, +] as const; + +export const UNIDESK_CLI_CONFIG_PATH = "config/unidesk-cli.yaml"; + +export type IssueViewJsonField = typeof ISSUE_VIEW_JSON_FIELDS[number]; + +export type IssueListJsonField = typeof ISSUE_LIST_JSON_FIELDS[number]; + +export type PrListJsonField = typeof PR_LIST_JSON_FIELDS[number]; + +export type PrReadJsonField = typeof PR_READ_JSON_FIELDS[number]; + +export type IssueListState = typeof ISSUE_LIST_STATES[number]; + +export type PrListState = typeof ISSUE_LIST_STATES[number]; + +export type BodyUpdateMode = typeof BODY_UPDATE_MODES[number]; + +export type PullRequestMergeMethod = "merge" | "squash" | "rebase"; + +export type RepoVisibility = "private" | "public"; + +export type BoardMutationSection = typeof BOARD_MUTATION_SECTIONS[number]; + +export type BoardGithubStatus = typeof BOARD_GITHUB_STATUSES[number]; + +export type IssueBodyProfileName = keyof typeof ISSUE_BODY_PROFILES; + +export type IssueBodyProfileOption = "auto" | IssueBodyProfileName; + +export type EscapeFindingClassification = "suspected-pollution" | "explanatory-mention" | "risk"; + +export type EscapeFindingSeverity = "low" | "medium" | "high"; + +export type EscapeBodyKind = "issue-body" | "comment-body"; + +export type CleanupSuggestionAction = "rewrite-issue-body-with-body-stdin" | "review-comment-manually" | "review-body-length" | "no-cleanup-needed"; + +export type BoardSectionKind = "open" | "closed"; + +export type BoardRequiredColumn = typeof BOARD_AUDIT_REQUIRED_COLUMNS[number]; + +export type BoardColumnKind = BoardRequiredColumn | "focus" | "githubStatus" | "category" | "summary"; + +export type BoardRowField = typeof BOARD_ROW_FIELDS[number]; + +export type BoardRowUpsertTextField = typeof BOARD_ROW_UPSERT_TEXT_FIELDS[number]; + +export type BoardRowUpsertField = BoardRowUpsertTextField | "status"; + +export type BoardIgnoreReason = "known-meta" | "ignored" | "brief-index-managed"; + +export type GitHubDegradedReason = + | "missing-binary" + | "missing-token" + | "auth-failed" + | "egress-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"; + +export type RunnerDisposition = "infra-blocked" | "business-failed"; + +export type ClaudeQqTargetType = "private" | "group"; + +export type CommanderBriefDiffMode = "identical" | "append-only" | "heading-diff" | "unreliable"; + +export interface PrMergeUnknownRetryConfig { + maxAttempts: number; + initialDelayMs: number; + maxDelayMs: number; + factor: number; +} + +export interface CommanderBriefDiff { + ok: boolean; + mode: CommanderBriefDiffMode; + message: string; + chars: number; + sections: string[]; + sectionCount: number; + skippedReason?: string; +} + +export interface ClaudeQqConfig { + enabled: boolean; + baseUrl: string; + targetType: ClaudeQqTargetType; + userId?: string; + groupId?: string; + timeoutMs: number; +} + +export interface ClaudeQqSendResult { + ok: boolean; + attempted: boolean; + skipped?: boolean; + skippedReason?: string; + endpoint?: string; + status?: number; + degradedReason?: string; + message?: string; + response?: unknown; + target: Record<string, unknown>; +} + +export interface ClaudeQqEndpointResult { + ok: boolean; + endpoint: string; + status?: number; + degradedReason?: string; + message?: string; + response?: unknown; +} + +export interface CommanderBriefSection { + heading: string; + text: string; + startLine: number; +} + +export interface EscapePatternDefinition { + kind: string; + pattern: RegExp; + description: string; +} + +export interface EscapeMatchFinding { + type: "issue" | "comment"; + bodyKind: EscapeBodyKind; + bodyId: string; + issueNumber: number; + issueId: number; + commentId?: number; + url: string; + kind: string; + classification: EscapeFindingClassification; + severity: EscapeFindingSeverity; + reason: string; + snippet: string; + lineNumber: number; + column: number; + match: string; + bodyChars: number | null; + bodyTrimmedChars: number | null; + cleanupPreview?: { + before: string; + after: string; + }; +} + +export interface EscapeCleanupSuggestion { + type: EscapeBodyKind; + bodyId: string; + issueNumber: number; + issueId: number; + commentId?: number; + url: string; + classification: EscapeFindingClassification; + action: CleanupSuggestionAction; + reason: string; + findings: Array<Pick<EscapeMatchFinding, "kind" | "classification" | "severity" | "lineNumber" | "column" | "snippet" | "match">>; + plannedCommand?: string; + bodyInputHint?: string; + preview?: { + before: string; + after: string; + }; +} + +export interface EscapeScanErrorFinding { + type: "comment-scan-error"; + bodyKind: EscapeBodyKind; + bodyId: string; + issueNumber: number; + issueId: number; + url: string; + degradedReason: GitHubDegradedReason; + runnerDisposition: RunnerDisposition; + details: GitHubErrorPayload; +} + +export type EscapeScanEntry = EscapeMatchFinding | EscapeScanErrorFinding; + +export interface BoardTableRow { + section: BoardSectionKind; + lineNumber: number; + raw: string; + cells: string[]; + issueNumbers: number[]; + issueNumber: number | null; + title: string | null; + columns: Partial<Record<BoardRequiredColumn, string>>; +} + +export interface BoardTableSection { + kind: BoardSectionKind; + heading: string; + headingLine: number; + headerLine: number; + headers: string[]; + rows: BoardTableRow[]; +} + +export interface BoardIssueEntry { + number: number; + title: string; + state: string; + url: string; +} + +export interface BoardIgnoredIssue { + number: number; + reason: BoardIgnoreReason; + title?: string; + state?: string; + url?: string; +} + +export interface BoardRowValidationWarning { + issueNumber: number | null; + section: BoardSectionKind; + lineNumber: number; + kind: string; + message: string; + missingColumns?: BoardRequiredColumn[]; + columns?: Partial<Record<BoardRequiredColumn, string>>; + rowPreview: string; +} + +export interface BoardRowLookup { + ok: true; + section: BoardTableSection; + row: BoardTableRow; + matches: Array<{ section: BoardTableSection; row: BoardTableRow }>; +} + +export interface BoardRowUpdatePlanResult { + ok: true; + plan: Record<string, unknown>; + newBody: string; +} + +export interface BoardRowMutationPlanResult { + ok: true; + plan: Record<string, unknown>; + newBody: string; +} + +export interface BoardRowUpsertValues { + category?: string; + branch?: string; + tasks?: string; + summary?: string; + focus?: string; + validation?: string; + progress?: string; + status?: BoardGithubStatus; +} + +export interface GitHubCommandResult { + ok: boolean; + repo: string; + command: string; + degradedReason?: GitHubDegradedReason; + degraded?: GitHubDegradedReason[]; + runnerDisposition?: RunnerDisposition; + [key: string]: unknown; +} + +export type GitHubCommandFailure = GitHubCommandResult & { ok: false }; + +export interface GitHubTokenProbe { + present: boolean; + source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null; + ghFallbackAttempted: boolean; + ghBinaryFound?: boolean; + ghAuthTokenAvailable?: boolean | null; +} + +export interface GitHubOptions { + repo: string; + dryRun: boolean; + raw: boolean; + full: boolean; + limit: number; + inactiveHours: number; + boardIssue: number; + knownMetaIssues: number[]; + ignoredIssues: number[]; + draft: boolean; + notifyClaudeQqBriefDiff: boolean; + allowShortBody: boolean; + labels: string[]; + search?: string; + titlePrefix?: string; + title?: string; + body?: string; + bodyFile?: string; + bodyPatchFile?: string; + comment?: string; + commentFile?: string; + repoDescription?: string; + repoVisibility?: RepoVisibility; + repoAutoInit: boolean; + base?: string; + head?: string; + jsonFields?: IssueViewJsonField[]; + issueListJsonFields?: IssueListJsonField[]; + prListJsonFields?: PrListJsonField[]; + prJsonFields?: PrReadJsonField[]; + listState: IssueListState; + prListState: PrListState; + mode: BodyUpdateMode; + expectUpdatedAt?: string; + expectBodySha?: string; + bodyProfile: IssueBodyProfileOption; + boardRowField?: BoardRowField; + boardRowValue?: string; + boardRowFile?: string; + boardSection?: BoardMutationSection; + boardMoveTo?: BoardMutationSection; + boardGithubStatus?: BoardGithubStatus; + boardRowUpsertValues: BoardRowUpsertValues; + mergeMethod: PullRequestMergeMethod; + deleteBranch: boolean; + attachmentSelector?: string; + outputPath?: string; +} + +export interface GitHubShorthandReference { + input: string; + repo: string; + number: number; + source?: "owner-repo-number" | "github-url" | "number-option"; + urlKind?: "issue" | "pr"; + standardCommand?: string; +} + +export interface GitHubResolvedNumberReference { + repo: string; + number: number; + shorthand?: GitHubShorthandReference; +} + +export interface IssueProfileValidationContext { + issueTitle?: string | null; + issueBody?: string | null; + issueMetadataFetched?: boolean; +} + +export interface GitHubIssueAttachment { + index: number; + url: string; + assetId: string | null; + source: "issue-body" | "comment"; + sourceUrl: string; + commentId?: number; + author?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + marker: "markdown-image" | "html-img" | "plain-url"; + alt?: string | null; + width?: string | null; + height?: string | null; +} + +export interface GitHubAttachmentDownload { + buffer: Buffer; + contentType: string | null; + contentLengthHeader: string | null; + finalUrl: string; +} + +export interface GitHubErrorPayload { + ok: false; + degradedReason: GitHubDegradedReason; + runnerDisposition: RunnerDisposition; + status?: number; + message: string; + retryable?: boolean; + commanderAction?: string; + details?: unknown; + scopes?: { + accepted: string | null; + token: string | null; + }; + request?: { + method: string; + path: string; + }; +} + +export interface GitHubIssue { + id: number; + number: number; + title: string; + body: string | null; + state: string; + 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; + closed_at?: string | null; +} + +export interface GitHubComment { + id: number; + body: string | null; + html_url: string; + user?: { login?: string }; + created_at?: string; + updated_at?: string; +} + +export interface GitHubIssueSearchResponse { + total_count?: number; + incomplete_results?: boolean; + items?: GitHubIssue[]; +} + +export interface GitHubIssueListPage { + path: string; + rawCount: number; + issueCount: number; +} + +export interface GitHubIssueListResult { + items: GitHubIssue[]; + rawCount: number; + fetchedPages: number; + pageSize: number; + exhausted: boolean; + hasMore: boolean; + pages: GitHubIssueListPage[]; + searchTotalCount?: number; + searchIncomplete?: boolean; +} + +export interface GitHubPullRequest { + id: number; + number: number; + title: string; + body: string | null; + state: string; + html_url: string; + draft?: boolean; + user?: { login?: string }; + head?: { ref?: string; sha?: string; repo?: { full_name?: string | null } | null }; + base?: { ref?: string; sha?: string }; + additions?: number; + deletions?: number; + changed_files?: number; + commits?: number; + closed_at?: string | null; + merged?: boolean | null; + merged_at?: string | null; + merge_commit_sha?: string | null; + mergeable?: string | null; + merge_state_status?: string | null; + created_at?: string; + updated_at?: string; +} + +export interface GitHubPullRequestFile { + sha?: string; + filename: string; + status?: string; + additions?: number; + deletions?: number; + changes?: number; + blob_url?: string; + raw_url?: string; + contents_url?: string; + previous_filename?: string; + patch?: string; +} + +export interface GitHubPullRequestGraphqlStatusContext { + __typename?: string; + name?: string | null; + status?: string | null; + conclusion?: string | null; + context?: string | null; + state?: string | null; + targetUrl?: string | null; + description?: string | null; +} + +export interface GitHubPullRequestGraphqlStatusCheckRollup { + state?: string | null; + contexts?: { + nodes?: GitHubPullRequestGraphqlStatusContext[] | null; + } | null; +} + +export interface GitHubPullRequestGraphqlMetadata { + mergeable?: string | null; + mergeStateStatus?: string | null; + headRefName?: string | null; + baseRefName?: string | null; + statusCheckRollup?: GitHubPullRequestGraphqlStatusCheckRollup | null; +} + +export interface GitHubRepository { + id?: number; + name?: string; + full_name?: string; + private?: boolean; + html_url?: string; + clone_url?: string; + ssh_url?: string; + description?: string | null; + default_branch?: string; + permissions?: Record<string, boolean>; + owner?: { login?: string }; + created_at?: string; + updated_at?: string; +} + +export interface GitHubAuthenticatedUser { + login?: string; + id?: number; +} + +export interface GitHubBranch { + name?: string; + commit?: { sha?: string }; +} + +export interface GitHubCompare { + status?: string; + ahead_by?: number; + behind_by?: number; + total_commits?: number; + html_url?: string; + base_commit?: { sha?: string }; + merge_base_commit?: { sha?: string }; +}