diff --git a/.github/workflows/ci-cli.yml b/.github/workflows/ci-cli.yml new file mode 100644 index 00000000..2e03888d --- /dev/null +++ b/.github/workflows/ci-cli.yml @@ -0,0 +1,46 @@ +name: CLI + +# meebox CLI(cli/,独立 Go module)的门禁,与 Node/Nx 的 CI 分开: +# 路径过滤只能加在 workflow 的 on 层(不能按 job 过滤),故独立成一条流水线——仅当 cli/ 变更时才跑, +# 既隔离 Go 工具链、又省 CI 分钟。发布期的交叉编译 / 出包见 release.yml 的 cli job。 +on: + push: + branches: [master] + paths: + - 'cli/**' + - '.github/workflows/ci-cli.yml' + pull_request: + paths: + - 'cli/**' + - '.github/workflows/ci-cli.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + cli: + name: Vet + Test + Build + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: cli + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go build + run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0529500b..22d96cd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ concurrency: cancel-in-progress: false jobs: - build: + gui: strategy: fail-fast: false matrix: @@ -137,3 +137,67 @@ jobs: make_latest: ${{ !contains(github.ref_name, '-') }} # 正文 = RELEASE_NOTES(安装 / 首次打开 / 校验和)+ 注入的本版 CHANGELOG 段 body_path: RELEASE_BODY.md + + # meebox CLI(cli/,独立 Go module):纯 Go、无 CGO → 单 runner 交叉编译全平台。出四平台压缩包 + + # 校验和,随桌面安装包挂到同一个 GitHub Release(不打进安装包,是独立可分发物)。仅 tag 触发上传; + # workflow_dispatch 仍构建做编译冒烟、不上传。本 job 不设 Release 正文(由 build job 注入),仅追加产物。 + cli: + name: CLI (${{ matrix.goos }}/${{ matrix.goarch }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { goos: windows, goarch: amd64, ext: '.exe', archive: zip } + - { goos: darwin, goarch: arm64, ext: '', archive: zip } + - { goos: linux, goarch: amd64, ext: '', archive: tar.gz } + - { goos: linux, goarch: arm64, ext: '', archive: tar.gz } + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: 交叉编译 meebox CLI + shell: bash + working-directory: cli + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + run: | + VERSION="${GITHUB_REF_NAME#v}" # tag 去 v 前缀;非 tag(dispatch)取 ref 名,仅用于编译冒烟 + mkdir -p dist + go build -trimpath \ + -ldflags "-s -w -X github.com/huhamhire/code-meeseeks/cli/cmd.version=${VERSION}" \ + -o "dist/meebox${{ matrix.ext }}" . + + - name: 打包压缩包 + 校验和 + if: startsWith(github.ref, 'refs/tags/') + shell: bash + working-directory: cli/dist + run: | + VERSION="${GITHUB_REF_NAME#v}" + BIN="meebox${{ matrix.ext }}" + ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}" + cp ../../LICENSE . + if [ "${{ matrix.archive }}" = "zip" ]; then + zip -q "${ARCHIVE}.zip" "${BIN}" LICENSE + else + tar -czf "${ARCHIVE}.tar.gz" "${BIN}" LICENSE + fi + for f in "${ARCHIVE}".zip "${ARCHIVE}".tar.gz; do + [ -e "$f" ] && sha256sum "$f" > "$f.sha256" + done + + - name: 上传到 Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + # 本 matrix 项仅产出自身的压缩包 + .sha256(fresh runner);不设 body_path 以免覆盖 build job 注入的正文 + files: cli/dist/meebox-cli-* + fail_on_unmatched_files: true + prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: ${{ !contains(github.ref_name, '-') }} diff --git a/AGENTS.md b/AGENTS.md index db4339ea..37d6b1e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,25 +4,26 @@ ## 仓库结构 -Electron 桌面应用 + npm workspaces + Nx 单仓多包。关键路径(`apps/desktop` 是主战场): +Electron 桌面应用(npm workspaces + Nx 单仓多包)外加一个独立 Go CLI 子工程。关键路径(`apps/desktop` 是主战场): ``` -apps/desktop/ +apps/desktop/ # Electron 应用(唯一有 build/dist 的项目) ├── src/ -│ ├── main/ # 主进程:index.ts(启动/单例锁) · ipc.ts(IPC handlers) · adapters.ts · utils/ +│ ├── main/ # 主进程:业务与 IO 唯一所在(启动/单例锁 · IPC handlers · 平台适配 · 服务层:pr · agent 编排 · 本地 API) │ ├── preload/ # contextBridge 暴露泛型 invoke() -│ └── renderer/src/ # React 渲染层(components/ 等) -├── scripts/ # assemble-pragent-runtime.mjs · pragent-shim/(shim) · pragent-runtime.json -├── build-resources/ # after-pack.cjs(ad-hoc 签名) · entitlements.mac.plist -├── electron-builder.yml -└── vendor/pragent/ # 嵌入式运行时(gitignored,由 prepare:pragent 生成) +│ └── renderer/src/ # React 渲染层(UI / 交互) +├── scripts/ # 嵌入式 pr-agent 运行时组装 + monkeypatch shim +├── build-resources/ # electron-builder 打包资源(签名钩子 · entitlements) +└── vendor/pragent/ # 嵌入式运行时(gitignored,prepare:pragent 生成) -packages//src/index.ts # 各库入口;shared 还含 ipc.ts(IPC 契约) · config.ts · poller-contract.ts +packages// # 内部库 @meebox/*(各 src/index.ts 为入口);shared 含共享类型 + IPC 契约 +cli/ # 独立 Go module:跨平台 CLI meebox(命令树 + HTTP client;经本地 API 集成,不入 npm/Nx) ``` - `apps/desktop` —— Electron 应用(main + preload + renderer/React)。唯一有 `build`/`dist` 的项目。 - `packages/*` —— 内部库(`@meebox/*`),按职责拆分;其中 `shared` 含共享类型与 IPC 契约。 -- `docs/arch/` 各模块设计文档(首选入口);`docs/ROADMAP.md` 路线图;`tools/` 杂项脚本。 +- `cli/` —— 独立分发的 Go 命令行工具 `meebox`(外部集成用,不属 npm/Nx;详见下「CLI 工程(cli/)」段)。 +- `docs/arch/` 各模块设计文档(首选入口);`docs/guide/` 使用说明;`docs/ROADMAP.md` 路线图;`tools/` 杂项脚本。 **命名约定**:代码内部统一用中性代号 `meebox`(npm 作用域 `@meebox/*`);对外品牌名 `Code Meeseeks`;用户数据目录 `~/.code-meeseeks/`。`pr-agent` 为第三方依赖,不在重命名范围内。 @@ -67,17 +68,19 @@ npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运 ## 发布流程 -发版从 `dev` 汇入 `master` 后,在 `master` 打 `v*` tag 触发 [release.yml](.github/workflows/release.yml)(出 Windows / macOS 安装包 + GitHub Release)。**打 tag 前必须在同一批改动里完成三步前置,且随发版改动一并经 `dev` → `master`**——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release: +发版:`dev` 汇入 `master` → 在 `master` 打 `v*` tag 触发 [release.yml](.github/workflows/release.yml)(出 Windows / macOS 安装包 + CLI 二进制 + GitHub Release)。 -1. **版本号** —— 把 [apps/desktop/package.json](apps/desktop/package.json) 的 `version` 改成目标版本(去 `v` 前缀,预发布带后缀,如 `0.5.0-alpha.1`)。electron-builder 的 `artifactName: code-meeseeks-${version}-...` 直接取此值——不改则安装包文件名缺 `-alpha.N`、与 tag 不符。改完跑一次 `npm install` 同步 lockfile。 -2. **CHANGELOG** —— 把 [CHANGELOG.md](CHANGELOG.md) 的 `## [Unreleased]` 改名为 `## [<版本>] - `,并在文件底部补 `[<版本>]: …/compare/…` 链接引用(仿现有行)。**发布即移除 Unreleased、不再另起空段**——`## [Unreleased]` 仅**开发期**存在以累积变更,发布时被改名消费掉;下一笔开发期 changelog 改动时再新建一个 `## [Unreleased]`(见「版本号规则」的 `-dev` 开发态)。release.yml 按 `## [<版本>]` 字面抽段注入 Release 正文的「版本变更」区——缺这段则正文回退、无任何变更说明。**若本次是正式版(无 `-` 后缀)、且其内容来自此前的 alpha/预发布**:此时开发期通常无独立 Unreleased(内容已在预发布段),直接把该预发布段改名为正式版段、删去对应的 `[-alpha.N]:` 链接引用即可(内容并入正式版段,不再保留空壳 stub);尚无对应正式版的其它预发布段保留。 -3. **校对** —— 确认 `## [<版本>]` 段已覆盖自上版本以来合入 `dev` 的全部要点(新增 / 变更 / 修复)。 +⚠️ **打 tag 前必须在同一批改动里完成三步前置(版本号 / CHANGELOG / 校对),随发版一并经 `dev` → `master`**——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release。**完整前置清单、`-dev` 版本号规则、CHANGELOG 撰写风格见 [打包与发布](docs/development/packaging-release.md)**。tag 名须等于 package.json 版本(`v<版本>`);名含 `-` 的预发布 tag 自动标 prerelease、不抢占 Latest。 -tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag(名含 `-`,如 `-alpha.N`)由 release.yml 自动标 prerelease 且不抢占 Latest。 +## CLI 工程(cli/) -**版本号规则**:每次正式发版后,`dev` 立即把 [apps/desktop/package.json](apps/desktop/package.json) 切到**下一版的 `-dev` 预发布号**(如发完 `0.6.0` 即切 `0.7.0-dev`,并 `npm install` 同步 lockfile),标记开发态、与正式版区分。`-dev` 仅作开发标记——**不打 tag、不发版**;发版时按上面三步前置把它改成目标号(预发布 `0.7.0-alpha.N` 或正式 `0.7.0`)。`-dev` 是合法 semver(`0.6.0` < `0.7.0-dev` < `0.7.0`),不影响更新检测([update-check.ts](apps/desktop/src/main/utils/update-check.ts) 用 `semver.gt` 比对、不用 range,故无「预发布不满足范围」陷阱)与构建。 +`cli/` 是独立分发的跨平台命令行客户端 `meebox`(供外部 agent / 脚本经[本地 API 服务](docs/arch/04-integration/01-service-api.md)集成)。设计见 [docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md),用法见 [docs/guide/06-cli.md](docs/guide/06-cli.md)。 -**CHANGELOG 撰写风格**(面向用户、求简):① 版本引言 `>` 区直接进入「本版重点」、要点用**无序列表**排版,不堆成长句,**不写「首个 / 第 N 个正式版」之类的版本序数引言**;② 新增 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止;③ 重构类任务**前后端合并**为一条总结、不展开实现细节;④ 修复 **不写「怎么修的」机制**,每条一句话只述修复的现象/影响;⑤ 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良;⑥ **安装 / 升级注意事项**(版本引言里的 ⚠️ 警示,如先卸载旧版、per-machine 提权等)属安全关键信息,**保留完整、不参与精简**——这些会随 release.yml 注入 GitHub Release 正文,删减会让用户漏看升级风险;⑦ **分段标题用中文 + emoji**:`### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全`(对应 Keep a Changelog 的 Added / Changed / Deprecated / Removed / Fixed / Security)。外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 +- **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。 +- **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。 +- **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本经 `-ldflags -X …/cmd.version` 注入、与应用同 tag。 +- **只读边界**:CLI 只做浏览与评审操作,**不提供评论发送等写操作**;写工具(approve/needswork/publish)在 CLI 与服务端双重硬拒绝。新增命令先确认对应 API 端点已存在且只读——CLI 不得绕过 API 直连应用内部。 +- **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向),`--output json` 供机器;配置走环境变量 / flag / `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 隔离),代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 ## 约定 @@ -96,7 +99,11 @@ tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag( ## 国际化 (i18n) -GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**,无源/译层级;UI 语言由 `config.language` 经 `resolveLanguage` 决定,空则按 OS 回落英语)。**默认 / 兜底语言取 `en-US`**(国际化标准:缺 key 回退英文而非中文):渲染层 en-US 静态打包进入口 + 其余懒加载、`fallbackLng: 'en-US'`,主进程各持一份 locale、同样兜底 en-US。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩的:①新增文本须在**各语言 locale 都加**并保持**递归字典序**(日语复数同中文仅 `_other`、德语同英语需 `_one`/`_other`);②i18next **只有 `count`** 触发复数,普通计数插值要换别的变量名;③**不要开 `nonExplicitSupportedLngs`**——它把 `zh-CN` 按基码 `zh` 查找、与按 `zh-CN` 注册的 bundle 错位 → 整页裸 key。 +GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**;**默认 / 兜底 `en-US`**,缺 key 回退英文)。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩(详见该篇): + +1. 新增文本各语言 locale 都加且保持**递归字典序**; +2. 复数只认 `count`(普通计数插值换别名); +3. 勿开 `nonExplicitSupportedLngs`(按基码 `zh` 错位 → 整页裸 key)。 ## 文档约定 @@ -107,14 +114,12 @@ GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / ` ## 工程维护坑 -- **新增内部 `@meebox/*` 包必做两步登记**(漏则报 `Cannot find module …/src/.js`):内部包源码是 `.ts`、相对 import 带 `.js` 扩展(NodeNext 约定),Node 运行期不能直接读。新建一个被 desktop 主/preload 引用的内部包后,除 `npm install`(建 workspace 软链)外,**必须**:① 在 `apps/desktop/package.json` 依赖加 `"@meebox/": "*"`;② 在 [apps/desktop/electron.vite.config.ts](apps/desktop/electron.vite.config.ts) 的 `internalPackages` 数组加该名——让 electron-vite 把它 **bundle**(转译 TS、解析 `.js`→`.ts`)而非 externalize。漏 ② 时 Node 把它当外部包按 `main: src/index.ts` 加载,撞到 `export … from './x.js'` 而文件是 `.ts` → 运行期崩。 -- **嵌入式 pr-agent 运行时**(见 [docs/arch/02-agent/03-pragent-runtime](docs/arch/02-agent/05-pragent-runtime.md)):`apps/desktop/scripts/assemble-pragent-runtime.mjs` 按 `pragent-runtime.json` 把可重定位 CPython + pinned pr-agent 装到 `apps/desktop/vendor/pragent/`(gitignored)。 -- **monkeypatch shim** `apps/desktop/scripts/pragent-shim/`(薄加载器 `sitecustomize.py` + 领域拆分包 `meebox_pragent_shim/`:`patches/` 各 patch + `cli/` 本地 CLI provider + `runtime.py`/`usage.py`。对 pr-agent 的无侵入补丁): - - 改了它,跑一次 `npm --prefix apps/desktop run prepare:pragent` 即重新同步进 vendor(幂等跳过分支也会同步 shim),**无需 `--force` 全量重建**。 - - 受版本守卫:`meebox_pragent_shim/runtime.py` 的 `_EXPECTED_PRAGENT_VERSION` 必须等于 `pragent-runtime.json` 的 `prAgent.version`(assemble 构建期强校验,运行期不符则跳过补丁 + stderr WARNING)。升级 pr-agent 要同步两处并重新验证。 - - **拆分铁律**:各 patch 对 `pr_agent` 的 import 一律放在 patch 函数体内(惰性);模块顶层只 import 同包内的 runtime/usage 等,**绝不在顶层 import pr_agent**(否则 sitecustomize 阶段即 eager 加载,拖慢每次 python 启动)。 - - 调试:`MEEBOX_SHIM_DEBUG=1` 让 shim 打 stderr 调试。 +- **新增内部 `@meebox/*` 包必做两步登记**(漏则报 `Cannot find module …/src/.js`):内部包源码是 `.ts`、相对 import 带 `.js` 扩展(NodeNext 约定),Node 运行期不能直接读。新建一个被 desktop 主/preload 引用的内部包后,除 `npm install`(建 workspace 软链)外**必须**: + 1. 在 `apps/desktop/package.json` 依赖加 `"@meebox/": "*"`; + 2. 在 [apps/desktop/electron.vite.config.ts](apps/desktop/electron.vite.config.ts) 的 `internalPackages` 数组加该名——让 electron-vite 把它 **bundle**(转译 TS、解析 `.js`→`.ts`)而非 externalize。 + + 漏第 2 步时 Node 把它当外部包按 `main: src/index.ts` 加载,撞到 `export … from './x.js'` 而文件是 `.ts` → 运行期崩。 +- **pr-agent 运行时 / shim**:嵌入式 CPython + pinned pr-agent 由 `assemble-pragent-runtime.mjs` 装到 `vendor/pragent`(gitignored);对 pr-agent 的无侵入补丁在 `scripts/pragent-shim/`。机制与铁律(惰性 import 拆分 · 版本守卫 · 改后跑 `prepare:pragent` 同步 · 调试 `MEEBOX_SHIM_DEBUG=1`)见 [02-agent/05-pragent-runtime](docs/arch/02-agent/05-pragent-runtime.md)。弱网 pip 超时加 `PIP_DEFAULT_TIMEOUT=120`。 - **二进制资源走 Git LFS**(`*.png/.ico/.icns` 等):本地没装 git-lfs 时拿到的是指针文件,electron-builder 转图标会崩 → `brew install git-lfs && git lfs pull`。 - **dev 起不来**:若 `npm run dev` 报 `electron does not provide an export named …`,是环境里有 `ELECTRON_RUN_AS_NODE=1`(VSCode 扩展宿主会注入)→ `unset ELECTRON_RUN_AS_NODE` 再跑。 - **grep 个别文件无输出**:如 `repo-mirror-manager.ts` 被 `file` 判为 `data`(含非 UTF-8 字节),普通 grep 静默 → 用 `grep -a`。 -- **prepare:pragent 网络**:pip 默认 15s 超时,弱网下加 `PIP_DEFAULT_TIMEOUT=120` 或配国内镜像(`~/.config/pip/pip.conf`)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fac538..86aad51d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +> 本版重点: +> +> - **外部集成与 CLI**:开启本机 API 把 PR 浏览与评审 Agent 操作开放给外部集成,并提供跨平台命令行工具 `meebox` + +### ✨ 新增 + +- **外部集成 · 本地 API 服务**:设置新增「集成」分区,可开启一个本机 API 服务,将 PR 浏览与评审 Agent 操作以接口形式开放给外部 agent / 工具 / 脚本集成。 + - 默认关闭;开启即强制访问令牌鉴权,令牌可一键生成 / 显示 / 复制 / 重新生成。 + - 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。 + - 仅开放浏览与评审操作(PR 列表 / 详情 / diff / 动态 / 提交 / 评审人审批,以及评审 Agent 的状态 / 历史 / 自动评审 / 指令 / 对话),不提供评论发送等写操作。 +- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR 与操作评审 Agent,便于脚本与外部 agent 集成;与本地 API 一致,只读取向、不含写操作。 + ## [0.8.0] - 2026-06-30 > 本版重点: @@ -371,6 +385,7 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...HEAD [0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.5.0...v0.6.0 diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts index fd400a97..a748c3fd 100644 --- a/apps/desktop/src/main/controllers/config.ts +++ b/apps/desktop/src/main/controllers/config.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { nativeTheme } from 'electron'; import { editorThemeNativeSource } from '@meebox/shared'; import { writeConfig } from '@meebox/config'; @@ -187,6 +188,36 @@ export const testConnection: IpcController<'config:testConnection'> = async (_ev } }; +/** + * 写本地 API 服务监听配置(开关 / host / port / token);内存同步后热重建监听器(停旧起新)。 + * token 由请求体携带(设置页保存当前值);单独「重新生成 token」走 generateServiceToken。 + */ +export const setService: IpcController<'config:setService'> = async (_event, req) => { + const { bootstrap, logger, reconfigureApiServer } = getContext(); + const next = { ...bootstrap.config, service: req.service }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.service = req.service; + await reconfigureApiServer(); + logger.info( + { enabled: req.service.enabled, host: req.service.host, port: req.service.port }, + 'service listener config updated (hot-reloaded)', + ); +}; + +/** + * 重新生成 bearer token(高强度随机),写盘 + 内存同步。监听器每次请求实时读内存 token,故新 token + * 即时生效、旧 token 立刻失效,无需重启监听器。返回新 token 供设置页展示 / 复制。 + */ +export const generateServiceToken: IpcController<'config:generateServiceToken'> = async () => { + const { bootstrap, logger } = getContext(); + const token = randomBytes(32).toString('base64url'); + const service = { ...bootstrap.config.service, token }; + await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, service }); + bootstrap.config.service = service; + logger.info('service listener token regenerated'); + return { token }; +}; + /** * 配置过程中把连接 + LLM 草稿写盘防丢失,但不更新内存 config、不 reconfigure(不生效)。 */ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index cbb9c078..4d140825 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -21,6 +21,7 @@ import { } from './bootstrap/index.js'; import { initMainI18n } from './i18n/index.js'; import { registerIpcHandlers } from './ipc.js'; +import { ApiServer } from './services/api-server/index.js'; import { readConnectionStates } from './utils/connection-state.js'; // 进程(模块加载)起点:用于度量到主窗口首帧(ready-to-show)的启动耗时。 @@ -53,6 +54,8 @@ class App { private conns!: ConnectionRuntimeController; private windowManager!: WindowManager; private ipcControl?: IpcControl; + /** 本地 API 服务监听器(默认关闭;按 config.service 决定是否 listen)。 */ + private apiServer?: ApiServer; private quitCleanupDone = false; constructor(private readonly startMs: number) {} @@ -210,7 +213,15 @@ class App { connectionRuntime: this.conns.runtime, reconfigureConnections: () => this.conns.reconfigure(), repoMirror: this.repoMirror, + // 惰性引用:ApiServer 在 registerIpcHandlers 之后才构造(其请求处理依赖此刻才安装的 + // ControllerContext 单例);闭包在 config:setService 调用时才取最新实例。 + reconfigureApiServer: () => this.apiServer?.reconfigure() ?? Promise.resolve(), }); + + // 本地 API 服务监听器:ControllerContext 已由 registerIpcHandlers 安装,可安全处理请求。 + // 按 config.service 决定是否实际 listen(默认关闭);监听失败为非致命(内部已兜底记录)。 + this.apiServer = new ApiServer({ bootstrap: this.bootstrap, logger: this.logger }); + await this.apiServer.start(); } /** @@ -293,6 +304,8 @@ class App { // 不清理会留孤儿进程锁住安装目录 → 升级时 NSIS 报「应用无法关闭」。 app.on('before-quit', (event) => { if (this.poller) this.poller.stop(); + // 停本地 API 监听(停止接收新连接);fire-and-forget,关闭很快、不阻塞退出。 + void this.apiServer?.stop(); if (this.quitCleanupDone) return; const aborted = this.ipcControl?.abortAllActiveRuns() ?? 0; if (aborted === 0) return; // 无进行中 run,直接退出 diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index c86338f1..9aaac7c9 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -114,6 +114,8 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('config:autosaveDraft', config.autosaveDraft); // 连接 / LLM 草稿存盘(不生效) ipcMain.handle('config:setPoller', config.setPoller); // 设轮询间隔(热替换定时器) ipcMain.handle('config:setMaxConcurrency', config.setMaxConcurrency); // 设评审并发数(热替换队列上限) + ipcMain.handle('config:setService', config.setService); // 设本地 API 服务监听(热重建监听器) + ipcMain.handle('config:generateServiceToken', config.generateServiceToken); // 重新生成 API bearer token /* * Agent 交互 diff --git a/apps/desktop/src/main/services/api-server/http.ts b/apps/desktop/src/main/services/api-server/http.ts new file mode 100644 index 00000000..c40737bb --- /dev/null +++ b/apps/desktop/src/main/services/api-server/http.ts @@ -0,0 +1,84 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { AppError, ERROR_CODES, type AppErrorMeta, type ErrorCode } from '@meebox/shared'; + +/** + * 本地 API 的 HTTP 工具:统一响应封套({ ok, data } / { ok:false, error })、请求体读取、 + * 错误 → HTTP 状态码映射。见 docs/arch/04-integration/01-service-api.md。 + */ + +const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB 请求体上限 + +/** API 层错误(鉴权 / 路由 / 校验 / 写禁止等):自带 HTTP 状态码与错误码。 */ +export class HttpError extends Error { + constructor( + readonly status: number, + readonly code: ErrorCode, + readonly meta?: AppErrorMeta, + ) { + super(code); + this.name = 'HttpError'; + } +} + +function writeJson(res: ServerResponse, status: number, payload: unknown): void { + const body = JSON.stringify(payload); + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(body); +} + +/** 成功响应:200 + { ok:true, data }。 */ +export function sendOk(res: ServerResponse, data: unknown): void { + writeJson(res, 200, { ok: true, data: data ?? null }); +} + +/** 失败响应:按错误映射状态码 + { ok:false, error:{ code, meta } };返回所选状态码 / 码供日志。 */ +export function sendError(res: ServerResponse, err: unknown): { status: number; code: string } { + const mapped = mapError(err); + writeJson(res, mapped.status, { + ok: false, + error: { code: mapped.code, ...(mapped.meta ? { meta: mapped.meta } : {}) }, + }); + return { status: mapped.status, code: mapped.code }; +} + +function mapError(err: unknown): { status: number; code: ErrorCode; meta?: AppErrorMeta } { + if (err instanceof HttpError) return { status: err.status, code: err.code, meta: err.meta }; + if (err instanceof AppError) return { status: statusForAppCode(err.code), code: err.code, meta: err.meta }; + return { status: 500, code: ERROR_CODES.SV_UNCLASSIFIED }; +} + +/** 把控制器抛出的 AppError 业务码映射到合适的 HTTP 状态码(未覆盖者归 500)。 */ +function statusForAppCode(code: ErrorCode): number { + switch (code) { + case ERROR_CODES.PR_NOT_FOUND: + return 404; + case ERROR_CODES.PR_FORBIDDEN: + return 403; + case ERROR_CODES.PR_URL_INVALID: + case ERROR_CODES.AG_ASK_NEEDS_QUESTION: + return 400; + case ERROR_CODES.PR_NO_ACTIVE_CONNECTION: + return 409; + case ERROR_CODES.AG_PR_AGENT_NOT_READY: + return 503; + default: + return 500; + } +} + +/** 读取并解析 JSON 请求体(空体 → undefined);超限 413、非法 JSON 400,均归一为 SV 错误码。 */ +export async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + total += (chunk as Buffer).length; + if (total > MAX_BODY_BYTES) throw new HttpError(413, ERROR_CODES.SV_BAD_REQUEST, { reason: 'body too large' }); + chunks.push(chunk as Buffer); + } + if (total === 0) return undefined; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'invalid json' }); + } +} diff --git a/apps/desktop/src/main/services/api-server/index.ts b/apps/desktop/src/main/services/api-server/index.ts new file mode 100644 index 00000000..fe586550 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/index.ts @@ -0,0 +1 @@ +export { ApiServer, type ApiServerDeps } from './server.js'; diff --git a/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts new file mode 100644 index 00000000..1a47f81b --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes.ts @@ -0,0 +1,168 @@ +import type { IpcMainInvokeEvent } from 'electron'; +import type { DiffSide } from '@meebox/ipc'; +import { + ERROR_CODES, + PR_SECONDARY_FILTERS, + filterPullRequests, + type PrDiscoveryFilter, + type PrSecondaryFilter, + type ReviewRunTool, +} from '@meebox/shared'; +import * as agentCtl from '../../controllers/agent.js'; +import * as prCtl from '../../controllers/pr.js'; +import { getContext } from '../context.js'; +import { HttpError } from './http.js'; + +/** + * 本地 API 的路由表与处理器。处理器**复用 IPC controller 同源逻辑**——controller 形态为 + * `(event, req)` 且只读路径不触碰 event,故以 NO_EVENT 占位调用,避免在 HTTP 侧另起一套实现。 + * 只读边界:写工具一律不暴露(见 agent/instruct)。见 docs/arch/04-integration/01-service-api.md。 + */ + +// controller 形参 event 在被复用的只读 / 队列路径中均未使用,占位即可。 +const NO_EVENT = undefined as unknown as IpcMainInvokeEvent; + +/** API 仅允许的只读 Agent 指令(与工具注册表 isRun 只读族一致;写工具不在此列)。 */ +const READ_ONLY_TOOLS: ReadonlySet = new Set([ + 'describe', + 'review', + 'ask', + 'improve', +]); + +export interface RouteContext { + params: Record; + query: URLSearchParams; + body: unknown; +} + +export type RouteHandler = (rc: RouteContext) => Promise | unknown; + +export interface Route { + method: 'GET' | 'POST'; + segments: string[]; + handler: RouteHandler; +} + +function seg(path: string): string[] { + return path.split('/').filter(Boolean); +} + +/** 当前启用平台下可用的分类标签:一级(平台发现分类)+ 二级(状态 / 合并态筛选)。 */ +const categories: RouteHandler = () => { + const ctx = getContext(); + const activeId = ctx.bootstrap.config.active_connection_id; + const built = activeId + ? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId) + : undefined; + const caps = built?.adapter.connection.capabilities(); + const primary: PrDiscoveryFilter[] = caps?.discoveryFilters + ? [...caps.discoveryFilters] + : ['review-requested']; + return { + platform: built?.adapter.kind ?? null, + primary, + secondary: [...PR_SECONDARY_FILTERS], + }; +}; + +/** + * PR 列表(不分页)+ 一级 / 二级分类过滤 + 检索。过滤语义复用 @meebox/shared 的纯谓词 + * (与渲染层侧栏同源),此处仅做查询参数解析 + 委派。 + */ +const listPrs: RouteHandler = async ({ query }) => { + const all = await prCtl.listPrs(NO_EVENT, undefined); + return filterPullRequests(all, { + primary: (query.get('primary') as PrDiscoveryFilter) || undefined, + secondary: (query.get('secondary') as PrSecondaryFilter) || undefined, + query: query.get('q') ?? undefined, + }); +}; + +const showPr: RouteHandler = ({ params }) => getContext().pr.findPrOrThrow(params.id); + +const reviewers: RouteHandler = async ({ params }) => + (await getContext().pr.findPrOrThrow(params.id)).reviewers; + +/** 无 path → 变更文件列表;带 path → 取该文件某一侧(默认 head)内容。 */ +const diff: RouteHandler = ({ params, query }) => { + const path = query.get('path'); + if (path) { + const side: DiffSide = query.get('side') === 'base' ? 'base' : 'head'; + return prCtl.getFileContent(NO_EVENT, { localId: params.id, side, path }); + } + return prCtl.listChangedFiles(NO_EVENT, { localId: params.id }); +}; + +const activity: RouteHandler = ({ params }) => + prCtl.listActivity(NO_EVENT, { localId: params.id }); + +const commits: RouteHandler = ({ params }) => prCtl.listCommits(NO_EVENT, { localId: params.id }); + +const agentStatus: RouteHandler = ({ params }) => + agentCtl.getSession(NO_EVENT, { localId: params.id }); + +const agentHistory: RouteHandler = ({ params }) => + agentCtl.getConversation(NO_EVENT, { localId: params.id }); + +const agentReview: RouteHandler = ({ params }) => agentCtl.runReview(NO_EVENT, { localId: params.id }); + +/** 发送只读 Agent 指令(describe / review / ask / improve);写工具硬拒绝(403),无二次确认。 */ +const agentInstruct: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { command?: string; args?: string }; + const command = (b.command ?? '').replace(/^\//, '') as ReviewRunTool; + if (!READ_ONLY_TOOLS.has(command)) { + throw new HttpError(403, ERROR_CODES.SV_WRITE_NOT_ALLOWED, { command: b.command ?? '' }); + } + if (command === 'ask' && !b.args?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'ask requires args' }); + } + return agentCtl.runPragent(NO_EVENT, { localId: params.id, tool: command, question: b.args }); +}; + +/** 发送自然语言聊天(可触发 Agent 任务):运行中入队、否则起一轮自由规划兜底。 */ +const agentChat: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { message?: string }; + if (!b.message?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'message required' }); + } + return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message }); +}; + +export const routes: Route[] = [ + { method: 'GET', segments: seg('/api/v1/categories'), handler: categories }, + { method: 'GET', segments: seg('/api/v1/prs'), handler: listPrs }, + { method: 'GET', segments: seg('/api/v1/prs/:id'), handler: showPr }, + { method: 'GET', segments: seg('/api/v1/prs/:id/diff'), handler: diff }, + { method: 'GET', segments: seg('/api/v1/prs/:id/activity'), handler: activity }, + { method: 'GET', segments: seg('/api/v1/prs/:id/commits'), handler: commits }, + { method: 'GET', segments: seg('/api/v1/prs/:id/reviewers'), handler: reviewers }, + { method: 'GET', segments: seg('/api/v1/prs/:id/agent'), handler: agentStatus }, + { method: 'GET', segments: seg('/api/v1/prs/:id/agent/conversation'), handler: agentHistory }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/review'), handler: agentReview }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat }, +]; + +/** 按方法 + 路径匹配路由,提取 `:param` 路径参数;无匹配返回 null。 */ +export function matchRoute( + method: string, + pathname: string, +): { route: Route; params: Record } | null { + const parts = seg(pathname); + for (const route of routes) { + if (route.method !== method || route.segments.length !== parts.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < route.segments.length; i++) { + const s = route.segments[i]; + if (s.startsWith(':')) params[s.slice(1)] = decodeURIComponent(parts[i]); + else if (s !== parts[i]) { + ok = false; + break; + } + } + if (ok) return { route, params }; + } + return null; +} diff --git a/apps/desktop/src/main/services/api-server/server.ts b/apps/desktop/src/main/services/api-server/server.ts new file mode 100644 index 00000000..75e7a82f --- /dev/null +++ b/apps/desktop/src/main/services/api-server/server.ts @@ -0,0 +1,117 @@ +import { timingSafeEqual } from 'node:crypto'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { BootstrapResult } from '@meebox/config'; +import { ERROR_CODES } from '@meebox/shared'; +import type { Logger } from 'pino'; +import { HttpError, readJsonBody, sendError, sendOk } from './http.js'; +import { matchRoute } from './routes.js'; + +/** + * 本地 API 服务监听器(见 docs/arch/04-integration/01-service-api.md)。 + * + * 主进程内置 HTTP listener,作为渲染层 IPC 之外的「第二前端」:复用同一 ControllerContext 与 service 层, + * 把只读 PR / Agent 能力暴露给外部 CLI / 工具。默认关闭;开启即强制 bearer token 鉴权。生命周期由 main 装配: + * start(按 config 决定是否 listen)/ stop(退出时优雅关闭)/ reconfigure(配置变更停旧起新)。 + */ +export interface ApiServerDeps { + bootstrap: BootstrapResult; + logger: Logger; +} + +export class ApiServer { + private server?: Server; + + constructor(private readonly deps: ApiServerDeps) {} + + /** 实时读内存 service 配置(token 变更无需重建即生效)。 */ + private get cfg() { + return this.deps.bootstrap.config.service; + } + + /** 按配置启动监听(未启用 / token 为空则不启动)。监听失败为非致命:记录后不抛,不拖垮应用启动。 */ + async start(): Promise { + if (this.server) return; + const cfg = this.cfg; + if (!cfg.enabled) return; + if (!cfg.token) { + this.deps.logger.warn('api server enabled but token is empty; not starting'); + return; + } + const server = createServer((req, res) => { + void this.handle(req, res); + }); + this.server = server; + await new Promise((resolve, reject) => { + const onError = (err: Error): void => { + this.server = undefined; + reject(err); + }; + server.once('error', onError); + server.listen(cfg.port, cfg.host, () => { + server.off('error', onError); + server.on('error', (err) => this.deps.logger.error({ err }, 'api server runtime error')); + this.deps.logger.info({ host: cfg.host, port: cfg.port }, 'local API server listening'); + resolve(); + }); + }).catch((err: unknown) => { + this.deps.logger.error({ err, port: cfg.port }, 'local API server failed to listen (non-fatal)'); + }); + } + + /** 优雅关闭:停止接收新连接、放行 in-flight 后落定。 */ + async stop(): Promise { + const server = this.server; + if (!server) return; + this.server = undefined; + await new Promise((resolve) => server.close(() => resolve())); + this.deps.logger.info('local API server stopped'); + } + + /** 配置(开关 / host / port)变更:停旧起新。 */ + async reconfigure(): Promise { + await this.stop(); + await this.start(); + } + + /** 常数时间比对 bearer token;缺 token 配置 / 非 Bearer 头 / 长度不符均判失败。 */ + private authorized(req: IncomingMessage): boolean { + const token = this.cfg.token; + if (!token) return false; + const header = req.headers['authorization']; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false; + const provided = Buffer.from(header.slice('Bearer '.length)); + const expected = Buffer.from(token); + if (provided.length !== expected.length) return false; + return timingSafeEqual(provided, expected); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const started = Date.now(); + const method = req.method ?? 'GET'; + const rawUrl = req.url ?? '/'; + const qIdx = rawUrl.indexOf('?'); + const pathname = qIdx >= 0 ? rawUrl.slice(0, qIdx) : rawUrl; + const search = qIdx >= 0 ? rawUrl.slice(qIdx + 1) : ''; + + let outcome: { status: number; code?: string }; + try { + if (!this.authorized(req)) throw new HttpError(401, ERROR_CODES.SV_UNAUTHORIZED); + const matched = matchRoute(method, pathname); + if (!matched) throw new HttpError(404, ERROR_CODES.SV_NOT_FOUND); + const body = method === 'POST' ? await readJsonBody(req) : undefined; + const data = await matched.route.handler({ + params: matched.params, + query: new URLSearchParams(search), + body, + }); + sendOk(res, data); + outcome = { status: 200 }; + } catch (err) { + outcome = sendError(res, err); + } + this.deps.logger.debug( + { method, path: pathname, status: outcome.status, code: outcome.code, ms: Date.now() - started }, + 'api request', + ); + } +} diff --git a/apps/desktop/src/main/services/context.ts b/apps/desktop/src/main/services/context.ts index 8bc627a5..90b3c9e9 100644 --- a/apps/desktop/src/main/services/context.ts +++ b/apps/desktop/src/main/services/context.ts @@ -31,6 +31,8 @@ export interface RegisterDeps { /** 重建 adapters/poller 使连接变更热生效(config:setConnections 写盘后调用) */ reconfigureConnections: () => Promise; repoMirror: RepoMirrorManager; + /** 重建本地 API 监听器使 service 配置(开关 / host / port)变更热生效(config:setService 写盘后调用)。 */ + reconfigureApiServer: () => Promise; } /** diff --git a/apps/desktop/src/main/services/pr-service.ts b/apps/desktop/src/main/services/pr-service.ts index 8a2f2913..b1b35c53 100644 --- a/apps/desktop/src/main/services/pr-service.ts +++ b/apps/desktop/src/main/services/pr-service.ts @@ -8,7 +8,12 @@ import { writeDiffBaseCache, } from '@meebox/poller'; import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; -import { pullRequestHeadRefspec, type StoredPullRequest } from '@meebox/shared'; +import { + AppError, + ERROR_CODES, + pullRequestHeadRefspec, + type StoredPullRequest, +} from '@meebox/shared'; import type { PlatformAdapter } from '@meebox/platform-core'; import type { JsonFileStateStore } from '@meebox/state-store'; import type { ConnectionRuntime } from '../adapters.js'; @@ -53,7 +58,7 @@ export class PrService { if (pr) return pr; const archived = await readPrMeta(this.deps.archiveStore, localId); if (archived) return archived.pr; - throw new Error(`PR not found in local state: ${localId}`); + throw new AppError(ERROR_CODES.PR_NOT_FOUND, { localId }, `PR not found in local state: ${localId}`); } /** diff --git a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx index 9dbfc857..a7c9960e 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx @@ -10,6 +10,7 @@ import { QuestionIcon, RobotIcon, SettingsIcon, + ShareIcon, } from '../../common'; import { useSettingsDraft } from './hooks/useSettingsDraft'; import { useAppearanceDraft } from './hooks/useAppearanceDraft'; @@ -26,6 +27,7 @@ import { LlmContextSection } from './sections/LlmContextSection'; import { ConcurrencySection } from './sections/ConcurrencySection'; import { AgentStrategySection } from './sections/AgentStrategySection'; import { ProxySection } from './sections/ProxySection'; +import { ServiceSection } from './sections/ServiceSection'; import { AgentDirSection } from './sections/AgentDirSection'; import { WorkDirSection } from './sections/WorkDirSection'; import { CacheDirSection } from './sections/CacheDirSection'; @@ -38,6 +40,7 @@ export type SettingsCategory = | 'model' | 'agent' | 'notifications' + | 'integration' | 'about'; /** @@ -54,6 +57,7 @@ const SETTINGS_CATEGORIES: ReadonlyArray<{ { id: 'model', labelKey: 'settings.catModel', Icon: CpuIcon }, { id: 'agent', labelKey: 'settings.catAgent', Icon: RobotIcon }, { id: 'notifications', labelKey: 'settings.catNotifications', Icon: BellIcon }, + { id: 'integration', labelKey: 'settings.catIntegration', Icon: ShareIcon }, { id: 'about', labelKey: 'settings.catAbout', Icon: QuestionIcon }, ]; @@ -252,6 +256,13 @@ export function SettingsModal({ {category === 'notifications' && ( )} + {category === 'integration' && ( + void s.regenerateServiceToken()} + /> + )} {category === 'about' && ( <> diff --git a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts index 4c3467a9..d2aa478d 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts +++ b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts @@ -63,6 +63,9 @@ export function useSettingsDraft({ // 代理在独立模态框里编辑:null=关闭,非 null=正在编辑的草稿;保存回 proxy,底栏「保存」才写盘。 const [proxyEditor, setProxyEditor] = useState(null); + // 本地 API 服务监听(开关 / host / port 随整体保存;token 经 generateServiceToken 立即写盘)。 + const [service, setServiceState] = useState(config.service); + // 连接:多条可配置 + 单选启用;编辑只改本地 state,整体保存才写盘 + 热重建 const [connections, setConnections] = useState(config.connections); const [activeConnId, setActiveConnId] = useState(config.active_connection_id); @@ -83,6 +86,7 @@ export function useSettingsDraft({ llm: config.llm, proxy: config.proxy, notifications: config.notifications, + service: config.service, connections: config.connections, activeConnId: config.active_connection_id, })); @@ -243,6 +247,21 @@ export function useSettingsDraft({ setNotificationsState(next); setSaved(false); }; + const setService = (next: Config['service']): void => { + setServiceState(next); + setSaved(false); + }; + // token 重新生成是即时副作用(写盘 + 即时生效),不随整体保存:更新草稿 token 的同时同步基线, + // 使「重新生成」本身不被算作待保存改动(仅开关 / host / port 的未保存编辑才标脏)。 + const regenerateServiceToken = async (): Promise => { + try { + const { token } = await invoke('config:generateServiceToken', undefined); + setServiceState((prev) => ({ ...prev, token })); + setBase((b) => ({ ...b, service: { ...b.service, token } })); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } + }; const setAgentDir = (v: string): void => { setAgentDirInput(v); setSaved(false); @@ -291,6 +310,7 @@ export function useSettingsDraft({ const proxyChanged = JSON.stringify(proxy) !== JSON.stringify(base.proxy); const notificationsChanged = JSON.stringify(notifications) !== JSON.stringify(base.notifications); + const serviceChanged = JSON.stringify(service) !== JSON.stringify(base.service); const connectionsChanged = activeConnId !== base.activeConnId || JSON.stringify(connections) !== JSON.stringify(base.connections); @@ -302,6 +322,7 @@ export function useSettingsDraft({ llmChanged || proxyChanged || notificationsChanged || + serviceChanged || connectionsChanged; const saveAll = async (): Promise => { @@ -344,6 +365,17 @@ export function useSettingsDraft({ if (notificationsChanged) { await invoke('config:setNotifications', { notifications }); } + if (serviceChanged) { + const host = service.host.trim(); + // 简单合法性校验:非空、无空白 / 协议 / 斜杠(端口单列);放过 IPv4 / 主机名 / 0.0.0.0 / ::1。 + if (!host || !/^[A-Za-z0-9.:-]+$/.test(host)) { + throw new Error(t('settings.serviceHostInvalidError')); + } + if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65535) { + throw new Error(t('settings.servicePortRangeError')); + } + await invoke('config:setService', { service: { ...service, host } }); + } if (connectionsChanged) { await invoke('config:setConnections', { connections, active_connection_id: activeConnId }); await onConnectionsChange?.(); @@ -365,6 +397,7 @@ export function useSettingsDraft({ llm, proxy, notifications, + service, connections, activeConnId, }); @@ -410,6 +443,10 @@ export function useSettingsDraft({ // 通知 notifications, setNotifications, + // 本地 API 服务监听 + service, + setService, + regenerateServiceToken, // 轮询 / 并发 / 目录 pollerInput, setPoller, diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx new file mode 100644 index 00000000..4e711d41 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { CopyIcon, EyeIcon, EyeOffIcon, Switch } from '../../../common'; + +/** + * 本地 API 服务监听分区:开关 + 监听地址(仅本机 / 局域网)+ 端口 + bearer token(展示 / 显隐 / 复制 / + * 重新生成)。默认关闭;启用且无 token 时自动生成。监听 0.0.0.0 暴露到局域网时给安全警示。token 经 + * config:generateServiceToken 立即写盘生效(不随整体保存);开关 / 地址 / 端口随底栏「保存」生效。 + */ +export function ServiceSection({ + value, + onChange, + onRegenerateToken, +}: { + value: Config['service']; + onChange: (next: Config['service']) => void; + /** 立即重新生成 token(写盘 + 即时生效);启用且无 token 时也由此自动补一枚。 */ + onRegenerateToken: () => void; +}) { + const { t } = useTranslation(); + const [revealed, setRevealed] = useState(false); + const [copied, setCopied] = useState(false); + const on = value.enabled; + // 暴露判定:非 loopback 绑定(0.0.0.0 / 局域网 IP 等)即视为可被同网段访问,给安全警示。 + const host = value.host.trim(); + const exposed = host !== '' && !['127.0.0.1', 'localhost', '::1'].includes(host); + + const set = (patch: Partial): void => onChange({ ...value, ...patch }); + + const handleEnabled = (v: boolean): void => { + if (v && !value.token) onRegenerateToken(); // 启用且无 token → 自动生成一枚 + set({ enabled: v }); + }; + + const copyToken = async (): Promise => { + if (!value.token) return; + try { + await navigator.clipboard.writeText(value.token); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* 复制失败静默 */ + } + }; + + return ( +
+
+
+

{t('settings.serviceTitle')}

+
+ +
+

+ {t('settings.serviceHint')} +

+ + {/* 监听地址:http://: 两个输入框组合。host 默认 127.0.0.1(仅本机), + 可填 0.0.0.0 / 局域网 IP 开放到同网段。 */} +
+
+ {t('settings.serviceHostLabel')} +
+
+ http:// + set({ host: e.target.value })} + placeholder="127.0.0.1" + spellCheck={false} + /> + : + set({ port: Number.parseInt(e.target.value, 10) || 0 })} + /> +
+

+ {t('settings.serviceHostHint')} +

+
+ + {exposed && ( +

+ {t('settings.serviceExposeWarning')} +

+ )} + +
+ + + + +
+ {copied && {t('settings.serviceTokenCopied')}} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx index ada47140..294ae25e 100644 --- a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx @@ -1,18 +1,22 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { - AgentRecommendationVerdict, - LocalPrStatus, - PrDiscoveryFilter, - StoredPullRequest, +import { + matchesDiscoveryFilter, + matchesPrQuery, + matchesSecondaryFilter, + type AgentRecommendationVerdict, + type PrDiscoveryFilter, + type PrSecondaryFilter, + type StoredPullRequest, } from '@meebox/shared'; import { invoke, subscribe } from '../../api'; import { useChatRunStore } from '../../stores/chat-run-store'; import { HistoryIcon, PaneLoading } from '../common'; import { PrItem } from '../features/pr'; -// 'conflict' / 'mergeable' 是按远端 merge 状态跨 localStatus 横切的筛选;'all' 不限定 -export type FilterKey = 'all' | LocalPrStatus | 'conflict' | 'mergeable'; +// 二级筛选键复用 @meebox/shared 的 PrSecondaryFilter(与本地 API 同源): +// 'conflict' / 'mergeable' 按远端 merge 状态跨 localStatus 横切;'all' 不限定。 +export type FilterKey = PrSecondaryFilter; /** PR 列表范围:进行中(活跃,按发现分类 + 状态细分)/ 已关闭(归档冷存储,扁平只读浏览)。 */ export type SidebarScope = 'active' | 'archived'; @@ -206,10 +210,7 @@ export function Sidebar({ // GitHub 发现分类:按 PR 上的 discoveryFilters 标记本地过滤(poller 已把四类都抓回来缓存), // 切标签纯本地、瞬时、零远端请求。非 GitHub(discoveryFilter 未设)时用全量。 const scopedPrs = useMemo( - () => - !isArchived && discoveryFilter - ? prs.filter((p) => p.discoveryFilters?.includes(discoveryFilter)) - : prs, + () => prs.filter((p) => matchesDiscoveryFilter(p, !isArchived ? discoveryFilter : undefined)), [prs, discoveryFilter, isArchived], ); @@ -231,30 +232,12 @@ export function Sidebar({ }, [scopedPrs]); const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - // 已关闭范围强制「全部」(不应用状态筛选);进行中范围按当前状态筛选。 + // 已关闭范围强制「全部」(不应用状态筛选);进行中范围按当前状态筛选。过滤 / 检索语义复用 + // @meebox/shared 纯谓词(与本地 API 同源)。 const effFilter: FilterKey = isArchived ? 'all' : filter; - return scopedPrs.filter((p) => { - if (effFilter === 'conflict') { - if (!p.hasConflict) return false; - } else if (effFilter === 'mergeable') { - if (!p.mergeStatus?.canMerge) return false; - } else if (effFilter !== 'all' && p.localStatus !== effFilter) { - return false; - } - if (!q) return true; - const hay = [ - p.title, - p.repo.projectKey, - p.repo.repoSlug, - p.author.displayName, - p.author.name, - p.remoteId, - ] - .join(' ') - .toLowerCase(); - return hay.includes(q); - }); + return scopedPrs.filter( + (p) => matchesSecondaryFilter(p, effFilter) && matchesPrQuery(p, query), + ); }, [scopedPrs, query, filter, isArchived]); const groups = useMemo(() => { diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index f5fe935f..bf7f2adb 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -715,6 +715,7 @@ "catAgent": "Agent", "catConnection": "Verbindung", "catGeneral": "Allgemein", + "catIntegration": "Integration", "catModel": "Modell", "catNotifications": "Benachrichtigungen", "checkFailed": "Prüfung fehlgeschlagen: {{error}}", @@ -817,6 +818,20 @@ "runtimeTitle": "Laufzeitumgebung", "saved": "Gespeichert", "saving": "Speichere…", + "serviceEnableLabel": "Lokalen API-Dienst aktivieren", + "serviceExposeWarning": "Das Lauschen auf 0.0.0.0 macht die API im lokalen Netzwerk zugänglich; der Token ist der einzige Schutz – halten Sie ihn geheim und nutzen Sie eine Firewall.", + "serviceHint": "Stellt eine lokale HTTP-API für die Integration externer Tools / CLI bereit. Standardmäßig aus; bei Aktivierung ist Bearer-Token-Authentifizierung erforderlich.", + "serviceHostHint": "Standard http://127.0.0.1:18765 (nur dieses Gerät). Host auf 0.0.0.0 oder eine LAN-IP setzen, um Zugriff aus demselben Subnetz zu erlauben (hohes Risiko).", + "serviceHostInvalidError": "Ungültige Lausch-Adresse (nur IP oder Hostname; ohne Schema, Leerzeichen oder Port).", + "serviceHostLabel": "Lausch-Adresse", + "servicePortRangeError": "Der Port muss eine ganze Zahl zwischen 1 und 65535 sein.", + "serviceTitle": "Lokaler API-Dienst", + "serviceTokenCopied": "Kopiert", + "serviceTokenCopy": "Token kopieren", + "serviceTokenHide": "Token verbergen", + "serviceTokenPlaceholder": "Noch kein Token generiert", + "serviceTokenRegenerate": "Neu generieren", + "serviceTokenReveal": "Token anzeigen", "setActiveLlmAria": "Als aktiv festlegen", "show": "Anzeigen", "starOnGithub": "Auf GitHub favorisieren", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index e508951c..3b600cae 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -715,6 +715,7 @@ "catAgent": "Agent", "catConnection": "Connection", "catGeneral": "General", + "catIntegration": "Integration", "catModel": "Model", "catNotifications": "Notifications", "checkFailed": "Check failed: {{error}}", @@ -817,6 +818,20 @@ "runtimeTitle": "Runtime", "saved": "Saved", "saving": "Saving…", + "serviceEnableLabel": "Enable local API service", + "serviceExposeWarning": "Listening on 0.0.0.0 exposes the API to your local network; the token is the only safeguard—keep it secret and use a firewall.", + "serviceHint": "Expose a local HTTP API for external tools / CLI integration. Off by default; enabling enforces bearer token authentication.", + "serviceHostHint": "Default http://127.0.0.1:18765 (this machine only). Set host to 0.0.0.0 or a LAN IP to allow same-subnet access (high risk).", + "serviceHostInvalidError": "Invalid listen address (IP or hostname only; no scheme, spaces, or port).", + "serviceHostLabel": "Listen address", + "servicePortRangeError": "Port must be an integer between 1 and 65535.", + "serviceTitle": "Local API service", + "serviceTokenCopied": "Copied", + "serviceTokenCopy": "Copy token", + "serviceTokenHide": "Hide token", + "serviceTokenPlaceholder": "No token generated yet", + "serviceTokenRegenerate": "Regenerate", + "serviceTokenReveal": "Show token", "setActiveLlmAria": "Set as active", "show": "Show", "starOnGithub": "Star on GitHub", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index 546aa837..e97ce3f7 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -698,6 +698,7 @@ "catAgent": "エージェント", "catConnection": "接続", "catGeneral": "一般", + "catIntegration": "連携", "catModel": "モデル", "catNotifications": "通知", "checkFailed": "チェックに失敗しました:{{error}}", @@ -800,6 +801,20 @@ "runtimeTitle": "実行環境", "saved": "保存しました", "saving": "保存中…", + "serviceEnableLabel": "ローカル API サービスを有効化", + "serviceExposeWarning": "0.0.0.0 でのリッスンは API を LAN に公開します。トークンが唯一の防御策です——秘匿し、ファイアウォールを併用してください。", + "serviceHint": "外部ツール / CLI 連携用のローカル HTTP API を公開します。既定は無効。有効化すると bearer トークン認証が必須になります。", + "serviceHostHint": "既定は http://127.0.0.1:18765(本機のみ)。host に 0.0.0.0 や LAN の IP を指定すると同一サブネットから接続可能(高リスク)。", + "serviceHostInvalidError": "リッスンアドレスの形式が不正です(IP / ホスト名のみ。プロトコル・空白・ポートは不可)。", + "serviceHostLabel": "リッスンアドレス", + "servicePortRangeError": "ポートは 1〜65535 の整数で指定してください。", + "serviceTitle": "ローカル API サービス", + "serviceTokenCopied": "コピーしました", + "serviceTokenCopy": "トークンをコピー", + "serviceTokenHide": "トークンを隠す", + "serviceTokenPlaceholder": "トークン未生成", + "serviceTokenRegenerate": "再生成", + "serviceTokenReveal": "トークンを表示", "setActiveLlmAria": "アクティブに設定", "show": "表示", "starOnGithub": "GitHub でスター", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index d7ae37e0..794c7799 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -698,6 +698,7 @@ "catAgent": "智能体", "catConnection": "连接", "catGeneral": "常规", + "catIntegration": "集成", "catModel": "模型", "catNotifications": "通知", "checkFailed": "检查失败:{{error}}", @@ -800,6 +801,20 @@ "runtimeTitle": "运行环境", "saved": "已保存", "saving": "保存中…", + "serviceEnableLabel": "启用本地 API 服务", + "serviceExposeWarning": "监听 0.0.0.0 会把 API 暴露到局域网,token 是唯一防线——请确保 token 保密并配合防火墙。", + "serviceHint": "对外提供本地 HTTP API,供外部工具 / CLI 集成。默认关闭;启用即强制 bearer token 鉴权。", + "serviceHostHint": "默认 http://127.0.0.1:18765(仅本机可达)。host 填 0.0.0.0 或本机局域网 IP 可被同网段访问(高风险)。", + "serviceHostInvalidError": "监听地址格式不合法(仅允许 IP / 主机名,不含协议、空格或端口)。", + "serviceHostLabel": "监听地址", + "servicePortRangeError": "端口需为 1–65535 之间的整数。", + "serviceTitle": "本地 API 服务", + "serviceTokenCopied": "已复制", + "serviceTokenCopy": "复制 token", + "serviceTokenHide": "隐藏 token", + "serviceTokenPlaceholder": "尚未生成 token", + "serviceTokenRegenerate": "重新生成", + "serviceTokenReveal": "显示 token", "setActiveLlmAria": "设为活跃", "show": "显示", "starOnGithub": "GitHub 上 Star", diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 00000000..65330843 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,14 @@ +# Build output +/bin/ +/dist/ +/meebox +/meebox.exe + +# Test binaries (go test -c), coverage / profiling output +*.test +*.out +*.prof + +# Go workspace files (local dev only) +go.work +go.work.sum diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..b13bc7f5 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,70 @@ +# meebox CLI + +A standalone, cross-platform command-line client for **Code Meeseeks**. It is a +thin client over the desktop app's local HTTP API — see the design docs: + +- [Service listener & local API](../docs/arch/04-integration/01-service-api.md) +- [CLI tool](../docs/arch/04-integration/02-cli.md) + +All exposed capabilities are **read-only**; write operations (commenting, +approving, publishing) are intentionally not provided. + +## Status + +Project scaffold. The command tree, connection/auth resolution, HTTP client, +output formatting, and exit-code mapping are in place and built against the +documented API contract. The server-side API is implemented separately; until +it is available, commands will fail to connect. + +## Build & run + +This is an independent Go module (`go.mod`), not part of the npm/Nx workspace. + +```bash +cd cli +go build -o bin/meebox . # or: go install . +go vet ./... +go test ./... +``` + +Cross-compile (matches the release matrix): + +```bash +GOOS=windows GOARCH=amd64 go build -o dist/meebox.exe . +GOOS=darwin GOARCH=arm64 go build -o dist/meebox . +GOOS=linux GOARCH=amd64 go build -o dist/meebox . +GOOS=linux GOARCH=arm64 go build -o dist/meebox . +``` + +## Connection + +The CLI resolves the API base URL and bearer token in this order (highest first): + +1. flags — `--api-url`, `--token` +2. env — `MEEBOX_API_URL`, `MEEBOX_TOKEN` +3. CLI config — `~/.code-meeseeks/cli.yaml` (`api_url`, `token`) +4. local auto-discovery — the app's `~/.code-meeseeks/config.yaml` `service` + section (same machine, same user; zero-config) + +## Commands + +```text +meebox categories +meebox pr list [--primary ] [--secondary ] [--query ] +meebox pr show +meebox pr diff [--file ] [--side base|head] +meebox pr activity +meebox pr commits +meebox pr reviewers +meebox agent status +meebox agent history +meebox agent review +meebox agent instruct [args...] # read-only: describe|review|ask|improve +meebox agent chat +``` + +Global flags: `--api-url`, `--token`, `--output yaml|json`, `--quiet`. + +Output defaults to **YAML** (human-friendly, k8s `-o yaml` style); pass +`--output json` for the machine-readable form used by third-party integrations. +Both are generic transforms of the response — no per-command formatting. diff --git a/cli/cmd/agent.go b/cli/cmd/agent.go new file mode 100644 index 00000000..0fd23da9 --- /dev/null +++ b/cli/cmd/agent.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" +) + +// readOnlyInstructions is the set of agent instructions the CLI may send. +// Write tools (approve / needswork / publish …) are intentionally excluded — +// the server also hard-refuses them, this is a friendly front-line check. +var readOnlyInstructions = map[string]bool{ + "describe": true, + "review": true, + "ask": true, + "improve": true, +} + +func newAgentCmd() *cobra.Command { + a := &cobra.Command{ + Use: "agent", + Short: "Operate the review agent on a PR", + } + a.AddCommand( + newAgentStatusCmd(), + newAgentHistoryCmd(), + newAgentReviewCmd(), + newAgentInstructCmd(), + newAgentChatCmd(), + ) + return a +} + +func newAgentStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status ", + Short: "Show the agent's current execution status", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/agent") + }, + } +} + +func newAgentHistoryCmd() *cobra.Command { + return &cobra.Command{ + Use: "history ", + Short: "Show the agent conversation history", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/agent/conversation") + }, + } +} + +func newAgentReviewCmd() *cobra.Command { + return &cobra.Command{ + Use: "review ", + Short: "Run auto review on a PR", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + c, err := resolveClient() + if err != nil { + return err + } + data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/review", nil) + if err != nil { + return err + } + return renderData(data) + }, + } +} + +func newAgentInstructCmd() *cobra.Command { + return &cobra.Command{ + Use: "instruct [args...]", + Short: "Send a read-only agent instruction (describe|review|ask|improve)", + Args: cobra.MinimumNArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + instruction := strings.TrimPrefix(args[1], "/") + if !readOnlyInstructions[instruction] { + return fmt.Errorf("instruction %q is not a read-only command; write operations are not supported via the CLI", args[1]) + } + c, err := resolveClient() + if err != nil { + return err + } + body := map[string]any{"command": instruction} + if len(args) > 2 { + body["args"] = strings.Join(args[2:], " ") + } + data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/instruct", body) + if err != nil { + return err + } + return renderData(data) + }, + } +} + +func newAgentChatCmd() *cobra.Command { + return &cobra.Command{ + Use: "chat ", + Short: "Send a natural-language chat message (may trigger agent tasks)", + Args: cobra.MinimumNArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + c, err := resolveClient() + if err != nil { + return err + } + body := map[string]any{"message": strings.Join(args[1:], " ")} + data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/chat", body) + if err != nil { + return err + } + return renderData(data) + }, + } +} diff --git a/cli/cmd/categories.go b/cli/cmd/categories.go new file mode 100644 index 00000000..d7e5d21b --- /dev/null +++ b/cli/cmd/categories.go @@ -0,0 +1,14 @@ +package cmd + +import "github.com/spf13/cobra" + +func newCategoriesCmd() *cobra.Command { + return &cobra.Command{ + Use: "categories", + Short: "List available PR classification labels for the enabled platforms", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/categories") + }, + } +} diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go new file mode 100644 index 00000000..e6104e18 --- /dev/null +++ b/cli/cmd/integration_test.go @@ -0,0 +1,238 @@ +package cmd + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/huhamhire/code-meeseeks/cli/internal/render" +) + +// capturedReq records what the mock server received, for request-shape assertions. +type capturedReq struct { + called bool + method string + path string + query string + auth string + body string +} + +// mockServer stands in for the local API: it records the request and returns the +// documented envelope ({ok:true,data} on 2xx, {ok:false,error} on >=400). +func mockServer(rec *capturedReq, status int, dataJSON string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + rec.called = true + rec.method = r.Method + rec.path = r.URL.Path + rec.query = r.URL.RawQuery + rec.auth = r.Header.Get("Authorization") + rec.body = string(body) + + w.Header().Set("Content-Type", "application/json") + code := status + if code == 0 { + code = http.StatusOK + } + w.WriteHeader(code) + if code >= 400 { + _, _ = io.WriteString(w, `{"ok":false,"error":{"code":"ESV0001"}}`) + return + } + _, _ = io.WriteString(w, `{"ok":true,"data":`+dataJSON+`}`) + })) +} + +// runCmd runs the root command with captured output, returning stdout + the error +// (Execute()'s os.Exit wrapper is bypassed so tests can assert on the error). +func runCmd(args ...string) (string, error) { + var buf bytes.Buffer + origOut, origErr := render.Stdout, render.Stderr + render.Stdout, render.Stderr = &buf, io.Discard + defer func() { render.Stdout, render.Stderr = origOut, origErr }() + + root := newRootCmd() + root.SetArgs(args) + err := root.Execute() + return buf.String(), err +} + +// base flags force explicit connection settings so tests are hermetic (no env / +// local config auto-discovery interference). +func base(srvURL string, rest ...string) []string { + return append([]string{"--api-url", srvURL, "--token", "tk"}, rest...) +} + +func TestCategories(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"platform":"github","primary":["review-requested"],"secondary":["all"]}`) + defer srv.Close() + + out, err := runCmd(base(srv.URL, "categories")...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/api/v1/categories" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if rec.auth != "Bearer tk" { + t.Errorf("wrong auth header: %q", rec.auth) + } + if !strings.Contains(out, "platform: github") { + t.Errorf("output missing rendered field: %q", out) + } +} + +func TestPrListFilters(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `[]`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "list", "--primary", "created", "--secondary", "approved", "--query", "foo")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.path != "/api/v1/prs" { + t.Errorf("wrong path: %s", rec.path) + } + for _, want := range []string{"primary=created", "secondary=approved", "q=foo"} { + if !strings.Contains(rec.query, want) { + t.Errorf("query %q missing %q", rec.query, want) + } + } +} + +func TestPrShow(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"localId":"abc123","title":"t"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "show", "abc123")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/api/v1/prs/abc123" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + +func TestPrDiffFile(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"binary":false,"content":"x"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "diff", "abc123", "--file", "src/a.go", "--side", "head")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.path != "/api/v1/prs/abc123/diff" { + t.Errorf("wrong path: %s", rec.path) + } + for _, want := range []string{"path=src", "side=head"} { + if !strings.Contains(rec.query, want) { + t.Errorf("query %q missing %q", rec.query, want) + } + } +} + +func TestAgentReviewPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"status":"succeeded"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "agent", "review", "abc123")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/review" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + +func TestAgentInstructBody(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"status":"queued"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "agent", "instruct", "abc123", "describe", "extra", "ctx")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/instruct" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if !strings.Contains(rec.body, "describe") || !strings.Contains(rec.body, "extra ctx") { + t.Errorf("body missing command/args: %q", rec.body) + } +} + +func TestAgentInstructWriteToolRejected(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `null`) + defer srv.Close() + + _, err := runCmd(base(srv.URL, "agent", "instruct", "abc123", "approve")...) + if err == nil { + t.Fatal("expected write tool to be rejected") + } + if rec.called { + t.Error("server must not be called for a rejected write tool") + } +} + +func TestAgentChatPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"queued":true}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "agent", "chat", "abc123", "hello", "world")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/chat" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if !strings.Contains(rec.body, "hello world") { + t.Errorf("body missing message: %q", rec.body) + } +} + +func TestAuthFailureExitCode(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 401, "") + defer srv.Close() + + _, err := runCmd(base(srv.URL, "categories")...) + if err == nil { + t.Fatal("expected auth error") + } + if got := render.ExitCodeFor(err); got != render.ExitAuth { + t.Errorf("auth failure exit code = %d, want %d", got, render.ExitAuth) + } +} + +func TestNotFoundExitCode(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 404, "") + defer srv.Close() + + _, err := runCmd(base(srv.URL, "pr", "show", "missing")...) + if err == nil { + t.Fatal("expected not-found error") + } + if got := render.ExitCodeFor(err); got != render.ExitNotFound { + t.Errorf("not-found exit code = %d, want %d", got, render.ExitNotFound) + } +} + +func TestOutputJSON(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"platform":"github"}`) + defer srv.Close() + + out, err := runCmd(base(srv.URL, "--output", "json", "categories")...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, `"platform": "github"`) { + t.Errorf("json output not indented JSON: %q", out) + } +} diff --git a/cli/cmd/pr.go b/cli/cmd/pr.go new file mode 100644 index 00000000..10390280 --- /dev/null +++ b/cli/cmd/pr.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "net/url" + + "github.com/spf13/cobra" +) + +func newPrCmd() *cobra.Command { + pr := &cobra.Command{ + Use: "pr", + Short: "Browse pull requests", + } + pr.AddCommand( + newPrListCmd(), + newPrShowCmd(), + newPrDiffCmd(), + newPrActivityCmd(), + newPrCommitsCmd(), + newPrReviewersCmd(), + ) + return pr +} + +func newPrListCmd() *cobra.Command { + var primary, secondary, query string + cmd := &cobra.Command{ + Use: "list", + Short: "List PRs (no pagination) with optional category and search filters", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + c, err := resolveClient() + if err != nil { + return err + } + q := url.Values{} + if primary != "" { + q.Set("primary", primary) + } + if secondary != "" { + q.Set("secondary", secondary) + } + if query != "" { + q.Set("q", query) + } + data, err := c.Get("/api/v1/prs", q) + if err != nil { + return err + } + return renderData(data) + }, + } + f := cmd.Flags() + f.StringVar(&primary, "primary", "", "primary category (platform discovery filter)") + f.StringVar(&secondary, "secondary", "", "secondary filter (review status / merge state)") + f.StringVar(&query, "query", "", "search text (title / repo / author / number)") + return cmd +} + +func newPrShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show PR description detail", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0])) + }, + } +} + +func newPrDiffCmd() *cobra.Command { + var file, side string + cmd := &cobra.Command{ + Use: "diff ", + Short: "List changed files, or fetch one file's content with --file", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + c, err := resolveClient() + if err != nil { + return err + } + q := url.Values{} + if file != "" { + q.Set("path", file) + } + if side != "" { + q.Set("side", side) + } + data, err := c.Get("/api/v1/prs/"+url.PathEscape(args[0])+"/diff", q) + if err != nil { + return err + } + return renderData(data) + }, + } + cmd.Flags().StringVar(&file, "file", "", "fetch this file's content instead of the changed-file list") + cmd.Flags().StringVar(&side, "side", "", "file side when --file is set: base|head") + return cmd +} + +func newPrActivityCmd() *cobra.Command { + return &cobra.Command{ + Use: "activity ", + Short: "Show the PR activity timeline (comments / commits / review decisions)", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/activity") + }, + } +} + +func newPrCommitsCmd() *cobra.Command { + return &cobra.Command{ + Use: "commits ", + Short: "List the PR commits", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/commits") + }, + } +} + +func newPrReviewersCmd() *cobra.Command { + return &cobra.Command{ + Use: "reviewers ", + Short: "Show reviewer approval status", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/reviewers") + }, + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go new file mode 100644 index 00000000..8707981f --- /dev/null +++ b/cli/cmd/root.go @@ -0,0 +1,91 @@ +// Package cmd defines the meebox command tree built on cobra. +package cmd + +import ( + "encoding/json" + "os" + + "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" + "github.com/huhamhire/code-meeseeks/cli/internal/render" + "github.com/huhamhire/code-meeseeks/cli/internal/settings" + "github.com/spf13/cobra" +) + +// version is overridden at build time via -ldflags; "dev" for local builds. +var version = "dev" + +type globalFlags struct { + apiURL string + token string + output string + quiet bool +} + +var gflags globalFlags + +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "meebox", + Short: "Code Meeseeks CLI — integrate PR review capabilities over the local API", + SilenceUsage: true, + SilenceErrors: true, + Version: version, + } + pf := root.PersistentFlags() + pf.StringVar(&gflags.apiURL, "api-url", "", "API base URL (overrides env and local auto-discovery)") + pf.StringVar(&gflags.token, "token", "", "bearer token (overrides env and local auto-discovery)") + pf.StringVar(&gflags.output, "output", "yaml", "output format: yaml|json") + pf.BoolVar(&gflags.quiet, "quiet", false, "suppress non-essential output") + + root.AddCommand( + newCategoriesCmd(), + newPrCmd(), + newAgentCmd(), + ) + return root +} + +// Execute runs the root command, printing errors to stderr and mapping them +// to process exit codes per docs/arch/04-integration/02-cli.md. +func Execute() { + if err := newRootCmd().Execute(); err != nil { + render.Errorln(err) + os.Exit(render.ExitCodeFor(err)) + } +} + +// resolveClient builds an API client from the resolved connection settings. +func resolveClient() (*apiclient.Client, error) { + s, err := settings.Resolve(settings.Overrides{ + APIURL: gflags.apiURL, + Token: gflags.token, + }) + if err != nil { + return nil, err + } + return apiclient.New(s.APIURL, s.Token), nil +} + +func outputMode() render.Mode { + if gflags.output == "json" { + return render.ModeJSON + } + return render.ModeYAML +} + +func renderData(data json.RawMessage) error { + return render.Output(outputMode(), data) +} + +// getAndRender is the common GET-then-render path used by read-only commands. +func getAndRender(path string) error { + c, err := resolveClient() + if err != nil { + return err + } + data, err := c.Get(path, nil) + if err != nil { + return err + } + return renderData(data) +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 00000000..31a8aa5e --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,13 @@ +module github.com/huhamhire/code-meeseeks/cli + +go 1.24 + +require ( + github.com/spf13/cobra v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 00000000..a01295bb --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/apiclient/client.go b/cli/internal/apiclient/client.go new file mode 100644 index 00000000..ccb417f0 --- /dev/null +++ b/cli/internal/apiclient/client.go @@ -0,0 +1,121 @@ +// Package apiclient is a thin HTTP client for the Code Meeseeks local API. +// It speaks the response envelope documented in +// docs/arch/04-integration/01-service-api.md: { ok, data } on success and +// { ok:false, error:{ code, meta } } on failure. +package apiclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client talks to the local API with a bearer token. +type Client struct { + baseURL string + token string + http *http.Client +} + +// New builds a client for the given base URL and bearer token. +func New(baseURL, token string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + http: &http.Client{Timeout: 60 * time.Second}, + } +} + +// APIError is the structured error decoded from a { ok:false, error } envelope +// (or a bare non-2xx status when the body is not an envelope). +type APIError struct { + Status int + Code string + Meta map[string]any +} + +func (e *APIError) Error() string { + if e.Code != "" { + return fmt.Sprintf("API error %s (HTTP %d)", e.Code, e.Status) + } + return fmt.Sprintf("API error (HTTP %d)", e.Status) +} + +type envelope struct { + OK bool `json:"ok"` + Data json.RawMessage `json:"data"` + Error *struct { + Code string `json:"code"` + Meta map[string]any `json:"meta"` + } `json:"error"` +} + +// Get issues an authenticated GET and returns the raw data payload. +func (c *Client) Get(path string, query url.Values) (json.RawMessage, error) { + u := c.baseURL + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + return c.do(req) +} + +// Post issues an authenticated POST with a JSON body and returns the raw data +// payload. A nil body sends no request entity. +func (c *Client) Post(path string, body any) (json.RawMessage, error) { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(b) + } + req, err := http.NewRequest(http.MethodPost, c.baseURL+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return c.do(req) +} + +func (c *Client) do(req *http.Request) (json.RawMessage, error) { + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var env envelope + if len(raw) > 0 { + if err := json.Unmarshal(raw, &env); err != nil { + // Body is not an API envelope (e.g. a proxy error page) — surface status. + return nil, &APIError{Status: resp.StatusCode} + } + } + if resp.StatusCode >= 400 || !env.OK { + ae := &APIError{Status: resp.StatusCode} + if env.Error != nil { + ae.Code = env.Error.Code + ae.Meta = env.Error.Meta + } + return nil, ae + } + return env.Data, nil +} diff --git a/cli/internal/render/render.go b/cli/internal/render/render.go new file mode 100644 index 00000000..8f529bfc --- /dev/null +++ b/cli/internal/render/render.go @@ -0,0 +1,113 @@ +// Package render handles CLI output formatting (text vs JSON) and the mapping +// of errors to process exit codes per docs/arch/04-integration/02-cli.md. +package render + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" + "github.com/huhamhire/code-meeseeks/cli/internal/settings" + "gopkg.in/yaml.v3" +) + +// Stdout / Stderr are the sinks for rendered output and errors. They default to +// the process streams and are overridable in tests to capture output. +var ( + Stdout io.Writer = os.Stdout + Stderr io.Writer = os.Stderr +) + +// Mode selects the output format. +type Mode int + +const ( + // ModeYAML renders responses as YAML — the default, human-friendly view + // (k8s `-o yaml` style): structured yet readable, and derived generically + // from any response without per-command formatters. + ModeYAML Mode = iota + // ModeJSON renders responses as JSON — the stable machine contract for + // third-party agents / tooling. + ModeJSON +) + +// Process exit codes. +const ( + ExitOK = 0 + ExitGeneric = 1 + ExitAuth = 2 + ExitNotFound = 3 +) + +// Output writes API data to stdout per the selected mode: YAML (default, +// human-friendly) or JSON (machine contract). Both are generic transforms of +// the response data — no per-command formatting. +func Output(mode Mode, data json.RawMessage) error { + if mode == ModeJSON { + return writeJSON(data) + } + return writeYAML(data) +} + +func writeJSON(data json.RawMessage) error { + if len(data) == 0 { + fmt.Fprintln(Stdout, "null") + return nil + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + // Valid JSON we can't re-decode into `any` is unlikely; print verbatim. + fmt.Fprintln(Stdout, string(data)) + return nil + } + enc := json.NewEncoder(Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func writeYAML(data json.RawMessage) error { + if len(data) == 0 { + fmt.Fprintln(Stdout, "null") + return nil + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + // Not decodable as JSON — fall back to the raw payload. + fmt.Fprintln(Stdout, string(data)) + return nil + } + out, err := yaml.Marshal(v) + if err != nil { + return err + } + _, err = Stdout.Write(out) + return err +} + +// Errorln prints an error to stderr. +func Errorln(err error) { + fmt.Fprintln(Stderr, "error:", err) +} + +// ExitCodeFor maps an error to a process exit code. +func ExitCodeFor(err error) int { + if err == nil { + return ExitOK + } + if errors.Is(err, settings.ErrNoToken) { + return ExitAuth + } + var ae *apiclient.APIError + if errors.As(err, &ae) { + switch { + case ae.Status == 401 || ae.Status == 403: + return ExitAuth + case ae.Status == 404: + return ExitNotFound + } + } + return ExitGeneric +} diff --git a/cli/internal/render/render_test.go b/cli/internal/render/render_test.go new file mode 100644 index 00000000..860a88e5 --- /dev/null +++ b/cli/internal/render/render_test.go @@ -0,0 +1,77 @@ +package render + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" + "github.com/huhamhire/code-meeseeks/cli/internal/settings" +) + +func TestExitCodeFor(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {"nil", nil, ExitOK}, + {"no token", settings.ErrNoToken, ExitAuth}, + {"401 unauthorized", &apiclient.APIError{Status: 401}, ExitAuth}, + {"403 forbidden", &apiclient.APIError{Status: 403}, ExitAuth}, + {"404 not found", &apiclient.APIError{Status: 404}, ExitNotFound}, + {"500 server", &apiclient.APIError{Status: 500}, ExitGeneric}, + {"generic", errors.New("boom"), ExitGeneric}, + } + for _, c := range cases { + if got := ExitCodeFor(c.err); got != c.want { + t.Errorf("%s: got %d, want %d", c.name, got, c.want) + } + } +} + +func TestOutputYAMLAndJSON(t *testing.T) { + orig := Stdout + defer func() { Stdout = orig }() + var buf bytes.Buffer + Stdout = &buf + + data := json.RawMessage(`{"platform":"github","primary":["review-requested"]}`) + + // YAML (default, human-friendly) + buf.Reset() + if err := Output(ModeYAML, data); err != nil { + t.Fatalf("yaml output: %v", err) + } + if got := buf.String(); !strings.Contains(got, "platform: github") { + t.Errorf("yaml output missing key: %q", got) + } + + // JSON (machine contract) — must be valid, re-parseable JSON + buf.Reset() + if err := Output(ModeJSON, data); err != nil { + t.Fatalf("json output: %v", err) + } + var v map[string]any + if err := json.Unmarshal(buf.Bytes(), &v); err != nil { + t.Fatalf("json output not valid JSON: %v (%q)", err, buf.String()) + } + if v["platform"] != "github" { + t.Errorf("json output wrong platform: %v", v["platform"]) + } +} + +func TestOutputEmptyData(t *testing.T) { + orig := Stdout + defer func() { Stdout = orig }() + var buf bytes.Buffer + Stdout = &buf + if err := Output(ModeYAML, nil); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(buf.String()) != "null" { + t.Errorf("empty data should print null, got %q", buf.String()) + } +} diff --git a/cli/internal/settings/settings.go b/cli/internal/settings/settings.go new file mode 100644 index 00000000..6bb0fe49 --- /dev/null +++ b/cli/internal/settings/settings.go @@ -0,0 +1,161 @@ +// Package settings resolves the API base URL and bearer token used by the CLI, +// following the precedence documented in docs/arch/04-integration/02-cli.md: +// flag > env > CLI config file > local auto-discovery of the app config. +package settings + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Environment variable names for connection settings. +const ( + EnvAPIURL = "MEEBOX_API_URL" + EnvToken = "MEEBOX_TOKEN" +) + +const ( + defaultHost = "127.0.0.1" + defaultPort = 18765 +) + +// Overrides carries the highest-precedence values, typically from CLI flags. +type Overrides struct { + APIURL string + Token string +} + +// Settings is the resolved connection configuration. +type Settings struct { + APIURL string + Token string +} + +// ErrNoToken indicates no bearer token could be resolved from any source. +var ErrNoToken = errors.New("no API token: pass --token, set " + EnvToken + + ", or enable the service listener in the app") + +// Resolve applies the documented precedence (lowest first, overwritten by +// higher sources) and returns the final connection settings. +func Resolve(ov Overrides) (Settings, error) { + var s Settings + + // 4) lowest precedence: local auto-discovery from the app config. + if disc, ok := discoverFromAppConfig(); ok { + s = disc + } + // 3) CLI config file. + if cfg, ok := loadCLIConfig(); ok { + if cfg.APIURL != "" { + s.APIURL = cfg.APIURL + } + if cfg.Token != "" { + s.Token = cfg.Token + } + } + // 2) environment. + if v := os.Getenv(EnvAPIURL); v != "" { + s.APIURL = v + } + if v := os.Getenv(EnvToken); v != "" { + s.Token = v + } + // 1) highest precedence: flags. + if ov.APIURL != "" { + s.APIURL = ov.APIURL + } + if ov.Token != "" { + s.Token = ov.Token + } + + if s.APIURL == "" { + s.APIURL = fmt.Sprintf("http://%s:%d", defaultHost, defaultPort) + } + if s.Token == "" { + return Settings{}, ErrNoToken + } + return s, nil +} + +// appConfig is the slice of the app's main config we care about. +type appConfig struct { + Service struct { + Enabled bool `yaml:"enabled"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Token string `yaml:"token"` + } `yaml:"service"` +} + +// discoverFromAppConfig reads the app's main config at ~/.code-meeseeks/config.yaml +// and, when the service listener is enabled with a token, derives settings from +// it — giving same-machine, same-user integrations a zero-config experience. +// appHome returns the app's fixed data directory (~/.code-meeseeks), shared by the GUI +// and CLI. Both meebox configs live here (GUI: config.yaml, CLI: cli.yaml). +func appHome() (string, bool) { + home, err := os.UserHomeDir() + if err != nil { + return "", false + } + return filepath.Join(home, ".code-meeseeks"), true +} + +func discoverFromAppConfig() (Settings, bool) { + home, ok := appHome() + if !ok { + return Settings{}, false + } + data, err := os.ReadFile(filepath.Join(home, "config.yaml")) + if err != nil { + return Settings{}, false + } + var cfg appConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Settings{}, false + } + svc := cfg.Service + if !svc.Enabled || svc.Token == "" { + return Settings{}, false + } + host := svc.Host + if host == "" || host == "0.0.0.0" { + // 0.0.0.0 is a bind address, not a dial target — assume loopback locally. + host = defaultHost + } + port := svc.Port + if port == 0 { + port = defaultPort + } + return Settings{ + APIURL: fmt.Sprintf("http://%s:%d", host, port), + Token: svc.Token, + }, true +} + +// cliConfig is the CLI's own optional config file. +type cliConfig struct { + APIURL string `yaml:"api_url"` + Token string `yaml:"token"` +} + +// loadCLIConfig reads the CLI config at ~/.code-meeseeks/cli.yaml — co-located with the +// GUI config but a separate file, isolating CLI settings from the GUI's config.yaml. +func loadCLIConfig() (cliConfig, bool) { + home, ok := appHome() + if !ok { + return cliConfig{}, false + } + data, err := os.ReadFile(filepath.Join(home, "cli.yaml")) + if err != nil { + return cliConfig{}, false + } + var cfg cliConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cliConfig{}, false + } + return cfg, true +} diff --git a/cli/internal/settings/settings_test.go b/cli/internal/settings/settings_test.go new file mode 100644 index 00000000..09c0d562 --- /dev/null +++ b/cli/internal/settings/settings_test.go @@ -0,0 +1,57 @@ +package settings + +import ( + "errors" + "testing" +) + +// isolateHome points HOME / USERPROFILE at an empty temp dir so os.UserHomeDir +// resolves there — keeping Resolve's local auto-discovery (~/.code-meeseeks/*) from +// reading the developer's real app config and making these tests non-hermetic. +func isolateHome(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) +} + +func TestResolveFlagsWinOverEnv(t *testing.T) { + isolateHome(t) + t.Setenv(EnvAPIURL, "http://env:1") + t.Setenv(EnvToken, "env-token") + + got, err := Resolve(Overrides{APIURL: "http://flag:2", Token: "flag-token"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.APIURL != "http://flag:2" || got.Token != "flag-token" { + t.Fatalf("flags should win, got %+v", got) + } +} + +func TestResolveDefaultsURLWhenOnlyTokenGiven(t *testing.T) { + isolateHome(t) + t.Setenv(EnvAPIURL, "") + t.Setenv(EnvToken, "env-token") + + got, err := Resolve(Overrides{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Token != "env-token" { + t.Fatalf("expected env token, got %q", got.Token) + } + if got.APIURL == "" { + t.Fatalf("expected a default API URL") + } +} + +func TestResolveNoTokenErrors(t *testing.T) { + isolateHome(t) + t.Setenv(EnvAPIURL, "http://x:1") + t.Setenv(EnvToken, "") + + if _, err := Resolve(Overrides{}); !errors.Is(err, ErrNoToken) { + t.Fatalf("expected ErrNoToken, got %v", err) + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 00000000..9b078614 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,13 @@ +// Command meebox is the CLI client for the Code Meeseeks local API. +// +// It is a thin client over the desktop app's local HTTP API (see +// docs/arch/04-integration). All exposed capabilities are read-only; +// write operations (commenting, approving, publishing) are intentionally +// not provided — integrators implement those against the platform directly. +package main + +import "github.com/huhamhire/code-meeseeks/cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 88774b95..31e28935 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -78,7 +78,8 @@ ### 进行中 / 待办 ⏭️ - [ ] **可观测性扩展**:规则命中率、模型对比(token 用量已做)。 -- [ ] **外部集成扩展与 CLI**:下个版本方向——扩展与外部系统 / 工具的集成,并提供 CLI 能力。 +- [ ] **外部集成扩展与 CLI**:下个版本(0.9.0)方向——主进程内置本地 API + 独立 CLI,使外部 agent / 工具 + 可集成应用能力(设计见 [服务监听与本地 API](arch/04-integration/01-service-api.md) · [CLI 工具](arch/04-integration/02-cli.md))。 --- diff --git a/docs/arch/00-overview.md b/docs/arch/00-overview.md index d0d3d83a..17a41090 100644 --- a/docs/arch/00-overview.md +++ b/docs/arch/00-overview.md @@ -62,6 +62,9 @@ flowchart TB - [命令面板](03-gui/02-command-palette.md) —— 渲染层标题栏入口 + 分域命令注册表 - [消息通知](03-gui/03-notifications.md) —— `poller` 事件投影 + 主进程系统通知 / dock 角标 - [国际化](03-gui/04-i18n.md) —— react-i18next + 主 / 渲染双运行时 locale +- **`04-integration/`** —— 外部集成扩展与 CLI + - [服务监听与本地 API](04-integration/01-service-api.md) —— 主进程内置 HTTP API(IPC 之外的第二前端) + - [CLI 工具](04-integration/02-cli.md) —— Go 独立二进制,经本地 API 消费应用能力 - **`99-core/`** —— 基础设施 - [状态存储与数据模型](99-core/01-state-storage.md) —— `state-store` + `poller` 的 pr-state - [配置与凭据](99-core/02-config-and-secrets.md) —— `config` + 设置页 diff --git a/docs/arch/04-integration/01-service-api.md b/docs/arch/04-integration/01-service-api.md new file mode 100644 index 00000000..333af30f --- /dev/null +++ b/docs/arch/04-integration/01-service-api.md @@ -0,0 +1,142 @@ +# 服务监听与本地 API + +## 职责与边界 + +在主进程内提供一个**本地 HTTP API**,把应用既有的 PR 发现 / 浏览 / Agent 操作能力,以语言无关的 +线协议暴露给**外部 agent / 工具 / 脚本**(经 [CLI](02-cli.md) 或直接 HTTP 调用)。是继渲染层 IPC +之后的**第二个前端**——同一套主进程 service 层,换一层入站协议。 + +负责:服务监听开关与生命周期、bearer token 鉴权、请求路由与响应封装、把内部能力映射成稳定的 HTTP 契约。 + +**不负责**: + +- **写操作**(发评论、审批、发布草稿等对远端有副作用的动作)—— API 一律不开放;有集成需求由调用方 + 自行用平台 API 实现(见下「只读边界」)。 +- **多用户 / 远端服务形态** —— 仍是单用户本地应用,API 只是本机(或可选局域网)的入站通道,不引入账户体系。 +- 业务逻辑本身 —— 复用 IPC controller 同源的 service 层,不在 HTTP 侧另起一套实现。 + +## 核心设计 + +### 默认关闭、强制鉴权 + +- **默认不启用**:`config.yaml` 新增 `service` 段,`enabled` 默认 `false`。不开启则主进程不监听任何端口, + 对外零暴露面。 +- **强制 bearer token**:开启监听即要求 token——所有请求须带 `Authorization: Bearer `,缺失 / 不匹配 + 直接拒绝(401 + 错误码)。**没有「关闭鉴权」选项**。开关首次打开时若 token 为空则**自动生成**一枚高强度 + 随机 token(`crypto.randomBytes` → base64url / hex),保证「启用」与「有 token」原子绑定。 +- **比对用常数时间**:token 校验走常数时间比较,避免计时侧信道。 +- **token 落盘策略同既有凭据**:token 明文存 `config.yaml`(与平台 token / LLM key / 代理密码一致,经 + `SecretStore` 抽象读写、绝不进日志 / 异常栈),属已知风险、文件权限收紧。将来切 keychain 时随既有凭据一并迁移。 + +### 监听地址与端口 + +- **默认仅 loopback**:`host` 默认 `127.0.0.1`,只本机可达。这是绝大多数「本机外部 agent 集成」场景的安全默认。 +- **可选 `0.0.0.0`**:允许配置为监听所有网卡(供同网段的远端 agent / CI 节点接入)。这是**显式高风险选项**—— + 设置页与文档须给安全警示(token 即唯一防线、建议配合防火墙 / 反代)。绑 `0.0.0.0` 时 token 强度与保密尤为关键。 +- **固定安全默认端口**:默认 `18765`(可在配置中改)。取 10000+ 既避开拥挤的 8xxx 开发 / 系统服务段 + (3000 / 5173 / 8000 / 8080 / 8888 等),又稳落在**临时端口范围(ephemeral,Windows 49152+ / Linux 32768+) + 之下**——固定监听端口若落进临时段可能与系统瞬时出站 socket 抢占,`18765` 处于已注册端口段、无此风险。 + 端口被占用导致监听失败时,记录错误并以非致命方式提示(不阻塞应用启动)。 + +### HTTP 实现:最小依赖 + +- 用 Node 内置 `http` 起服务 + **极简手写路由**(按 method + path 模式匹配),**不引入 express 等重型框架**—— + 与项目「优先复用、最小依赖」一致,端点数量有限、无需框架。 +- 统一中间环节:JSON body 解析(带最大 body 上限)、请求超时、鉴权校验、错误 → 响应封装、访问日志 + (记 method / path / status / 耗时,**不记** token 与敏感 body)。 + +### 路由复用 service 层 + +- HTTP route handler 经与 IPC controller **同一个进程级 `ControllerContext`**(`getContext()`)取用 service + (`ctx.pr` / `ctx.orchestrator` / `ctx.poller` / `ctx.connectionRuntime` 等),**不重复业务逻辑**。 +- 原则:核心能力沉在 service 层,IPC 与 HTTP 各自只做**薄封装 + 协议适配**。新增 API 端点前,先确保对应能力 + 在 service 层有可复用方法(必要时把 controller 内联逻辑下沉到 service)。 + +### 只读边界(写操作的硬拒绝) + +- API 暴露的 Agent 操作**仅限只读工具**(`/describe`·`/review`·`/ask`·`/improve` 一族)。修改类工具 + (`/approve`·`/needswork`·`/publish` 等,见工具注册表 `kind: 'mutating'`)**在 API 层即被硬拒绝**—— + 与 Agent 自身的 grant 授权闸**相互独立**:即便某 PR 的 AutoPilot grants 授予了写权限,经 API 发起的指令 + 仍不得触发写工具。 +- 由此「**不支持二次确认**」自然成立:API 无交互确认通道,只读指令直接执行、无需确认;需确认的写操作干脆不开放。 + +### 生命周期与热生效 + +- **启动时机**:在主进程完成连接 / IPC 初始化(`ControllerContext` 就绪)之后、轮询启动前后启动监听器; + 仅当 `service.enabled` 为真才实际 `listen`。 +- **优雅关闭**:应用退出(`before-quit`)时关闭监听、停止接收新连接、放行 in-flight 请求后退出。 +- **热生效**:`enabled` / `host` / `port` / token 变更 → 写盘 + 内存同步 + **停旧监听起新监听**(端口 / 地址变更 + 必然重建;token 变更即时生效,旧 token 立刻失效)。与既有「保存即热生效」一致,无需重启应用。 + +### 并发与资源 + +- 只读 `GET` 端点可并发处理。 +- Agent 写入型动作(触发 review / 指令 / 聊天)**复用既有 run 队列与 Orchestrator 的单工作者 + 并发上限**, + 不绕过调度——API 触发与 GUI 触发在同一队列里排队,互不抢占语义保持一致。 + +## 数据 / 接口契约 + +### 配置(`config.yaml` 顶层 `service`) + +```yaml +service: + enabled: false # 总开关;默认关 = 不监听、零暴露面 + host: 127.0.0.1 # 监听地址;可设 0.0.0.0(高风险,需安全警示) + port: 18765 # 固定安全默认端口(10000+,避开 8xxx 拥挤段且低于临时端口范围),可改 + token: '' # bearer token;启用且为空时自动生成;明文落盘(同既有凭据策略) +``` + +### 鉴权 + +- 请求头:`Authorization: Bearer `;缺失 / 不匹配 → `401` + 错误码。 +- token 经 `SecretStore` 读写,响应 / 日志中不回显。 + +### 统一响应封套 + +```jsonc +// 成功 +{ "ok": true, "data": } +// 失败(复用 AppError 的 code + 可序列化 meta;前端 / CLI 按码本地化) +{ "ok": false, "error": { "code": "ESV0001", "meta": { /* ... */ } } } +``` + +- HTTP 状态码与语义对齐:`400` 校验失败 / `401` 未授权 / `403` 写操作不开放 / `404` 资源不存在 / + `409` 冲突 / `500` 内部错误。 +- 新增 **`SV`(service)错误码领域**(`E`+`SV`+四位,见 [错误码规范](../99-core/04-error-codes.md)): + 如 token 无效、写操作被拒、监听未就绪等;与既有 `AG`/`PR`/`NT` 等领域并列。 + +### 端点(`/api/v1`,逐条对应 [CLI](02-cli.md) 命令) + +| Method & Path | 用途 | 复用的内部能力 | +| --- | --- | --- | +| `GET /api/v1/categories` | 当前启用平台下可用的分类标签:一级(`PrDiscoveryFilter`)+ 二级(状态 / 合并态筛选),按平台能力裁剪 | 平台能力位 + 列表筛选语义 | +| `GET /api/v1/prs` | PR 列表(**不分页**、返回全部基础信息);query:`primary`(一级)/`secondary`(二级)/`q`(检索:标题 / 仓库 / 作者 / 编号) | `prs:list` 同源(`StoredPullRequest[]`) | +| `GET /api/v1/prs/{localId}` | 描述详情(标题 / 描述 / 作者 / 分支 / 时间 / 状态 / 合并态) | `StoredPullRequest` 概要 | +| `GET /api/v1/prs/{localId}/diff` | 变更文件列表;带 `?path=&side=base\|head` 时取单文件内容 | `diff:listChangedFiles` / `diff:getFileContent` 同源 | +| `GET /api/v1/prs/{localId}/activity` | 动态(评论 / 提交更新 / 评审决断归并的时间线) | `diff:listActivity` 同源 | +| `GET /api/v1/prs/{localId}/commits` | 提交列表(`PrCommit[]`) | `diff:listCommits` 同源 | +| `GET /api/v1/prs/{localId}/reviewers` | 评审人审批状态(`Reviewer[]`,含各人 `status`) | `StoredPullRequest.reviewers` | +| `GET /api/v1/prs/{localId}/agent` | Agent 当前执行状态(`AgentSession`:status / 进度 / 总结 / 建议) | `agent:getSession` 同源 | +| `GET /api/v1/prs/{localId}/agent/conversation` | 历史会话(`AgentMessage[]`) | `agent:getConversation` 同源 | +| `POST /api/v1/prs/{localId}/agent/review` | 执行 auto review(固定评审微流程 describe→review→[追问]→总结) | `agent:run` 同源 | +| `POST /api/v1/prs/{localId}/agent/instruct` | 发送 Agent 指令(**仅只读工具**:describe / review / ask / improve;写工具硬拒绝、无二次确认) | 只读工具派发(复用 run 队列) | +| `POST /api/v1/prs/{localId}/agent/chat` | 发送自然语言聊天(可触发 Agent 规划与任务执行) | `agent:ask` / `agent:enqueueMessage` 同源 | + +- `localId` 为跨平台稳定 PR 标识(内部哈希,非平台 `remoteId`);所有 PR 维度端点以它定位。 +- 过程步骤(transcript)暂不在初版 API 内开放,作为将来扩展位(见下)。 + +### 新增 IPC(设置页驱动) + +- `config:setService`:写 `service` 段 → 写盘 + 内存同步 + 重建监听器(热生效)。 +- `config:generateServiceToken`:重新生成 token → 写盘 + 即时失效旧 token,返回新 token 供 UI 展示 / 复制。 + +## 扩展与注意事项 + +- **加新端点先下沉 service**:HTTP 与 IPC 必须共用 service 方法,避免逻辑分叉;端点是 service 能力的薄投影。 +- **写操作边界是硬约束**:只读工具白名单在 API 层强校验,独立于 Agent grant 闸;新增工具时同步确认其 + `kind` 与是否纳入 API 白名单,默认排除一切 `mutating`。 +- **`0.0.0.0` 安全警示不可省**:设置页与使用文档须明确暴露范围与风险;token 是唯一防线。 +- **端口冲突**:监听失败以非致命方式提示,不拖垮应用启动;提示用户改端口。 +- **进度推送是将来扩展位**:初版以「轮询 `GET .../agent` 拉状态」为主;如需实时进度,可在同一监听器上加 + SSE / WebSocket 推送 Agent step 事件(复用现有 `agent:stepProgress` 广播),不改既有 REST 契约。 +- **契约稳定性**:`/api/v1` 前缀预留版本演进位;响应封套与错误码领域一旦发布需保持兼容(CLI 与第三方依赖它)。 diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md new file mode 100644 index 00000000..1d58b33e --- /dev/null +++ b/docs/arch/04-integration/02-cli.md @@ -0,0 +1,119 @@ +# CLI 工具(meebox) + +## 职责与边界 + +提供一个**独立分发的跨平台命令行客户端**,经[本地 API](01-service-api.md) 消费应用能力,供外部 +agent / 脚本 / CI 把 meebox 的 PR 发现、浏览与 Agent 操作纳入自动化流程。命令名 **`meebox`**。 + +> 面向用户的使用说明见 [docs/guide/06-cli.md](../../guide/06-cli.md)。 + +负责:把 API 端点封装成顺手的命令树、解析连接 / 鉴权配置、按人 / 机两种消费方式输出(文本 / JSON)、 +约定退出码。 + +**不负责**: + +- 业务逻辑 —— CLI 是 API 的瘦客户端,不内置任何评审 / 平台逻辑。 +- **写操作**(发评论、审批、发布等)—— 不提供对应命令;API 本就不开放(见 [服务端的只读边界](01-service-api.md))。 +- 桌面应用本体 —— CLI **不内嵌进安装包**,是独立可分发物(见下「分发」)。 + +## 核心设计 + +### 技术栈与仓库形态决策(Go 评估) + +CLI 优先技术栈定为 **Go**,并就「是否内嵌当前项目」给出结论——**该问题分两层,结论不同**: + +1. **是否打进 Electron 安装包:否。** CLI 面向外部自动化、经 HTTP 与应用通信,无需随桌面包分发;打进去 + 只会无谓增大安装体积。二者是**相互独立的可分发物**。 +2. **源码是否放进本仓库(monorepo):是,但作为独立的顶层 `cli/` 目录、自带 `go.mod`,不纳入 npm + workspaces / Nx。** Go 有独立的模块系统与构建缓存,与 npm/Nx 的工程模型不兼容;强行包成 Nx project + (run-commands 壳)徒增复杂、且让 Go 工具链成为全仓开发的前置。CLI 与主工程的**唯一耦合是 HTTP/JSON + 线协议**(语言无关),无代码级共享,故「同仓、独立构建」最自然。 + +**为什么 Go 适合做这个 CLI**:静态链接小体积二进制、`GOOS`/`GOARCH` 一条命令交叉编译出全平台、启动快、 +无运行时依赖——正是分发型 CLI 的理想形态。相较把 Node/TS 用 pkg / SEA 打包(产物数十 MB、交叉编译脆弱、 +冷启动慢),Go 在分发体验上明显占优。 + +**代价与应对——类型契约同步**:Go 端无法编译期复用 `shared` 的 TS 类型。 + +- **初期**:API 端点少而稳,**手写 Go struct 对齐文档契约**即可(成本低)。 +- **将来**:若契约增长,引入 OpenAPI / JSON Schema 作单一事实源,生成 TS 侧校验 + Go 侧 client, + 消除手工漂移。 + +### 连接与鉴权 + +CLI 需 API base URL + token。来源优先级(高 → 低): + +1. 命令行 flag:`--api-url` / `--token`; +2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN`; +3. CLI 自身配置文件 `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 同目录、独立文件,隔离二者配置); +4. **本机自动发现**:同机同用户时,读用户主目录下的应用主配置 `~/.code-meeseeks/config.yaml` 的 `service` + 段,自动取 `host`/`port`/`token`——本机集成**零配置**开箱即用。 + +远端(服务端绑 `0.0.0.0`)场景无法自动发现,须显式给 `--api-url` + `--token`。token 缺失即报鉴权错误。 + +### 命令结构 + +```text +meebox [全局 flag] <组> <命令> [参数] + +全局 flag:--api-url · --token · --output (yaml|json) · --quiet +``` + +| 命令 | 用途 | 对应 API | +| --- | --- | --- | +| `meebox categories` | 列当前启用平台下可用的分类标签(一级 + 二级) | `GET /categories` | +| `meebox pr list [--primary <一级>] [--secondary <二级>] [--query <检索>]` | PR 列表(不分页、全部基础信息) | `GET /prs` | +| `meebox pr show ` | 描述详情 | `GET /prs/{id}` | +| `meebox pr diff [--file ] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | `GET /prs/{id}/diff` | +| `meebox pr activity ` | 动态(时间线) | `GET /prs/{id}/activity` | +| `meebox pr commits ` | 提交列表 | `GET /prs/{id}/commits` | +| `meebox pr reviewers ` | 评审人审批状态 | `GET /prs/{id}/reviewers` | +| `meebox agent status ` | Agent 当前执行状态 | `GET /prs/{id}/agent` | +| `meebox agent history ` | 历史会话 | `GET /prs/{id}/agent/conversation` | +| `meebox agent review ` | 执行 auto review | `POST /prs/{id}/agent/review` | +| `meebox agent instruct [args]` | 发送 Agent 指令(仅只读:describe / review / ask / improve) | `POST /prs/{id}/agent/instruct` | +| `meebox agent chat ` | 自然语言聊天(可触发任务执行) | `POST /prs/{id}/agent/chat` | + +- `` 为 PR 的 `localId`(由 `pr list` 输出获得)。 +- 写工具不在 `instruct` 白名单内;传入即被服务端拒绝(CLI 也可前置友好报错)。 + +### 输出与退出码 + +- **`--output yaml`(默认)**:把响应渲染为 YAML(类 k8s `-o yaml`)——结构化又可读,便于人交互式查看。 + 与 JSON 一样是对响应数据的**通用转换**,不做逐命令表格 / formatter(省去手写 struct 的契约同步负担)。 +- **`--output json`**:原样输出 API `data`,供外部 agent / 脚本机器消费。agent 传参无门槛,故默认面向人优化(YAML)、 + 机器集成显式取 `json`;两者字段形状同源、皆稳定。 +- **退出码约定**:`0` 成功;非 0 表错误并按类别区分(如 `2` 鉴权失败、`3` 资源不存在、`1` 通用错误); + 错误信息打 `stderr`,携带服务端返回的错误码(`ESV*` 等),便于脚本分支处理。 + +### 实现选型 + +- Go + 命令树库(如 cobra)+ 标准 `net/http` client,**最小依赖**。 +- 错误码 / 响应封套与服务端契约一一对齐(见 [服务端契约](01-service-api.md))。 + +## 数据 / 接口契约 + +- **配置来源优先级**:flag > env(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)> CLI 配置文件 + (`~/.code-meeseeks/cli.yaml`)> 本机 `~/.code-meeseeks/config.yaml` 自动发现。 +- **输出模式**:`yaml`(默认,人,类 k8s `-o yaml`)/ `json`(机,输出 API `data`);均为响应数据的通用转换。 +- **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 +- **二进制与压缩包命名**:`meebox-cli---.`(Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`), + 附 `.sha256` 校验和。`` 与应用版本对齐(同一 `v*` tag)。 + +## 分发与 CI + +- **覆盖平台**:Windows x64、macOS arm64、Linux x64 / arm64。 +- **随主工程一起发布**:在发布流程中增加一个 **Go 构建 job**(`actions/setup-go` + `GOOS`/`GOARCH` 交叉编译 + 矩阵;可选 GoReleaser 简化),产出四平台压缩包 + 校验和,与桌面安装包一并上传到**同一个 GitHub Release** + (由现有 `v*` tag 触发,见 [发布流程](../../../AGENTS.md))。 +- 版本号与应用同源(同 tag),确保 CLI 与服务端 API 契约版本可对应。 + +## 扩展与注意事项 + +- **只读边界**:写操作显式不提供;新增命令前先确认对应 API 端点已存在且为只读。 +- **加新命令先加端点**:CLI 不得绕过 API 直连应用内部;能力缺口先在[服务端](01-service-api.md)补端点。 +- **本机自动发现的边界**:仅同机同用户可读主目录下的 `~/.code-meeseeks/config.yaml`;远端 / 跨用户必须显式配 URL + token。 +- **契约漂移防护**:初期手写 struct 务必随服务端契约同步更新;契约增长后转 OpenAPI / Schema 代码生成。 +- **JSON 优先稳定**:`--output json` 是自动化主路径,其字段形状视为对外契约,演进需保持兼容。 +- **代理走环境变量**:HTTP client 用 Go `net/http` 默认 transport,天然遵循标准 `HTTP(S)_PROXY` / + `NO_PROXY`;loopback(`127.0.0.1` / `localhost`)默认直连不走代理——无需自实现代理逻辑。 diff --git a/docs/arch/README.md b/docs/arch/README.md index 85b05123..31c9cac0 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -47,6 +47,9 @@ docs/arch/ │ ├── 02-command-palette.md 命令面板(标题栏入口 / 两级选择 / 按语言搜索 / 注册表 + 分域) │ ├── 03-notifications.md 消息通知(poll 事件投影 / 系统通知 toast / macOS dock 角标 / OS 权限降级) │ └── 04-i18n.md 国际化(react-i18next / 双运行时 / key 命名 / 翻译规范 / 模板翻译) +├── 04-integration/ 外部集成扩展与 CLI +│ ├── 01-service-api.md 服务监听与本地 API(loopback 默认 / 强制 token / 只读边界 / 路由复用 service) +│ └── 02-cli.md CLI 工具(Go 独立二进制 / 命令树 / 本机自动发现 / 跨平台分发) └── 99-core/ 基础设施 ├── 01-state-storage.md 状态存储与数据模型(StateStore / per-PR 目录 / 存储模型 + 业务生命周期) ├── 02-config-and-secrets.md 配置与凭据(config.yaml / SecretStore / 设置页 / 首启向导) diff --git a/docs/development/packaging-release.md b/docs/development/packaging-release.md index 9a8b64bd..092ce693 100644 --- a/docs/development/packaging-release.md +++ b/docs/development/packaging-release.md @@ -42,3 +42,26 @@ 或提供 Homebrew Cask。 - **升级公证**:见上;同时 mac 段需加回 hardenedRuntime + entitlements + notarize(entitlements 已备好 `disable-library-validation` 让嵌入式 python 在 hardened runtime 下能加载第三方 dylib)。 + +## 发布前置清单(打 tag 前必做) + +在**同一批改动**里完成,随发版经 `dev` → `master`——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release: + +1. **版本号** —— 把 [apps/desktop/package.json](../../apps/desktop/package.json) 的 `version` 改成目标版本(去 `v` 前缀,预发布带后缀如 `0.5.0-alpha.1`)。electron-builder 的 `artifactName: code-meeseeks-${version}-...` 直取此值——不改则安装包文件名与 tag 不符。改完 `npm install` 同步 lockfile。 +2. **CHANGELOG** —— 把 [CHANGELOG.md](../../CHANGELOG.md) 的 `## [Unreleased]` 改名为 `## [<版本>] - `,并在文件底部补 `[<版本>]: …/compare/…` 链接引用。**发布即消费掉 Unreleased、不留空段**;下一笔开发期 changelog 改动时再新建。release.yml 按 `## [<版本>]` 字面抽段注入 Release 正文——缺段则正文回退、无变更说明。**若正式版内容来自此前的 alpha/预发布**:开发期通常无独立 Unreleased(内容已在预发布段),直接把该预发布段改名为正式版段、删去对应 `[-alpha.N]:` 链接引用(内容并入正式版段,不留空壳 stub);尚无对应正式版的其它预发布段保留。 +3. **校对** —— 确认 `## [<版本>]` 段已覆盖自上版本以来合入 `dev` 的全部要点(新增 / 变更 / 修复)。 + +tag 名与 package.json 版本必须一致(`v<版本>`)。名含 `-` 的预发布 tag(如 `-alpha.N`)由 release.yml 自动标 prerelease 且不抢占 Latest。 + +**版本号规则(`-dev`)**:每次正式发版后,`dev` 立即把 [apps/desktop/package.json](../../apps/desktop/package.json) 切到**下一版的 `-dev` 预发布号**(如发完 `0.6.0` 即切 `0.7.0-dev`,`npm install` 同步 lockfile),标记开发态。`-dev` 仅作开发标记——**不打 tag、不发版**;发版时按上面改成目标号(`0.7.0-alpha.N` 或 `0.7.0`)。`-dev` 是合法 semver(`0.6.0` < `0.7.0-dev` < `0.7.0`),不影响更新检测([update-check.ts](../../apps/desktop/src/main/utils/update-check.ts) 用 `semver.gt` 比对、不用 range)与构建。 + +## CHANGELOG 撰写风格(面向用户、求简) + +- 版本引言 `>` 区直接进入「本版重点」、要点用**无序列表**排版,不堆成长句,**不写「首个 / 第 N 个正式版」之类的版本序数引言**; +- 新增 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止; +- 重构类任务**前后端合并**为一条总结、不展开实现细节; +- 修复 **不写「怎么修的」机制**,每条一句话只述修复的现象/影响; +- 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良; +- **安装 / 升级注意事项**(版本引言里的 ⚠️ 警示,如先卸载旧版、per-machine 提权等)属安全关键信息,**保留完整、不参与精简**——这些会随 release.yml 注入 GitHub Release 正文,删减会让用户漏看升级风险; +- **分段标题用中文 + emoji**:`### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全`(对应 Keep a Changelog 的 Added / Changed / Deprecated / Removed / Fixed / Security); +- 外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md new file mode 100644 index 00000000..71da6394 --- /dev/null +++ b/docs/guide/06-cli.md @@ -0,0 +1,96 @@ +# CLI 命令行工具(meebox) + +`meebox` 是随发布提供的跨平台命令行工具,经本机的「本地 API 服务」访问应用能力,便于把 PR 浏览与 +评审 Agent 操作接入脚本、CI 或外部 agent。命令行只做**浏览与评审操作**,不含评论发送等写操作。 + +> 面向开发者的接口 / 架构细节见 [../arch/04-integration/](../arch/04-integration/01-service-api.md)。 + +## 1. 开启本地 API 服务 + +CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → 集成** 开启: + +- 打开「本地 API 服务」开关(首次开启会自动生成一枚访问令牌)。 +- **监听地址**:默认 `http://127.0.0.1:18765`(仅本机可达)。如需被同网段的其他机器 / CI 访问,可把 + host 改为 `0.0.0.0` 或本机局域网 IP——此时**令牌是唯一防线**,请妥善保密并配合防火墙。 +- **访问令牌**:可显示 / 复制 / 重新生成;重新生成后旧令牌立即失效。 + +## 2. 获取 CLI + +从 [GitHub Release](https://github.com/huhamhire/code-meeseeks/releases) 下载对应平台的压缩包 +(`meebox-cli-<版本>-<系统>-<架构>.zip` / `.tar.gz`),解压后把 `meebox` 可执行文件放到 `PATH` 中。 + +覆盖平台:Windows x64、macOS arm64、Linux x64 / arm64。 + +## 3. 连接方式 + +`meebox` 按以下优先级解析 API 地址与令牌(高 → 低): + +1. 命令行参数:`--api-url` / `--token` +2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN` +3. CLI 配置文件:`~/.code-meeseeks/cli.yaml`(字段 `api_url` / `token`) +4. **本机自动发现**:同机同用户时,自动读应用配置 `~/.code-meeseeks/config.yaml` 的服务监听设置 + +因此**在开启服务的本机上零配置即可用**——直接运行命令,自动读取本机地址与令牌: + +```bash +meebox pr list +``` + +远端访问(服务监听 `0.0.0.0`)需显式提供地址与令牌: + +```bash +meebox --api-url http://<主机>:18765 --token <令牌> pr list +# 或经环境变量 +export MEEBOX_API_URL=http://<主机>:18765 +export MEEBOX_TOKEN=<令牌> +meebox pr list +``` + +## 4. 命令 + +```text +meebox [全局参数] <组> <命令> [参数] +``` + +| 命令 | 用途 | +| --- | --- | +| `meebox categories` | 列出当前平台可用的分类标签(一级发现分类 + 二级状态 / 合并态筛选) | +| `meebox pr list [--primary <一级>] [--secondary <二级>] [--query <检索>]` | PR 列表(不分页),支持按分类与关键字过滤 | +| `meebox pr show ` | PR 描述详情 | +| `meebox pr diff [--file <路径>] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | +| `meebox pr activity ` | 活动时间线(评论 / 提交 / 评审决断) | +| `meebox pr commits ` | 提交列表 | +| `meebox pr reviewers ` | 评审人审批状态 | +| `meebox agent status ` | 评审 Agent 当前执行状态 | +| `meebox agent history ` | 历史会话 | +| `meebox agent review ` | 执行一次自动评审 | +| `meebox agent instruct <指令> [参数]` | 发送评审指令(`describe` / `review` / `ask` / `improve`) | +| `meebox agent chat <消息>` | 发送自然语言消息(可触发 Agent 任务) | + +其中 `` 为 PR 的本地标识,由 `meebox pr list` 输出获得。 + +## 5. 输出格式 + +全局参数 `--output`: + +- **`yaml`(默认)**:结构化又易读(类 kubectl `-o yaml`),适合人在终端查看。 +- **`json`**:适合脚本 / 外部 agent 机器解析。 + +```bash +meebox pr list --output json | jq '.[].title' +``` + +**退出码**:`0` 成功;非 0 表错误(`2` 鉴权失败、`3` 资源不存在、`1` 其他);错误信息打到 `stderr`。 + +## 网络代理 + +`meebox` 遵循标准的 HTTP 代理环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`,大小写均可),无需额外配置: + +- 访问**本机**服务(`127.0.0.1` / `localhost`)自动直连、不走代理。 +- 访问**远端**服务(如经 `0.0.0.0` 暴露的机器)时,若设了 `HTTP_PROXY` 则经其出网;可用 `NO_PROXY` 排除特定主机。 + +## 注意事项 + +- **只读取向**:CLI 不提供评论发送、审批、合并等写操作;有此需求请自行对接代码平台。 +- **令牌安全**:令牌明文存于 `~/.code-meeseeks/config.yaml`(同其他凭据);监听 `0.0.0.0` 暴露到局域网时尤需保密, + 并及时通过「重新生成」吊销泄露的令牌。 diff --git a/docs/guide/README.md b/docs/guide/README.md index 1b4afb27..445da964 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -14,6 +14,7 @@ Code Meeseeks 是本地运行的 PR 评审客户端:连上你的代码平台 | [03 · 网络代理配置](03-proxy.md) | 内网 / 受限网络下统一走 HTTP 代理出网 | | [04 · 配置文件参考](04-config-reference.md) | `config.yaml` 完整结构与各配置项功能说明(含高级参数) | | [05 · 自定义评审规则](05-rules.md) | 编写规则 `.md` 文件:frontmatter 命中条件 + 正文注入 AI 的评审指令 | +| [06 · CLI 命令行工具](06-cli.md) | 开启本地 API 服务 + 用 `meebox` 命令行浏览 PR / 操作评审 Agent(供脚本 / 外部 agent 集成) | ## 通用须知 diff --git a/packages/ipc/src/config.ts b/packages/ipc/src/config.ts index 2834f3f5..93e6ea1c 100644 --- a/packages/ipc/src/config.ts +++ b/packages/ipc/src/config.ts @@ -60,6 +60,13 @@ export interface ConfigChannels { request: { base_url: string; token: string; kind?: PlatformKind }; response: PingResult; }; + /** + * 写入本地 API 服务监听配置(开关 / host / port / token)到 config.yaml,并**热重建**监听器 + * (开关 / 地址 / 端口变更停旧起新;token 变更下次请求即生效)。见 docs/arch/04-integration/01-service-api.md。 + */ + 'config:setService': { request: { service: Config['service'] }; response: void }; + /** 重新生成 bearer token 并写盘(旧 token 即时失效),返回新 token 供设置页展示 / 复制。 */ + 'config:generateServiceToken': { request: void; response: { token: string } }; /** * 配置过程中自动把连接 + LLM 草稿写入 config.yaml(防丢失),但**不应用到运行时** * (不 reconfigure adapter/poller、不更新内存 config)——重启或点底栏「保存」才生效。 diff --git a/packages/shared/package.json b/packages/shared/package.json index bd68cf72..67a90044 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,11 +11,13 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint src --max-warnings=0" + "test": "vitest run", + "lint": "eslint src tests --max-warnings=0" }, "nx": { "targets": { "typecheck": { "cache": true }, + "test": { "cache": true }, "lint": { "cache": true } } } diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 7eeb1e26..34b6b1cd 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -157,6 +157,20 @@ export const ProxySchema = z.object({ }); export type ProxyConfig = z.infer; +/** + * 本地 API 服务监听(见 docs/arch/04-integration/01-service-api.md)。默认关闭、零暴露面;开启即**强制** + * bearer token 鉴权(token 为空时由主进程在启用时自动生成)。`host` 默认仅 loopback(127.0.0.1),可设 + * `0.0.0.0` 暴露到局域网——高风险、需安全警示。`port` 固定安全默认 18765(10000+,避开常见开发端口且低于 + * 临时端口范围)。token 明文落盘(同既有凭据策略,经 SecretStore 抽象、绝不进日志)。 + */ +export const ServiceSchema = z.object({ + enabled: z.boolean().default(false), + host: z.string().default('127.0.0.1'), + port: z.number().int().min(1).max(65535).default(18765), + token: z.string().default(''), +}); +export type ServiceConfig = z.infer; + /** * LLM 上下文长度(token):裁剪输入内容的全局上限,透传 pr-agent `CONFIG__MAX_MODEL_TOKENS` / * `CONFIG__CUSTOM_MODEL_MAX_TOKENS`。默认 128000(与现代主流模型上下文匹配);**对本地 CLI 模式 @@ -279,6 +293,8 @@ export const ConfigSchema = z.object({ check_enabled: z.boolean().default(true), }) .default({}), + /** 本地 API 服务监听(默认关闭)。见上 {@link ServiceSchema}。 */ + service: ServiceSchema.default({}), /** * 消息通知(见 docs/arch/03-gui/03-notifications.md)。enabled 为总开关;关闭后既不弹系统通知也不亮 dock 角标。 * new_pr / reply / mention 按事件类型分别控制系统通知(toast)是否弹出。macOS dock「待回应」计数角标无独立 diff --git a/packages/shared/src/error-code.ts b/packages/shared/src/error-code.ts index 52a5e9df..603df53f 100644 --- a/packages/shared/src/error-code.ts +++ b/packages/shared/src/error-code.ts @@ -4,7 +4,7 @@ */ /** 领域标签(两字母大写)。新增领域追加在末尾。 */ -export type ErrorDomain = 'AG' | 'UI' | 'CF' | 'NT' | 'PR'; +export type ErrorDomain = 'AG' | 'UI' | 'CF' | 'NT' | 'PR' | 'SV'; /** * 错误码注册表(唯一真相源):`E` + 两字母领域 + 四位数字。新增码在此登记,并在渲染层各 locale 补 @@ -45,6 +45,21 @@ export const ERROR_CODES = { PR_FORBIDDEN: 'EPR0006', /** 没有活动连接,无法按链接打开 PR。 */ PR_NO_ACTIVE_CONNECTION: 'EPR0007', + /** + * 本地 API 服务(service listener)域错误码。**经 HTTP 返回给外部 CLI / 客户端**,不经渲染层 i18n + * (故暂不在 renderer locale 登记;如未来在 GUI 展示再补 `errors.`,formatBackendError 已有兜底)。 + * 见 docs/arch/04-integration/01-service-api.md。 + */ + /** 未分类服务错误(兜底)。 */ + SV_UNCLASSIFIED: 'ESV0000', + /** 鉴权失败:缺失 / 不匹配 bearer token(→ HTTP 401)。 */ + SV_UNAUTHORIZED: 'ESV0001', + /** 写操作不经本地 API 开放(→ HTTP 403)。 */ + SV_WRITE_NOT_ALLOWED: 'ESV0002', + /** 路由 / 资源不存在(→ HTTP 404)。 */ + SV_NOT_FOUND: 'ESV0003', + /** 请求体校验失败(→ HTTP 400)。 */ + SV_BAD_REQUEST: 'ESV0004', } as const; /** 已登记的错误码字面量联合(抛错时只能用注册过的码,防笔误)。 */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 91f4350e..794b0664 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './language.js'; export * from './platform.js'; export * from './poller-contract.js'; export * from './pr-agent-status.js'; +export * from './pr-filter.js'; export * from './sync-progress.js'; export * from './theme.js'; export * from './tool-registry.js'; diff --git a/packages/shared/src/pr-filter.ts b/packages/shared/src/pr-filter.ts new file mode 100644 index 00000000..40696902 --- /dev/null +++ b/packages/shared/src/pr-filter.ts @@ -0,0 +1,83 @@ +import type { LocalPrStatus, StoredPullRequest } from './poller-contract.js'; +import type { PrDiscoveryFilter } from './platform.js'; + +/** + * PR 列表筛选与检索的**纯谓词**(单一真相源)。渲染层侧栏与本地 API 的 PR 列表端点共用同一套语义, + * 避免两处各写一份过滤逻辑而漂移。仅做无副作用的判定 / 过滤,不含 UI(计数、可见性、分组属各自表现层)。 + * + * 二级筛选 `PrSecondaryFilter`:`'all'` 不限定;`LocalPrStatus`(本人评审决断 pending/approved/needs_work) + * 按 `localStatus` 匹配;`'conflict'` / `'mergeable'` 是跨 localStatus 横切的远端合并态筛选。 + */ +export type PrSecondaryFilter = 'all' | LocalPrStatus | 'conflict' | 'mergeable'; + +/** 二级筛选全集(与 {@link PrSecondaryFilter} 同步;本地 API 的分类标签据此列出)。 */ +export const PR_SECONDARY_FILTERS: readonly PrSecondaryFilter[] = [ + 'all', + 'pending', + 'approved', + 'needs_work', + 'conflict', + 'mergeable', +]; + +/** 一级(平台发现分类)匹配:未指定一级 = 不限定;否则按 PR 携带的 discoveryFilters 命中判定。 */ +export function matchesDiscoveryFilter( + pr: StoredPullRequest, + primary?: PrDiscoveryFilter, +): boolean { + return !primary || (pr.discoveryFilters?.includes(primary) ?? false); +} + +/** 二级筛选匹配(状态 / 合并态)。 */ +export function matchesSecondaryFilter( + pr: StoredPullRequest, + secondary: PrSecondaryFilter, +): boolean { + switch (secondary) { + case 'all': + return true; + case 'conflict': + return pr.hasConflict === true; + case 'mergeable': + return pr.mergeStatus?.canMerge === true; + default: + return pr.localStatus === secondary; + } +} + +/** 检索匹配:空查询恒真;否则在 标题 / 仓库 / 作者 / 编号 拼成的串里做大小写无关子串匹配。 */ +export function matchesPrQuery(pr: StoredPullRequest, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return [ + pr.title, + pr.repo.projectKey, + pr.repo.repoSlug, + pr.author.displayName, + pr.author.name, + pr.remoteId, + ] + .join(' ') + .toLowerCase() + .includes(q); +} + +/** 筛选条件(各项可省,省略即不限定)。 */ +export interface PrFilterCriteria { + primary?: PrDiscoveryFilter; + secondary?: PrSecondaryFilter; + query?: string; +} + +/** 按 一级 + 二级 + 检索 顺序过滤 PR 列表。 */ +export function filterPullRequests( + prs: StoredPullRequest[], + criteria: PrFilterCriteria, +): StoredPullRequest[] { + return prs.filter( + (p) => + matchesDiscoveryFilter(p, criteria.primary) && + matchesSecondaryFilter(p, criteria.secondary ?? 'all') && + matchesPrQuery(p, criteria.query ?? ''), + ); +} diff --git a/packages/shared/tests/pr-filter.test.ts b/packages/shared/tests/pr-filter.test.ts new file mode 100644 index 00000000..9bf461ce --- /dev/null +++ b/packages/shared/tests/pr-filter.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import type { StoredPullRequest } from '../src/poller-contract.js'; +import { + PR_SECONDARY_FILTERS, + filterPullRequests, + matchesDiscoveryFilter, + matchesPrQuery, + matchesSecondaryFilter, +} from '../src/pr-filter.js'; + +/** 最小 StoredPullRequest 构造:只填谓词用到的字段,其余以 double-cast 略过。 */ +function mkPr(over: Partial): StoredPullRequest { + return { + title: 'Fix login bug', + repo: { projectKey: 'PROJ', repoSlug: 'web-app' }, + author: { displayName: 'Alice Zhang', name: 'alice' }, + remoteId: '42', + localStatus: 'pending', + hasConflict: false, + mergeStatus: { canMerge: false, conflicted: false, vetoes: [] }, + discoveryFilters: ['review-requested'], + ...over, + } as unknown as StoredPullRequest; +} + +describe('matchesDiscoveryFilter', () => { + it('无一级 = 不限定,恒真', () => { + expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: [] }), undefined)).toBe(true); + }); + it('命中 discoveryFilters 为真', () => { + expect( + matchesDiscoveryFilter(mkPr({ discoveryFilters: ['review-requested', 'created'] }), 'created'), + ).toBe(true); + }); + it('未命中为假', () => { + expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: ['review-requested'] }), 'assigned')).toBe( + false, + ); + }); + it('PR 无 discoveryFilters 且指定了一级 → 假', () => { + expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: undefined }), 'review-requested')).toBe( + false, + ); + }); +}); + +describe('matchesSecondaryFilter', () => { + it("'all' 恒真", () => { + expect(matchesSecondaryFilter(mkPr({ localStatus: 'needs_work' }), 'all')).toBe(true); + }); + it('按 localStatus 匹配', () => { + expect(matchesSecondaryFilter(mkPr({ localStatus: 'approved' }), 'approved')).toBe(true); + expect(matchesSecondaryFilter(mkPr({ localStatus: 'pending' }), 'approved')).toBe(false); + }); + it("'conflict' 看 hasConflict", () => { + expect(matchesSecondaryFilter(mkPr({ hasConflict: true }), 'conflict')).toBe(true); + expect(matchesSecondaryFilter(mkPr({ hasConflict: false }), 'conflict')).toBe(false); + }); + it("'mergeable' 看 mergeStatus.canMerge", () => { + expect( + matchesSecondaryFilter( + mkPr({ mergeStatus: { canMerge: true, conflicted: false, vetoes: [] } }), + 'mergeable', + ), + ).toBe(true); + expect( + matchesSecondaryFilter( + mkPr({ mergeStatus: { canMerge: false, conflicted: false, vetoes: [] } }), + 'mergeable', + ), + ).toBe(false); + }); +}); + +describe('matchesPrQuery', () => { + const pr = mkPr({ + title: 'Fix login bug', + repo: { projectKey: 'PROJ', repoSlug: 'web-app' }, + author: { displayName: 'Alice Zhang', name: 'alice' } as StoredPullRequest['author'], + remoteId: '42', + }); + it('空查询恒真', () => { + expect(matchesPrQuery(pr, '')).toBe(true); + expect(matchesPrQuery(pr, ' ')).toBe(true); + }); + it('大小写无关匹配标题 / 仓库 / 作者 / 编号', () => { + expect(matchesPrQuery(pr, 'LOGIN')).toBe(true); // 标题 + expect(matchesPrQuery(pr, 'web-app')).toBe(true); // repoSlug + expect(matchesPrQuery(pr, 'proj')).toBe(true); // projectKey + expect(matchesPrQuery(pr, 'alice')).toBe(true); // author.name + expect(matchesPrQuery(pr, 'Alice Zhang')).toBe(true); // author.displayName + expect(matchesPrQuery(pr, '42')).toBe(true); // remoteId + }); + it('未命中为假', () => { + expect(matchesPrQuery(pr, 'nonexistent')).toBe(false); + }); +}); + +describe('filterPullRequests', () => { + const prs = [ + mkPr({ + remoteId: '1', + title: 'alpha', + localStatus: 'pending', + discoveryFilters: ['review-requested'], + }), + mkPr({ + remoteId: '2', + title: 'beta', + localStatus: 'approved', + discoveryFilters: ['created'], + }), + mkPr({ + remoteId: '3', + title: 'gamma', + localStatus: 'approved', + discoveryFilters: ['review-requested'], + hasConflict: true, + }), + ]; + + it('空条件返回全部', () => { + expect(filterPullRequests(prs, {})).toHaveLength(3); + }); + it('一级过滤', () => { + const out = filterPullRequests(prs, { primary: 'review-requested' }); + expect(out.map((p) => p.remoteId)).toEqual(['1', '3']); + }); + it('一级 + 二级 AND', () => { + const out = filterPullRequests(prs, { primary: 'review-requested', secondary: 'approved' }); + expect(out.map((p) => p.remoteId)).toEqual(['3']); + }); + it('二级 + 检索 AND', () => { + const out = filterPullRequests(prs, { secondary: 'approved', query: 'beta' }); + expect(out.map((p) => p.remoteId)).toEqual(['2']); + }); + it('conflict 横切筛选', () => { + expect(filterPullRequests(prs, { secondary: 'conflict' }).map((p) => p.remoteId)).toEqual(['3']); + }); +}); + +describe('PR_SECONDARY_FILTERS', () => { + it('含全部二级筛选键', () => { + expect(PR_SECONDARY_FILTERS).toEqual([ + 'all', + 'pending', + 'approved', + 'needs_work', + 'conflict', + 'mergeable', + ]); + }); +});