diff --git a/.agents/skills/lian-jing/SKILL.md b/.agents/skills/lian-jing/SKILL.md new file mode 100644 index 0000000..f52f5f6 --- /dev/null +++ b/.agents/skills/lian-jing/SKILL.md @@ -0,0 +1,19 @@ +--- +name: lian-jing +description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管理、Gitea 与交付能力。 +--- + +# 炼境项目技能 + +开始项目工作时,先读取根 `AGENTS.md` 与 `.lian-jing/context.json` 取得 `project_id`。`.claude/liangjing.json` 仅为旧项目的 Claude 兼容投影,不能作为长期身份来源。 + +如果当前客户端已发现 `lian-jing` MCP: + +1. 所有 MCP 调用显式传入 `project_id`。 +2. 优先调用 `get_diagnostics`,将其作为当前项目健康状态的事实来源。 +3. 在 Node/pnpm 项目运行包管理命令前调用 `get_toolchain_status(project_id)`;`ready_managed` 时通过 `mise exec -- pnpm ...` 执行,其他状态先报告实际版本与 executable 证据。 +4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。 +5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。 +6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。 + +如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。 diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index d96f5a1..2c7adca 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -457,7 +457,7 @@ modules: area: infra status: in_progress progress: 14 - note: "P1 与 P3-A 已完成:Gitea 1.22.6 真实 deployment context/时间/URL/SHA 已归一化,并修复 skipped-as-success 误判;P3-B~E 待执行。首轮只交付 MCP 闭环,不建 staging、不开放 SSH/部署/回滚写操作,UI 待 2~4 周收益观测后决定。" + note: "P1、P3-A 与 P3-S1 已完成:交付状态聚合、Gitea 1.22.6 deployment signal 归一化及炼境自身 Codex 接入均已闭环;P3-S2 安全合并门禁已实现并通过本地/MCP 负向验证,待新 PR 的 CI 与用户针对具体 SHA 确认后完成正向合并验收。P3-B~E 待执行;首轮不开放 SSH/部署/回滚写操作。" position: [2000, 800] - id: agent-client-capability-packs diff --git a/.blueprint/modules/project-capability-control-plane.md b/.blueprint/modules/project-capability-control-plane.md index 6af080f..c8b0e86 100644 --- a/.blueprint/modules/project-capability-control-plane.md +++ b/.blueprint/modules/project-capability-control-plane.md @@ -309,6 +309,20 @@ v1 不新增 deployment history 表。最近部署时间和过程状态来自 Gi - acceptance: "[cargo test] 使用脱敏后的真实 Gitea 1.22 `/statuses/{sha}` payload 运行 deployment signal 归一化测试 → success/pending/failure、context、updated_at、target_url 与目标 SHA 均被保留,无关 CI context 不得误判为部署;若真实 payload 不含独立 deploy signal,则由既有 workflow 发布稳定 context 后用同一测试通过。" - done_note: "2026-09-22:通过炼境 MCP 实证 Gitea 1.22.6 为 run #525/#529 发布独立 context,API 为 `CI — DeepSeek Agent / deploy-api (push)`;保留 target SHA、target_url、created_at/updated_at,并发现/修复 skipped job 被编码为 status=success + description=Has been skipped 的误判风险。既有 workflow 无需修改;cargo test --lib 全量通过,实机 get_delivery_status 已将 run #529 deploy-api/deploy-web 正确归一化为 skipped。" +### ✅ P3-S1. 炼境自身 Codex 接入闭环 +- status: done +- complexity: M +- files: AGENTS.md, .claude/commands/envoy.md, .agents/skills/lian-jing/SKILL.md, .codex/config.toml, .lian-jing/context.json, .lian-jing/capability-packs.json +- acceptance: "agent_verify: [MCP] `get_diagnostics()` 返回本地服务状态;`verify_onboarding(dev-manager-tauri)` → canonical context、Codex skill、MCP 与能力包全部 ready;`get_delivery_status(dev-manager-tauri)` → 能读取 PR #13 的 SHA、CI 与 review。不得从旧 `.claude/liangjing.json` 猜测身份或提取 Gitea token。" +- done_note: "2026-09-22:通过既有 inject_mcp 迁移旧上下文,apply_onboarding_pack 升级托管基线,verify_onboarding 11/11;经 MCP 实测读取 PR #13(SHA 0141b8b、CI passed),用户确认后由 merge_pull_request 合并,合并后开放 PR 状态为 none。当前 Codex 任务不支持热加载新 MCP 配置,原生客户端发现留待新任务验证。" + +### 🔵 P3-S2. Agent PR 合并安全门禁 +- status: in_progress +- complexity: M +- depends: P3-S1. 炼境自身 Codex 接入闭环 +- files: src-tauri/src/commands/gitea.rs, src-tauri/src/mcp_tools.rs, docs/ai-context/git-workflow.md, src-tauri/resources/onboarding/git-workflow.md.tmpl, src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, src-tauri/resources/onboarding/.lian-jing/capability-packs.json, plugins/lian-jing-codex/skills/lian-jing/SKILL.md +- acceptance: "agent_verify: [cargo test] `merge_pull_request` 在 confirmed=false、expected_head_sha 与远端不一致、PR 非 open、CI pending/failure 或 review changes_requested 时拒绝且不执行 merge;仅在 PR open、SHA 一致、CI passed、无阻塞 review 且 confirmed=true 时允许执行;[MCP schema] confirmed 与 expected_head_sha 均为必填;合并后重新调用 get_delivery_status 可读取真实结果。" + ### 📋 P3-B. Enterprise System 不可变 revision 与版本健康端点 - status: todo - complexity: M diff --git a/.blueprint/notes-archive.jsonl b/.blueprint/notes-archive.jsonl index e54afca..986c986 100644 --- a/.blueprint/notes-archive.jsonl +++ b/.blueprint/notes-archive.jsonl @@ -19,3 +19,4 @@ {"content":"【炼境使者回传】来源: dev-manager-tauri(炼境自身)\n类型: validated_practice\n置信度: confirmed\n影响范围: all_projects\n内容: 任务卡动工前做多角度辩证收缩实测显著降返工——3 张初稿卡辩证后 1 张否决(自动触发伤择)、1 张载体翻转(hook→ingest 层)、1 张补两处设计缺陷,直做会错 2/3;实施后 U3/B4 零返工、U1 仅一轮实调返工\n建议炼境: update_convention","created_at":"2026-07-15 23:16:03","id":92,"project_id":"2c5b54ef-5570-42ab-8c41-8ed1e2cd21dc","source":"mcp"} {"content":"【炼境使者回传】来源: dev-manager-tauri(炼境自身)\n类型: workflow_hypothesis\n置信度: under_validation\n影响范围: all_projects\n内容: verify_onboarding「lefthook 检查段」按键名匹配是名义匹配非语义匹配——等价功能不同键名(如 scripts 老写法、项目自定义键)会被误报缺失;enterprise-system 6/7 的 fail 尚未核实是缺失还是改名定制\n建议炼境: log_for_review","created_at":"2026-07-15 23:16:07","id":93,"project_id":"2c5b54ef-5570-42ab-8c41-8ed1e2cd21dc","source":"mcp"} {"content":"【复盘】notes 档案分片双写 + backfill 自愈(governance-feedback-loop 缺口①地基)\n任务类型: MCP工具\n复杂度: M\n实际轮数: 1轮(预估1-2轮)\n是否返工: 否\n有效策略: 动工前辩证翻转归宿设计(集中式→按项目分片),翻转依据全部来自侦察事实(安装态进程定位不了炼境仓库/usage.json 先例本就 per-project/可拆除性按项目成立);实现前把 project_root 重构出 DI 版而非复制 WSL 转换逻辑,单一实现;三个测试对着 acceptance 逐条写(双写匹配/回填幂等+死登记跳过/并发不交错),110 全绿一次过;PR #5 CI 双侧绿后 MCP 合并\n遗留风险: MCP 实调(append 双写 + backfill 存量落盘)待重建安装后终验——本条笔记即终验样本(重建后跑 backfill_notes_archive,应出现在 dev-manager-tauri/.blueprint/notes-archive.jsonl 尾部);WSL 项目分片路径未真机验;子项目档案文件的 git 收编节奏未约定(沿 usage.json 惯例)\n附带产出: /ship 体检实补3处(push 提前信号/新文件占比口径/机械阈值归 lint);/envoy 体检零改动收官——两项均早已消化,沉淀教训「分诊定态前先核实目标文件现状」","created_at":"2026-09-11 22:27:48","id":164,"project_id":"dev-manager-tauri","source":"mcp"} +{"content":"【复盘】P3-S1 炼境自身 Codex 接入闭环\n任务类型: MCP工具\n复杂度: M\n实际轮数: 2轮(预估2轮)\n是否返工: 否\n有效策略: 先由 inject_mcp 建立工具无关 canonical context,再由 apply_onboarding_pack 分发版本化能力包;最终通过 verify_onboarding 11/11 与 get_delivery_status 实机调用验证,未读取或复制 Gitea token。\n遗留风险: 当前 Codex 任务不能热加载新 MCP 配置,需在新任务验证原生工具发现;个人 marketplace 指向的 lian-jing-codex 源目录缺失,仓库插件源码已更新但本机插件缓存尚未刷新。\nauto_applied_rules: R10,R13\nrule_effectiveness: R10=有效,R13=有效","created_at":"2026-09-22 21:08:45","id":175,"project_id":"dev-manager-tauri","source":"mcp"} diff --git a/.claude/commands/envoy.md b/.claude/commands/envoy.md index eca28e4..59d9eec 100644 --- a/.claude/commands/envoy.md +++ b/.claude/commands/envoy.md @@ -1,3 +1,5 @@ +在子项目会话结束时扫描体系级洞察、回传给炼境。Use at the end of a session in a managed project. + 你是**炼境使者**——在子项目会话里代表炼境执行现场感知与回传。 diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..e883406 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,4 @@ +# LIANJING_MCP_MANAGED_START +[mcp_servers.lian-jing] +url = "http://127.0.0.1:27190/mcp" +# LIANJING_MCP_MANAGED_END diff --git a/.lian-jing/capability-packs.json b/.lian-jing/capability-packs.json new file mode 100644 index 0000000..b40de6a --- /dev/null +++ b/.lian-jing/capability-packs.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "packs": [ + { + "pack_id": "base-onboarding", + "version": "1.3.0", + "supported_clients": ["codex", "claude"], + "project_artifacts": [ + "AGENTS.md", + ".agents/skills/lian-jing/SKILL.md", + ".lian-jing/context.json" + ], + "self_check": "verify_onboarding", + "upgrade_hint": "调用 apply_onboarding_pack(project_id) 更新版本化基线;项目身份上下文由 inject_mcp(project_id) 创建或迁移。该升级仅覆盖炼境托管的接入包文件,不覆盖项目定制或手工 MCP 配置。" + }, + { + "pack_id": "toolchain-node-pnpm", + "version": "1.0.0", + "supported_clients": ["codex", "claude", "human-shell"], + "project_artifacts": [ + ".nvmrc", + "package.json#packageManager", + "mise.toml" + ], + "self_check": "get_toolchain_status", + "upgrade_hint": "重跑 apply_onboarding_pack(project_id) 可为已声明 .nvmrc 与 pnpm packageManager 的项目补充分离版本事实的最小 mise resolver;已有 mise.toml 永不覆盖。" + } + ] +} diff --git a/.lian-jing/context.json b/.lian-jing/context.json new file mode 100644 index 0000000..1f673e8 --- /dev/null +++ b/.lian-jing/context.json @@ -0,0 +1,4 @@ +{ + "project_id": "dev-manager-tauri", + "schema_version": 1 +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0506224..9bce4e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ docs/templates/ 炼境接入包模板草案(设计层) - [ ] `src/lib/commands.ts` 的封装覆盖了所有 `src-tauri/src/commands/` 下的 command 吗? - [ ] 接入包模板(`resources/onboarding/`)和 `docs/templates/` 内容一致吗? - + ## 项目蓝图 本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。 @@ -138,7 +138,7 @@ docs/templates/ 炼境接入包模板草案(设计层) 完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。 - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。 ## 核心开发约束 @@ -186,6 +186,20 @@ docs/templates/ 炼境接入包模板草案(设计层) 见 `docs/ai-context/git-workflow.md`(分支策略 + Conventional Commits + Rules-Applied trailer + PR 规范)。 **commit 必须符合 Conventional Commits**(lefthook 强制),炼境据此采集飞轮数据。 +## 炼境项目上下文与能力包 + +- `.lian-jing/context.json` 是项目 `project_id` 的工具无关单一事实来源;不要从客户端项目 UI、用户级插件或全局配置推断项目身份。 +- `.claude/liangjing.json` 只是旧项目的 Claude 兼容投影。只发现它时,调用 `inject_mcp(project_id)` 迁移,而不是手工复制或猜测 ID。 +- `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。 +- `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。 + +### Node/pnpm 工具链 + +- Node 精确版本以 `.nvmrc` 为事实源,pnpm 精确版本以 `package.json#packageManager` 为事实源;不要追随 IDE、Codex 或全局 PATH 的 bundled runtime。 +- Node+pnpm 项目由炼境分发最小 `mise.toml`,只启用上述标准声明,不重复保存版本;已有手写 `mise.toml` 永不覆盖。 +- 开始运行 pnpm 命令前调用 `get_toolchain_status(project_id)`。返回 `ready_managed` 时使用 `mise exec -- pnpm ...`;`mismatch/conflict/unavailable` 时先报告证据。 +- `prepare_toolchain(project_id, confirmed=true)` 会下载/安装项目声明版本,只有用户明确确认本次机器状态变更后才能调用;不得为消除告警而修改项目版本或 lockfile。 + ## agent 按钮与验证路径 功能的本体是代码层的能力;UI 按钮是它给人的投影,**agent 按钮**是给 agent 的投影 diff --git a/CLAUDE.md b/CLAUDE.md index 0926827..344ce58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules - 代码重构/简化:/simplify → /code-review - 发现深层问题:/Dev-deep-think - + ## 项目蓝图 蓝图工作流规则统一维护在 @AGENTS.md 的 BLUEPRINT_MANAGED 区(跨工具单一来源,勿在此复制)。 diff --git a/docs/ai-context/git-workflow.md b/docs/ai-context/git-workflow.md index 307a774..c651d5d 100644 --- a/docs/ai-context/git-workflow.md +++ b/docs/ai-context/git-workflow.md @@ -109,6 +109,21 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 - `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。 - 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。 +## PR 合并(炼境托管项目) + +合并属于高影响操作,Agent 必须先读取交付状态并向用户展示 PR 编号、完整 head SHA、CI 与 review;仅在用户明确确认本次合并后调用: + +```json +{ + "project_id": "", + "pr_number": 123, + "expected_head_sha": "<完整 40 位 SHA>", + "confirmed": true +} +``` + +工具名为 `merge_pull_request`。能力本体会重新读取远端状态;PR 非 open、head SHA 漂移、CI 未通过、review 请求修改或任一门禁来源不可用时均拒绝合并。合并成功后再次调用 `get_delivery_status` 读取真实结果。`confirmed=true` 只能来自当前用户对该 PR 与 SHA 的明确确认,不能由 Agent 自行推断。 + ## 常用命令 ```bash diff --git a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md index 42b9e14..67d586a 100644 --- a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md +++ b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md @@ -11,7 +11,8 @@ description: Inspect an already onboarded Lian Jing project, use its existing MC 2. 仅将 `.lian-jing/context.json` 中的 `project_id` 用作炼境 MCP 调用参数。`.claude/liangjing.json` 只用于识别旧项目并提示迁移,不能作为长期身份来源。 3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding`、`get_toolchain_status` 和 `get_delivery_status` 获取可观察信号;所有调用显式传入 `project_id`。 4. Node/pnpm 项目运行包管理命令前先读取工具链状态;`ready_managed` 时使用 `mise exec -- pnpm ...`。只有用户明确确认下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配 Codex bundled runtime 修改项目版本或 lockfile。 -5. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。 -6. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 +5. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。 +6. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。 +7. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。 diff --git a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md index c494479..f52f5f6 100644 --- a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md +++ b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md @@ -14,5 +14,6 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管 3. 在 Node/pnpm 项目运行包管理命令前调用 `get_toolchain_status(project_id)`;`ready_managed` 时通过 `mise exec -- pnpm ...` 执行,其他状态先报告实际版本与 executable 证据。 4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。 5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。 +6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。 如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。 diff --git a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json index 54caecd..b40de6a 100644 --- a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json +++ b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json @@ -3,7 +3,7 @@ "packs": [ { "pack_id": "base-onboarding", - "version": "1.2.0", + "version": "1.3.0", "supported_clients": ["codex", "claude"], "project_artifacts": [ "AGENTS.md", diff --git a/src-tauri/resources/onboarding/git-workflow.md.tmpl b/src-tauri/resources/onboarding/git-workflow.md.tmpl index 307a774..c651d5d 100644 --- a/src-tauri/resources/onboarding/git-workflow.md.tmpl +++ b/src-tauri/resources/onboarding/git-workflow.md.tmpl @@ -109,6 +109,21 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 - `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。 - 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。 +## PR 合并(炼境托管项目) + +合并属于高影响操作,Agent 必须先读取交付状态并向用户展示 PR 编号、完整 head SHA、CI 与 review;仅在用户明确确认本次合并后调用: + +```json +{ + "project_id": "", + "pr_number": 123, + "expected_head_sha": "<完整 40 位 SHA>", + "confirmed": true +} +``` + +工具名为 `merge_pull_request`。能力本体会重新读取远端状态;PR 非 open、head SHA 漂移、CI 未通过、review 请求修改或任一门禁来源不可用时均拒绝合并。合并成功后再次调用 `get_delivery_status` 读取真实结果。`confirmed=true` 只能来自当前用户对该 PR 与 SHA 的明确确认,不能由 Agent 自行推断。 + ## 常用命令 ```bash diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 9042cf4..324dfe7 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -1371,30 +1371,140 @@ pub fn gitea_api_get_ci_status(project_id: &str, commit_sha: &str) -> Result Result { - let conn = crate::db::pool().get().map_err(|e| e.to_string())?; +fn is_full_git_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} +/// 合并门禁的纯函数部分。网络层先抓取同一时刻的 PR / CI / review,再一次性校验。 +/// 返回已确认的实际 head SHA,供执行结果与审计信息复用。 +fn validate_merge_preconditions( + confirmed: bool, + expected_head_sha: &str, + pull_request: &serde_json::Value, + ci: &DeliverySignal, + review: &DeliverySignal, +) -> Result { + if !confirmed { + return Err("合并需要当前用户明确确认(confirmed=true)".to_string()); + } + if !is_full_git_sha(expected_head_sha) { + return Err("expected_head_sha 必须是完整 40 位 Git SHA".to_string()); + } + + let state = pull_request["state"].as_str().unwrap_or("unknown"); + if state != "open" || pull_request["merged"].as_bool() == Some(true) { + return Err(format!("PR 当前不可合并:state={state}")); + } + if pull_request["mergeable"].as_bool() == Some(false) { + return Err("PR 存在冲突或被 Gitea 判定为不可合并".to_string()); + } + + let actual_head_sha = pull_request["head"]["sha"] + .as_str() + .ok_or_else(|| "Gitea PR 响应缺少 head.sha,拒绝合并".to_string())?; + if actual_head_sha != expected_head_sha { + return Err(format!( + "PR head SHA 已变化:期望 {expected_head_sha},实际 {actual_head_sha};请重新检查后确认" + )); + } + + if ci.state != "passed" { + return Err(format!("CI 门禁未通过:{}({})", ci.state, ci.detail)); + } + if ci.data["target_sha"].as_str() != Some(actual_head_sha) { + return Err("CI 证据未绑定当前 PR head SHA,拒绝合并".to_string()); + } + + match review.state.as_str() { + "approved" | "pending" => {} + "changes_requested" => return Err("review 门禁阻塞:存在 reviewer 请求修改".to_string()), + other => { + return Err(format!( + "review 状态不可用或未知:{other};为避免误合并已拒绝" + )) + } + } + + Ok(actual_head_sha.to_string()) +} + +fn merge_request_body(pr_number: i64, head_sha: &str) -> serde_json::Value { + serde_json::json!({ + "Do": "merge", + "merge_title_field": format!("Merge PR #{pr_number}"), + "merge_message_field": "", + "head_commit_id": head_sha, + }) +} + +/// 合并指定 PR。所有高影响边界在能力本体内强制校验,MCP/UI 不得绕过。 +/// 调用 POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge。 +pub fn gitea_api_merge_pull_request( + conn: &rusqlite::Connection, + project_id: &str, + pr_number: i64, + expected_head_sha: &str, + confirmed: bool, +) -> Result { + if !confirmed { + return Err("合并需要当前用户明确确认(confirmed=true)".to_string()); + } + if !is_full_git_sha(expected_head_sha) { + return Err("expected_head_sha 必须是完整 40 位 Git SHA".to_string()); + } let (instance_id, owner, repo_name): (String, String, String) = conn.query_row( "SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1", params![project_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ).map_err(|_| format!("项目 {} 未绑定 Gitea 仓库", project_id))?; - let (base_url, token) = instance_auth(&conn, &instance_id)?; + let (base_url, token) = instance_auth(conn, &instance_id)?; + + let pull_path = format!("/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}"); + let pull_request = gitea_get_json(&base_url, &token, &pull_path, "读取待合并 PR 失败")?; + let actual_head_sha = pull_request["head"]["sha"] + .as_str() + .ok_or_else(|| "Gitea PR 响应缺少 head.sha,拒绝合并".to_string())?; + + let status_path = format!( + "/api/v1/repos/{owner}/{repo_name}/statuses/{actual_head_sha}?sort=recentupdate&limit=50" + ); + let statuses = gitea_get_json( + &base_url, + &token, + &status_path, + "读取待合并 PR 的 CI 状态失败", + )?; + let ci = summarize_ci_statuses(&statuses, Some(actual_head_sha)); + + let reviews_path = format!("/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}/reviews"); + let reviews = gitea_get_json( + &base_url, + &token, + &reviews_path, + "读取待合并 PR 的 review 状态失败", + )?; + let review = summarize_reviews(&reviews); + let validated_sha = + validate_merge_preconditions(confirmed, expected_head_sha, &pull_request, &ci, &review)?; let url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}/merge"); ureq::post(&url) .set("Authorization", &format!("token {token}")) .set("Content-Type", "application/json") - .send_json(serde_json::json!({ - "Do": "merge", - "merge_title_field": format!("Merge PR #{}", pr_number), - "merge_message_field": "", - })) + .send_json(merge_request_body(pr_number, &validated_sha)) .map_err(|e| api_err("合并 PR 失败", e))?; - Ok(format!("PR #{pr_number} 已成功合并({owner}/{repo_name})")) + serde_json::to_string_pretty(&serde_json::json!({ + "merged": true, + "project_id": project_id, + "repository": format!("{owner}/{repo_name}"), + "pr_number": pr_number, + "head_sha": validated_sha, + "ci_state": ci.state, + "review_state": review.state, + })) + .map_err(|error| error.to_string()) } // ── MCP 工具:一键建仓库 + 配置 remote ─────────────────────────────────────── @@ -1891,4 +2001,116 @@ mod tests { ); assert!(select_open_pull_request(&pulls, "main").is_none()); } + + fn merge_gate_fixture() -> (serde_json::Value, DeliverySignal, DeliverySignal) { + let sha = "0123456789abcdef0123456789abcdef01234567"; + let pull_request = serde_json::json!({ + "state": "open", + "merged": false, + "mergeable": true, + "head": { "sha": sha } + }); + let ci = DeliverySignal::new( + "passed", + "gitea", + "所有最新 CI context 均成功", + serde_json::json!({ "target_sha": sha, "contexts": [] }), + ); + let review = DeliverySignal::new( + "pending", + "gitea", + "尚无有效 approve/reject review", + serde_json::json!({ "reviewers": [] }), + ); + (pull_request, ci, review) + } + + #[test] + fn merge_gate_requires_explicit_confirmation_and_full_sha() { + let (pull_request, ci, review) = merge_gate_fixture(); + let sha = pull_request["head"]["sha"].as_str().unwrap(); + assert!( + validate_merge_preconditions(false, sha, &pull_request, &ci, &review) + .unwrap_err() + .contains("明确确认") + ); + assert!( + validate_merge_preconditions(true, "01234567", &pull_request, &ci, &review) + .unwrap_err() + .contains("完整 40 位") + ); + } + + #[test] + fn merge_gate_rejects_sha_drift_and_non_open_pr() { + let (mut pull_request, ci, review) = merge_gate_fixture(); + let different_sha = "fedcba9876543210fedcba9876543210fedcba98"; + assert!( + validate_merge_preconditions(true, different_sha, &pull_request, &ci, &review,) + .unwrap_err() + .contains("SHA 已变化") + ); + + pull_request["state"] = serde_json::json!("closed"); + let original_sha = "0123456789abcdef0123456789abcdef01234567"; + assert!( + validate_merge_preconditions(true, original_sha, &pull_request, &ci, &review,) + .unwrap_err() + .contains("不可合并") + ); + } + + #[test] + fn merge_gate_rejects_unready_ci_and_blocking_review() { + let (pull_request, mut ci, mut review) = merge_gate_fixture(); + let sha = pull_request["head"]["sha"].as_str().unwrap(); + + for state in ["pending", "failed", "unavailable"] { + ci.state = state.to_string(); + assert!( + validate_merge_preconditions(true, sha, &pull_request, &ci, &review) + .unwrap_err() + .contains("CI 门禁未通过") + ); + } + + ci.state = "passed".to_string(); + review.state = "changes_requested".to_string(); + assert!( + validate_merge_preconditions(true, sha, &pull_request, &ci, &review) + .unwrap_err() + .contains("请求修改") + ); + + review.state = "unavailable".to_string(); + assert!( + validate_merge_preconditions(true, sha, &pull_request, &ci, &review) + .unwrap_err() + .contains("不可用或未知") + ); + } + + #[test] + fn merge_gate_allows_current_sha_with_passed_ci_and_no_blocking_review() { + let (pull_request, ci, mut review) = merge_gate_fixture(); + let sha = pull_request["head"]["sha"].as_str().unwrap(); + assert_eq!( + validate_merge_preconditions(true, sha, &pull_request, &ci, &review).unwrap(), + sha + ); + + review.state = "approved".to_string(); + assert_eq!( + validate_merge_preconditions(true, sha, &pull_request, &ci, &review).unwrap(), + sha + ); + } + + #[test] + fn merge_request_body_binds_the_confirmed_head_sha() { + let sha = "0123456789abcdef0123456789abcdef01234567"; + let body = merge_request_body(13, sha); + assert_eq!(body["Do"], "merge"); + assert_eq!(body["head_commit_id"], sha); + } } diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 1c86854..0dfe3d6 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str = const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl"); const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; -const BASE_ONBOARDING_PACK_VERSION: &str = "1.2.0"; +const BASE_ONBOARDING_PACK_VERSION: &str = "1.3.0"; const TOOLCHAIN_PACK_VERSION: &str = "1.0.0"; /// 单个模板的分发结果。 diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index eef1eab..13bfc72 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -221,14 +221,16 @@ pub fn tools_list_result() -> Value { }, { "name": "merge_pull_request", - "description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。", + "description": "在用户明确确认后合并指定 PR。能力本体会重新读取 PR、head SHA、CI 与 review;SHA 漂移、CI 未通过、changes requested、PR 非 open 或 confirmed=false 时拒绝执行。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, - "pr_number": { "type": "integer", "description": "PR 编号(来自 create_pull_request 的返回结果或 Gitea UI)" } + "pr_number": { "type": "integer", "description": "PR 编号(来自 create_pull_request 的返回结果或 Gitea UI)" }, + "expected_head_sha": { "type": "string", "description": "用户确认时看到的完整 40 位 PR head SHA;远端变化后旧确认自动失效" }, + "confirmed": { "type": "boolean", "description": "当前用户是否已明确确认合并该 PR 与 expected_head_sha" } }, - "required": ["project_id", "pr_number"] + "required": ["project_id", "pr_number", "expected_head_sha", "confirmed"] } }, { @@ -506,10 +508,34 @@ pub async fn tools_call(params: Option<&Value>) -> Value { "merge_pull_request" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let pr_num = args.get("pr_number").and_then(|v| v.as_i64()).unwrap_or(0); + let expected_head_sha = args + .get("expected_head_sha") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let confirmed = args + .get("confirmed") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + if pid.is_empty() { + return Err("project_id 不能为空".to_string()); + } if pr_num <= 0 { return Err("pr_number 必须为正整数".to_string()); } - crate::commands::gitea::gitea_api_merge_pull_request(pid, pr_num) + if !confirmed { + return Err("合并需要当前用户明确确认(confirmed=true)".to_string()); + } + if expected_head_sha.is_empty() { + return Err("expected_head_sha 不能为空".to_string()); + } + let conn = db::pool().get().map_err(|e| e.to_string())?; + crate::commands::gitea::gitea_api_merge_pull_request( + &conn, + pid, + pr_num, + expected_head_sha, + confirmed, + ) } "get_pr_events" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); @@ -1646,6 +1672,44 @@ mod tests { } } + #[test] + fn merge_pull_request_schema_requires_confirmation_and_expected_sha() { + let result = tools_list_result(); + let tools = result["tools"].as_array().expect("tools 应为数组"); + let merge = tools + .iter() + .find(|tool| tool["name"] == "merge_pull_request") + .expect("缺少 merge_pull_request"); + let required: Vec<&str> = merge["inputSchema"]["required"] + .as_array() + .expect("required 应为数组") + .iter() + .filter_map(|value| value.as_str()) + .collect(); + assert!(required.contains(&"project_id")); + assert!(required.contains(&"pr_number")); + assert!(required.contains(&"expected_head_sha")); + assert!(required.contains(&"confirmed")); + } + + #[tokio::test] + async fn merge_pull_request_rejects_missing_confirmation_before_external_io() { + let result = tools_call(Some(&json!({ + "name": "merge_pull_request", + "arguments": { + "project_id": "project-1", + "pr_number": 13, + "expected_head_sha": "0123456789abcdef0123456789abcdef01234567" + } + }))) + .await; + assert_eq!(result["isError"], true); + assert!(result["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains("明确确认")); + } + #[tokio::test] async fn delivery_status_tool_requires_project_id() { let result = tools_call(Some(&json!({