diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index bcb8103..0895d28 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -6,7 +6,7 @@ description: > 成为每个项目的贴身导师。炼境本身亦被纳入其中, 形成可自我进化的开发智能闭环。 iteration: 1 -updated: 2026-09-20 +updated: 2026-09-21 agent_button_carrier: tauri @@ -451,6 +451,14 @@ modules: status: planned progress: 0 position: [750, 800] + + - id: project-capability-control-plane + name: 项目能力与交付控制面 + area: infra + status: in_progress + progress: 14 + note: "P1 已完成:只读交付信号聚合(Git / PR / CI / review / 部署 unknown),不改变 Enterprise 既有 production 自动部署;后续受控执行按需推进。" + position: [2000, 800] edges: # 前端依赖 - from: workspace-mgmt @@ -733,3 +741,12 @@ edges: - from: codex-model-switch to: ui-restructure type: related + - from: onboarding-pack + to: project-capability-control-plane + type: dependency + - from: gitea-integration + to: project-capability-control-plane + type: dependency + - from: project-capability-control-plane + to: runtime-diagnostics + type: dependency diff --git a/.blueprint/modules/project-capability-control-plane.md b/.blueprint/modules/project-capability-control-plane.md new file mode 100644 index 0000000..b5364e0 --- /dev/null +++ b/.blueprint/modules/project-capability-control-plane.md @@ -0,0 +1,151 @@ +--- +id: project-capability-control-plane +name: 项目能力与交付控制面 +status: planned +progress: 0 +--- + +## 概述 + +炼境是被管理项目的工程管家,而非项目业务大脑或通用 CI 替代品。项目拥有业务规则、测试、迁移和部署要求;炼境保存云端环境与凭证引用,执行受控操作,采集运行证据。Enterprise System 是首个真实样板。 + +## 设计决策:项目能力协议与轻量交付控制面 v1 + +### 背景 + +Enterprise System 已有可用的云端交付基础:Gitea `main` CI 通过 SSH 调用服务器端 `deploy/deploy-api.sh`;脚本用 Git SHA 标记镜像,在停止旧容器前做环境/网络/镜像检查,切换后做容器内 HTTP 健康闸门,失败自动回滚,且保留近期镜像与 Gitea registry 回滚池。其关键缺口不是再造部署脚本,而是让 Agent 可安全发现、请求、验证和追溯这条能力。 + +### 方案选择 + +| 方案 | 说明 | 结论 | +| --- | --- | --- | +| 炼境直接内置每个项目的部署命令 | 快,但把项目细节、环境差异和业务风险硬编码进平台 | 否决 | +| 项目声明能力,炼境以 adapter 执行 | 项目保持自主,凭证与审计集中,能复用到异构项目 | **选定** | +| 强制所有 commit 经 PR/CI 后再部署 | 治理完整但阻塞 Agent 高频真实环境验证 | 否决;仅 production/高风险路径强制 | + +### 系统边界 + +``` +项目(Enterprise System) + 声明 verify/build/migrate/deploy/health/logs/rollback 的输入与成功断言 + 保留 Dockerfile、migration、部署脚本和业务终验 + +炼境控制面 + 保存环境、凭证引用、允许项目/目标、部署记录和运行证据 + 通过 SSH / Docker Compose 等 adapter 执行受控动作 + +Gitea adapter + 提供 SHA、PR、少量 CI 与 release 依据;不是炼境核心依赖 + +Codex / Claude adapter + 读取项目规则与能力声明,调用炼境 MCP;不持有生产凭证 +``` + +### 交付节奏 + +1. Agent 完成本地验证后小步 commit;多个相关 commit 汇成一个可说明的 PR/交付包。 +2. 普通内部功能在 PR 合并后沿既有 Gitea CI 自动部署 production;炼境先采集版本、CI、健康与回滚证据,不替换项目部署脚本。 +3. 涉及公共引擎、跨业务线、数据写路径或 migration 的改动进入 PR;Gitea CI 只保留高价值检查,并要求在 PR 中说明备份或回滚方式。 +4. 炼境只呈现信号和执行受控操作,不判断业务是否“应该上线”;没有可验证的部署回传时必须明确标为 `unknown`。 + +### 协议草案 + +项目 canonical context 采用工具无关位置 `.lian-jing/context.json`;现有 `.claude/liangjing.json` 与 Codex 配置是派生兼容层。项目能力声明建议位于 `.lian-jing/capabilities.yaml`: + +```yaml +version: 1 +capabilities: + verify: + command: pnpm verify + deploy: + targets: + production: + adapter: ssh-script + environment_ref: enterprise-production + revision: git_sha + command: bash deploy/deploy-api.sh + requires_confirmation: true + health: + checks: + - kind: http + url_ref: enterprise-api + expect_status: 401 +``` + +声明不得包含 IP、SSH 私钥、数据库 URL 或 Secret。`environment_ref` 由炼境映射到加密保存的环境事实;adapter 命令采用 allowlist/模板参数,禁止 Agent 任意拼接远程 shell。 + +### 证据与失败模型 + +一次 deployment record 至少包含:`project_id`、`environment`、`requested_revision`、`resolved_sha`、`requester`、`started_at`、`finished_at`、`migration_set`、`adapter_result`、`health_evidence`、`previous_healthy_release`、`rollback_result`。任何外部源不可用都显式标为 unavailable/error,不能伪装成“无 PR”或“部署成功”。 + +### 验证路径 + +- 炼境:`cargo test` 直接调用 Git/Gitea 状态归并纯函数,断言脏工作区、无绑定、网络不可达和缺失部署证据均可诚实降级。 +- Enterprise System:继续使用现有 production 部署脚本及其健康闸门/回滚;本阶段不把 production 当作自动测试路径。 +- MCP:`get_delivery_status(project_id)` 返回本地 Git、Gitea、CI、review 与可取得的部署/health 来源状态,无错误字段时每项均可追溯。 + +### 现有链路的兼容策略 + +Enterprise System 当前 `push main → Gitea CI → deploy-api.sh` 是 production 链路,且服务器资源有限。本阶段保持该链路不动,也不引入常驻 staging。`get_delivery_status` 不将 CI 成功误报为线上健康;后续若线上验证成本或事故频率证明有必要,再以独立资源或按需环境增加 staging adapter。 + +### 风险点 + +- migration 非向后兼容:v1 不自动执行任意 migration;先读取项目声明、预检与锁定策略,production 必须人工确认。 +- 无 staging 的生产验证风险:普通改动依赖本地验证、PR 和现有自动回滚;高风险 migration/关键数据改动要求备份和显式交付说明。 +- Git SHA 与服务器工作树漂移:adapter 输出必须解析/回传 resolved SHA,且 health 证据绑定同一记录。 +- 现有部署健康闸门仅证明 HTTP 监听:协议同时支持项目声明业务 health probe,不把存活探测误报为业务就绪。 +- Gitea/网络故障:交付状态明确降级;项目本地验证与项目自有手工部署能力不受炼境阻断。 + +## 任务卡 + +### 📋 A. Canonical 项目上下文与 capability schema +- status: todo +- complexity: M +- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/resources/onboarding/, docs/templates/agents-meta-rules.md +- acceptance: "[cargo test] 旧 `.claude/liangjing.json` 项目重跑接入 → 写入 `.lian-jing/context.json`,project_id 一致;Claude/Codex adapter 均从 canonical context 派生且保留用户手工同名配置;无 canonical context 时给出可诊断兼容状态。" + +### 📋 B. 炼境环境、授权与 deployment record 数据模型(后续受控执行) +- status: todo +- complexity: M +- depends: A. Canonical 项目上下文与 capability schema +- files: src-tauri/src/db.rs, src-tauri/src/models.rs, src-tauri/src/commands/ +- acceptance: "[cargo test] in-memory DB 创建 environment / project authorization / deployment record 后,未授权项目、production 未确认、同环境并发请求均被拒绝;record 可记录 requested_sha、resolved_sha、health_evidence 与 previous_healthy_release。" + +### 📋 C. SSH-script adapter 与生产环境锁(后续受控执行) +- status: todo +- complexity: M +- depends: B. 炼境环境、授权与 deployment record 数据模型 +- files: src-tauri/src/commands/, src-tauri/src/lib.rs, src/lib/commands.ts +- acceptance: "[Tauri command] deploy_production(project_id, revision, confirmation) 对 mock SSH adapter → 仅接受声明中的 adapter/command,写入 running→succeeded|failed record;同一 environment_ref 第二次调用返回锁定状态,绝不执行第二条远程命令。" + +### ✅ P1. 交付状态与 Gitea 轻量治理投影 +- status: done +- complexity: M +- files: src-tauri/src/commands/gitea.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs, docs/ai-context/ +- acceptance: "[MCP tool] get_delivery_status(project_id) → 返回当前 SHA/工作树、PR/CI/review 来源状态与 deployment/health evidence 的已知或 unknown 状态;Gitea 不可达时标记 unavailable 而非把 PR 误报为空;cargo test 通过纯函数/命令级降级测试。" +- done_note: "2026-09-21:MCP schema/dispatch、Git/Gitea 聚合、接入说明和降级测试完成;production 部署回传尚未接入,按协议显式返回 unknown。" + +### 📋 E. Enterprise System 可选 staging 隔离与部署 profile +- status: todo +- complexity: M +- depends: A. Canonical 项目上下文与 capability schema, C. SSH-script staging adapter 与环境锁 +- files: E:/code-project/enterprise-system/deploy/deploy-api.sh, E:/code-project/enterprise-system/deploy/README.md, E:/code-project/enterprise-system/.gitea/workflows/, E:/code-project/enterprise-system/.env.api.example +- acceptance: "agent_verify: [future isolated staging] 部署已 push SHA → 使用与 production 不同的 environment_ref、服务器目录/容器名/域名/运行时配置,resolved SHA 和 health evidence 可读取;不触发既有 `push main` production job;human_verify: staging 关键业务接口可用。仅当资源与实际线上验证成本证明必要时启动。" + +### 📋 F. Enterprise System capability 声明与可选 staging 适配验证 +- status: todo +- complexity: M +- depends: E. Enterprise System 可选 staging 隔离与部署 profile +- files: E:/code-project/enterprise-system/.lian-jing/, E:/code-project/enterprise-system/deploy/README.md, E:/code-project/enterprise-system/.blueprint/ +- acceptance: "agent_verify: [炼境 staging adapter] 请求 Enterprise System 已 push SHA → 只匹配声明的 staging profile,服务器输出 resolved SHA,容器 health gate 与项目声明的 HTTP probe 均通过,炼境 record 绑定同一 SHA;human_verify: staging 关键业务接口可用。" + +### 📋 G. production 发布确认、回滚与操作报告(后续受控执行) +- status: todo +- complexity: M +- depends: C. SSH-script adapter 与生产环境锁(后续受控执行), P1. 交付状态与 Gitea 轻量治理投影, F. Enterprise System capability 声明与可选 staging 适配验证 +- files: src-tauri/src/commands/, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/gitea.rs, E:/code-project/enterprise-system/deploy/ +- acceptance: "[cargo test] deploy_production 缺显式 confirmation 或 required CI/PR evidence → 无远程执行;[adapter integration] health gate 失败 → record 标 failed 并保存 rollback result / previous release,生产成功记录含确认者与 resolved SHA。" + +## 迭代衡量 + +首个版本上线后连续观察四周:commit 至 production 部署结果可追溯的时间、production 前实际拦截问题数、失败定位与回滚耗时、PR 等待时间、Agent 重复劳动/上下文丢失次数。不能降低任一真实成本的门禁或自动化应降级或移除。 diff --git a/docs/ai-context/git-workflow.md b/docs/ai-context/git-workflow.md index d50c169..d22abce 100644 --- a/docs/ai-context/git-workflow.md +++ b/docs/ai-context/git-workflow.md @@ -3,7 +3,7 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。 ## 分支策略(复杂度感知) @@ -93,6 +93,20 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。 +## 交付状态(炼境托管项目) + +在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具: + +```json +{ "project_id": "" } +``` + +工具名为 `get_delivery_status`,返回本地工作区与最近 commit SHA、当前分支的 Gitea PR、CI、review 和已知的部署/健康证据。 + +- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。 +- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。 +- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。 + ## 常用命令 ```bash diff --git a/docs/ai-context/project-brief.md b/docs/ai-context/project-brief.md index 2c61753..800170c 100644 --- a/docs/ai-context/project-brief.md +++ b/docs/ai-context/project-brief.md @@ -26,6 +26,8 @@ src/lib/commands.ts 前后端契约(唯一前端入口) src-tauri/src/commands/ Rust 后端实现(一功能一文件) src-tauri/src/db.rs 数据库 schema + migration src-tauri/src/mcp_server.rs MCP Server(agent 调用炼境的入口) +src-tauri/src/mcp_tools.rs MCP tool schema 与 dispatch +src-tauri/src/commands/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据) src-tauri/resources/onboarding/ 接入包分发模板 .blueprint/ 项目蓝图 docs/ai-context/ AI 上下文文档(本文件、契约映射) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 67c0391..4badfe1 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -8,6 +8,13 @@ contracts: - "src-tauri/src/mcp_server.rs" doc: "路由变更需同步 API 文档" + - label: MCP 工具接口 + paths: + - "src-tauri/src/mcp_tools.rs" + - "src-tauri/src/commands/delivery_status.rs" + - "src-tauri/src/commands/gitea.rs" + doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功" + - label: Tauri command 接口(前后端契约) paths: - "src-tauri/src/commands/**/*.rs" diff --git a/src-tauri/resources/onboarding/git-workflow.md.tmpl b/src-tauri/resources/onboarding/git-workflow.md.tmpl index d50c169..d22abce 100644 --- a/src-tauri/resources/onboarding/git-workflow.md.tmpl +++ b/src-tauri/resources/onboarding/git-workflow.md.tmpl @@ -3,7 +3,7 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。 ## 分支策略(复杂度感知) @@ -93,6 +93,20 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。 +## 交付状态(炼境托管项目) + +在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具: + +```json +{ "project_id": "" } +``` + +工具名为 `get_delivery_status`,返回本地工作区与最近 commit SHA、当前分支的 Gitea PR、CI、review 和已知的部署/健康证据。 + +- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。 +- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。 +- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。 + ## 常用命令 ```bash diff --git a/src-tauri/src/commands/delivery_status.rs b/src-tauri/src/commands/delivery_status.rs new file mode 100644 index 0000000..ffc568b --- /dev/null +++ b/src-tauri/src/commands/delivery_status.rs @@ -0,0 +1,229 @@ +//! 项目交付状态聚合。 +//! +//! 本模块只采集和呈现信号,不替项目判断功能是否应交付。Git 本地状态、Gitea +//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。 + +use std::{path::Path, process::Command}; + +use serde::Serialize; + +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct DeliverySignal { + /// 领域状态,例如 `passed`、`open`、`unknown`、`unavailable`。 + pub state: String, + /// 产生该信号的系统,例如 `gitea`、`local_git`、`deployment_report`。 + pub source: String, + /// 面向 Agent 的可读解释;不得把未知状态描述成成功。 + pub detail: String, + /// 结构化原始摘要,便于不同 Agent 作自己的交付判断。 + pub data: serde_json::Value, +} + +impl DeliverySignal { + pub fn new( + state: impl Into, + source: impl Into, + detail: impl Into, + data: serde_json::Value, + ) -> Self { + Self { + state: state.into(), + source: source.into(), + detail: detail.into(), + data, + } + } +} + +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct RecentCommit { + pub sha: String, + pub short_sha: String, + pub committed_at: String, + pub subject: String, +} + +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct GitDeliveryStatus { + /// `clean` / `dirty` / `unavailable`。 + pub state: String, + pub source: String, + pub detail: String, + pub branch: Option, + pub changed_files: usize, + pub recent_commit: Option, +} + +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct DeliveryStatus { + pub project_id: String, + pub git: GitDeliveryStatus, + pub pull_request: DeliverySignal, + pub ci: DeliverySignal, + pub review: DeliverySignal, + pub deployment: DeliverySignal, +} + +/// 聚合已注册项目的交付信号。连接由 MCP/Tauri 边界传入,方便 in-memory DB 测试。 +pub fn get_delivery_status( + conn: &rusqlite::Connection, + project_id: &str, + project_root: &Path, +) -> DeliveryStatus { + let git = inspect_git_status(project_root); + let sha = git.recent_commit.as_ref().map(|commit| commit.sha.as_str()); + let gitea = + crate::commands::gitea::get_delivery_signals(conn, project_id, git.branch.as_deref(), sha); + + DeliveryStatus { + project_id: project_id.to_string(), + git, + pull_request: gitea.pull_request, + ci: gitea.ci, + review: gitea.review, + // P1 尚未要求项目向炼境回传部署结果。保留独立信号位,防止把 CI 成功误报为线上健康。 + deployment: DeliverySignal::new( + "unknown", + "deployment_report", + "项目尚未向炼境回传部署完成或健康证据;CI 成功不等于 production 健康", + serde_json::json!({}), + ), + } +} + +/// 读取本地 Git,不依赖网络。失败也返回结构化状态,使 MCP 调用可继续展示其他来源。 +pub fn inspect_git_status(project_root: &Path) -> GitDeliveryStatus { + let porcelain = match run_git(project_root, ["status", "--porcelain=v1", "--branch"]) { + Ok(output) => output, + Err(detail) => { + return GitDeliveryStatus { + state: "unavailable".into(), + source: "local_git".into(), + detail, + branch: None, + changed_files: 0, + recent_commit: None, + } + } + }; + let (branch, changed_files) = parse_porcelain_status(&porcelain); + let recent_commit = run_git( + project_root, + ["log", "-1", "--format=%H%x1f%h%x1f%cI%x1f%s"], + ) + .ok() + .and_then(|output| parse_recent_commit(&output)); + let state = if changed_files == 0 { "clean" } else { "dirty" }; + + GitDeliveryStatus { + state: state.into(), + source: "local_git".into(), + detail: if changed_files == 0 { + "工作区无未提交改动".into() + } else { + format!("工作区有 {changed_files} 个未提交路径") + }, + branch, + changed_files, + recent_commit, + } +} + +fn run_git(project_root: &Path, args: [&str; N]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(project_root) + .args(args) + .output() + .map_err(|e| format!("无法执行本地 git:{e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + "本地 git 命令失败".into() + } else { + format!("本地 git 命令失败:{stderr}") + }); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +pub fn parse_porcelain_status(output: &str) -> (Option, usize) { + let mut lines = output.lines(); + let branch = lines.next().and_then(|line| { + line.strip_prefix("## ").and_then(|value| { + let name = value + .strip_prefix("No commits yet on ") + .unwrap_or(value) + .split("...") + .next() + .unwrap_or(value) + .trim() + .trim_end_matches(" [gone]"); + (!name.is_empty() && !name.starts_with("HEAD (")).then(|| name.to_string()) + }) + }); + (branch, lines.filter(|line| !line.trim().is_empty()).count()) +} + +pub fn parse_recent_commit(output: &str) -> Option { + let mut fields = output.trim().split('\u{1f}'); + let sha = fields.next()?.trim(); + let short_sha = fields.next()?.trim(); + let committed_at = fields.next()?.trim(); + let subject = fields.next()?.trim(); + (!sha.is_empty()).then(|| RecentCommit { + sha: sha.to_string(), + short_sha: short_sha.to_string(), + committed_at: committed_at.to_string(), + subject: subject.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn porcelain_parser_reports_branch_and_dirty_paths() { + let (branch, changed_files) = parse_porcelain_status( + "## feat/delivery...origin/feat/delivery [ahead 1]\n M src/a.rs\n?? docs/new.md\n", + ); + assert_eq!(branch.as_deref(), Some("feat/delivery")); + assert_eq!(changed_files, 2); + } + + #[test] + fn porcelain_parser_handles_unborn_and_detached_heads() { + let (branch, _) = parse_porcelain_status("## No commits yet on feat/new\n?? file.rs\n"); + assert_eq!(branch.as_deref(), Some("feat/new")); + let (branch, _) = parse_porcelain_status("## HEAD (detached at abc123)\n"); + assert!(branch.is_none()); + } + + #[test] + fn recent_commit_parser_keeps_delivery_anchor() { + let commit = parse_recent_commit( + "1234567890abcdef\u{1f}12345678\u{1f}2026-09-21T10:00:00+09:00\u{1f}feat(mcp): status\n", + ) + .expect("应解析 commit"); + assert_eq!(commit.short_sha, "12345678"); + assert_eq!(commit.subject, "feat(mcp): status"); + } + + #[test] + fn unavailable_local_git_is_explicit() { + let status = inspect_git_status(Path::new("E:/definitely-not-a-project")); + assert_eq!(status.state, "unavailable"); + assert!(status.recent_commit.is_none()); + } + + #[test] + fn no_gitea_binding_does_not_fabricate_delivery_success() { + let conn = crate::db::conn_with_schema(); + let status = + get_delivery_status(&conn, "unbound", Path::new("E:/definitely-not-a-project")); + assert_eq!(status.pull_request.state, "not_configured"); + assert_eq!(status.ci.state, "not_configured"); + assert_eq!(status.deployment.state, "unknown"); + } +} diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 97e6ee1..3e45888 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -230,6 +230,328 @@ fn api_err(prefix: &str, e: ureq::Error) -> String { } } +/// `get_delivery_status` 使用的 Gitea 投影。它只反映远端信号,不给出发布决策。 +#[derive(Debug, Clone)] +pub struct GiteaDeliverySignals { + pub pull_request: crate::commands::delivery_status::DeliverySignal, + pub ci: crate::commands::delivery_status::DeliverySignal, + pub review: crate::commands::delivery_status::DeliverySignal, +} + +type DeliverySignal = crate::commands::delivery_status::DeliverySignal; + +fn unavailable_delivery_signals(detail: impl Into) -> GiteaDeliverySignals { + let detail = detail.into(); + GiteaDeliverySignals { + pull_request: DeliverySignal::new( + "unavailable", + "gitea", + detail.clone(), + serde_json::json!({}), + ), + ci: DeliverySignal::new( + "unavailable", + "gitea", + detail.clone(), + serde_json::json!({}), + ), + review: DeliverySignal::new("unavailable", "gitea", detail, serde_json::json!({})), + } +} + +fn gitea_get_json( + base_url: &str, + token: &str, + path: &str, + label: &str, +) -> Result { + ureq::get(&format!("{base_url}{path}")) + .set("Authorization", &format!("token {token}")) + .call() + .map_err(|e| api_err(label, e))? + .into_json() + .map_err(|e| format!("{label}: 解析响应失败: {e}")) +} + +/// 从 Gitea 拉取当前分支的 PR、当前 SHA 的 CI 以及该 PR 的 review 摘要。 +/// +/// 远端不可用必须作为数据返回,以便上层继续展示本地 Git 与部署未知状态。 +pub fn get_delivery_signals( + conn: &rusqlite::Connection, + project_id: &str, + branch: Option<&str>, + commit_sha: Option<&str>, +) -> GiteaDeliverySignals { + let binding: rusqlite::Result<(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)?)), + ); + let (instance_id, owner, repo) = match binding { + Ok(binding) => binding, + Err(rusqlite::Error::QueryReturnedNoRows) => { + let detail = "项目未绑定 Gitea 仓库"; + return GiteaDeliverySignals { + pull_request: DeliverySignal::new( + "not_configured", + "gitea", + detail, + serde_json::json!({}), + ), + ci: DeliverySignal::new("not_configured", "gitea", detail, serde_json::json!({})), + review: DeliverySignal::new( + "not_configured", + "gitea", + detail, + serde_json::json!({}), + ), + }; + } + Err(error) => return unavailable_delivery_signals(format!("读取 Gitea 绑定失败:{error}")), + }; + let (base_url, token) = match instance_auth(conn, &instance_id) { + Ok(auth) => auth, + Err(error) => return unavailable_delivery_signals(error), + }; + + let pull_path = + format!("/api/v1/repos/{owner}/{repo}/pulls?state=open&limit=50&type=pullrequest"); + let pull_requests = match gitea_get_json(&base_url, &token, &pull_path, "读取 Gitea PR 失败") + { + Ok(value) => value, + Err(error) => return unavailable_delivery_signals(error), + }; + if !pull_requests.is_array() { + return unavailable_delivery_signals("Gitea PR 响应不是数组"); + } + + let matched_pr = branch.and_then(|name| select_open_pull_request(&pull_requests, name)); + let pull_request = match (branch, matched_pr) { + (None, _) => DeliverySignal::new( + "unknown", + "gitea", + "本地 Git 未提供当前分支,无法匹配开放 PR", + serde_json::json!({}), + ), + (Some(_), Some(pr)) => DeliverySignal::new( + "open", + "gitea", + "当前分支存在开放 PR", + pull_request_summary(pr), + ), + (Some(_), None) => DeliverySignal::new( + "none", + "gitea", + "当前分支没有开放 PR", + serde_json::json!({}), + ), + }; + + let ci = match commit_sha { + Some(sha) => { + let path = format!("/api/v1/repos/{owner}/{repo}/statuses/{sha}?limit=50"); + match gitea_get_json(&base_url, &token, &path, "读取 Gitea CI 状态失败") { + Ok(statuses) => summarize_ci_statuses(&statuses), + Err(error) => { + DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})) + } + } + } + None => DeliverySignal::new( + "unknown", + "gitea", + "本地 Git 未提供最近 commit SHA,无法查询 CI", + serde_json::json!({}), + ), + }; + + let review = match matched_pr.and_then(|pr| pr["number"].as_i64()) { + Some(number) => { + let path = format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"); + match gitea_get_json(&base_url, &token, &path, "读取 Gitea review 状态失败") { + Ok(reviews) => summarize_reviews(&reviews), + Err(error) => { + DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})) + } + } + } + None if branch.is_some() => DeliverySignal::new( + "not_applicable", + "gitea", + "当前分支没有开放 PR,因此无 review 状态", + serde_json::json!({}), + ), + None => DeliverySignal::new( + "unknown", + "gitea", + "无法确定当前分支,未查询 review", + serde_json::json!({}), + ), + }; + + GiteaDeliverySignals { + pull_request, + ci, + review, + } +} + +pub fn select_open_pull_request<'a>( + pull_requests: &'a serde_json::Value, + branch: &str, +) -> Option<&'a serde_json::Value> { + pull_requests.as_array()?.iter().find(|pr| { + pr["head"]["ref"].as_str() == Some(branch) + || pr["head"]["label"] + .as_str() + .map(|label| label.ends_with(&format!(":{branch}")) || label == branch) + .unwrap_or(false) + }) +} + +fn pull_request_summary(pr: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "number": pr["number"], + "title": pr["title"], + "url": pr["html_url"], + "head": pr["head"]["ref"], + "base": pr["base"]["ref"], + "updated_at": pr["updated_at"], + }) +} + +/// 把 Gitea commit status 归并成稳定枚举;保留每个 context,供 Agent 自行判断门禁。 +pub fn summarize_ci_statuses(statuses: &serde_json::Value) -> DeliverySignal { + let Some(items) = statuses.as_array() else { + return DeliverySignal::new( + "unknown", + "gitea", + "Gitea CI 响应不是数组", + serde_json::json!({}), + ); + }; + if items.is_empty() { + return DeliverySignal::new( + "unknown", + "gitea", + "当前 commit 暂无 CI 记录", + serde_json::json!({ "contexts": [] }), + ); + } + + let mut latest: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for item in items { + let context = item["context"].as_str().unwrap_or("unknown").to_string(); + // Gitea statuses API 按新到旧返回,同名 context 只保留第一条。 + latest.entry(context).or_insert(item); + } + let contexts: Vec = latest + .iter() + .map(|(context, item)| { + let state = item["status"] + .as_str() + .or_else(|| item["state"].as_str()) + .unwrap_or("unknown"); + serde_json::json!({ + "context": context, + "state": state, + "description": item["description"], + }) + }) + .collect(); + let states: Vec = contexts + .iter() + .filter_map(|item| item["state"].as_str()) + .map(|state| state.to_ascii_lowercase()) + .collect(); + let state = if states + .iter() + .any(|state| state == "failure" || state == "error") + { + "failed" + } else if states.iter().all(|state| state == "success") { + "passed" + } else { + "pending" + }; + let detail = match state { + "passed" => "所有最新 CI context 均成功", + "failed" => "至少一个最新 CI context 失败", + _ => "存在尚未成功的 CI context", + }; + DeliverySignal::new( + state, + "gitea", + detail, + serde_json::json!({ "contexts": contexts }), + ) +} + +/// 对每位 reviewer 仅保留提交时间最新的状态,避免旧的 approve 掩盖后续 request changes。 +pub fn summarize_reviews(reviews: &serde_json::Value) -> DeliverySignal { + let Some(items) = reviews.as_array() else { + return DeliverySignal::new( + "unknown", + "gitea", + "Gitea review 响应不是数组", + serde_json::json!({}), + ); + }; + let mut latest: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (index, review) in items.iter().enumerate() { + let reviewer = review["user"]["login"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| format!("unknown-{index}")); + let should_replace = latest + .get(&reviewer) + .map(|existing| review_timestamp(review) >= review_timestamp(existing)) + .unwrap_or(true); + if should_replace { + latest.insert(reviewer, review); + } + } + let reviewers: Vec = latest + .iter() + .map(|(reviewer, review)| { + serde_json::json!({ + "reviewer": reviewer, + "state": review["state"], + "submitted_at": review["submitted_at"], + }) + }) + .collect(); + let states: Vec = reviewers + .iter() + .filter_map(|review| review["state"].as_str()) + .map(|state| state.to_ascii_uppercase()) + .collect(); + let state = if states.iter().any(|state| state == "REQUEST_CHANGES") { + "changes_requested" + } else if states.iter().any(|state| state == "APPROVED") { + "approved" + } else { + "pending" + }; + let detail = match state { + "changes_requested" => "存在 reviewer 请求修改", + "approved" => "至少一位 reviewer 已批准,未发现后续请求修改", + _ => "尚无有效 approve/reject review", + }; + DeliverySignal::new( + state, + "gitea", + detail, + serde_json::json!({ "reviewers": reviewers }), + ) +} + +fn review_timestamp(review: &serde_json::Value) -> &str { + review["submitted_at"].as_str().unwrap_or("") +} + /// 写入或更新项目 ↔ 仓库绑定(换绑时重置 webhook 字段,旧 hook 已随旧仓库失效) fn upsert_repo_link( conn: &rusqlite::Connection, @@ -1249,4 +1571,42 @@ mod tests { assert!(parse_gitea_commits(&serde_json::json!({"not": "array"})).is_empty()); assert!(parse_gitea_commits(&serde_json::json!([{"no_sha": true}])).is_empty()); } + + #[test] + fn delivery_ci_summary_uses_latest_context_and_preserves_failure() { + let statuses = serde_json::json!([ + {"context": "test", "status": "success", "description": "green"}, + {"context": "test", "status": "failure", "description": "old failure"}, + {"context": "lint", "state": "failure", "description": "bad"} + ]); + let summary = summarize_ci_statuses(&statuses); + assert_eq!(summary.state, "failed"); + assert_eq!(summary.data["contexts"].as_array().unwrap().len(), 2); + } + + #[test] + fn delivery_review_summary_prefers_latest_reviewer_decision() { + let reviews = serde_json::json!([ + {"user": {"login": "alice"}, "state": "REQUEST_CHANGES", "submitted_at": "2026-09-21T10:00:00Z"}, + {"user": {"login": "alice"}, "state": "APPROVED", "submitted_at": "2026-09-21T09:00:00Z"}, + {"user": {"login": "bob"}, "state": "APPROVED", "submitted_at": "2026-09-21T10:01:00Z"} + ]); + let summary = summarize_reviews(&reviews); + assert_eq!(summary.state, "changes_requested"); + assert_eq!(summary.data["reviewers"].as_array().unwrap().len(), 2); + } + + #[test] + fn delivery_pr_selection_matches_current_branch_only() { + let pulls = serde_json::json!([ + {"number": 2, "head": {"ref": "feat/other"}}, + {"number": 3, "head": {"ref": "feat/delivery"}} + ]); + assert_eq!( + select_open_pull_request(&pulls, "feat/delivery").expect("应匹配当前分支")["number"] + .as_i64(), + Some(3) + ); + assert!(select_open_pull_request(&pulls, "main").is_none()); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 665d3b8..d7e0200 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod claude_config; pub mod cloud_db_databases; pub mod cloud_products; pub mod commit_metrics; +pub mod delivery_status; pub mod devtools; pub mod docker_apps; pub mod git_ops; diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 828cd27..ecd67af 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -420,7 +420,11 @@ pub fn verify_onboarding( // 1/2 模板管理块版本到位(期望版本从内嵌模板自省,不硬编码) for (rel, tmpl, label) in [ ("AGENTS.md", TMPL_AGENTS, "AGENTS.md 管理块版本"), - ("docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW, "git-workflow 管理块版本"), + ( + "docs/ai-context/git-workflow.md", + TMPL_GIT_WORKFLOW, + "git-workflow 管理块版本(含交付状态调用说明)", + ), ] { let expected = extract_managed_block(tmpl).and_then(block_version).unwrap_or("?"); let (pass, detail) = check_managed_version(read(rel).as_deref(), expected); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index a67fa2a..7e9c23a 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -183,6 +183,10 @@ mod tests { "工具清单不应因去组化而缺失,实际 {}", list.len() ); + assert!( + list.iter().any(|tool| tool["name"] == "get_delivery_status"), + "MCP 握手后的工具清单应暴露 get_delivery_status" + ); } } diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index bb48a05..f592274 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -185,6 +185,17 @@ pub fn tools_list_result() -> Value { "required": ["project_id", "commit_sha"] } }, + { + "name": "get_delivery_status", + "description": "读取项目当前交付信号:本地 Git 工作区与最近 SHA、当前分支的 Gitea PR、该 SHA 的 CI、PR review,以及已知 deployment/health 证据。该工具只呈现来源信号,不替项目判断是否应该发布;来源不可用或未接入时会明确返回 unknown/unavailable。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 的返回结果)" } + }, + "required": ["project_id"] + } + }, { "name": "merge_pull_request", "description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。", @@ -251,7 +262,7 @@ pub fn tools_list_result() -> Value { }, { "name": "verify_onboarding", - "description": "接入生效验收:检查接入包在项目里是否真正生效(模板管理块版本到位 / lefthook hook 真挂在 .git/hooks / lefthook 检查段对齐模板 / origin 指向已登记 Gitea / CI workflow 存在 / 近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。", + "description": "接入生效验收:检查接入包在项目里是否真正生效(含 get_delivery_status 调用说明的模板管理块版本、lefthook hook 真挂在 .git/hooks、检查段对齐模板、origin 指向已登记 Gitea、CI workflow 存在、近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。", "inputSchema": { "type": "object", "properties": { @@ -436,6 +447,16 @@ pub async fn tools_call(params: Option<&Value>) -> Value { } crate::commands::gitea::gitea_api_get_ci_status(pid, sha) } + "get_delivery_status" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + if pid.is_empty() { + return Err("project_id 不能为空".to_string()); + } + let root = resolve_project_root(pid)?; + let conn = db::pool().get().map_err(|e| e.to_string())?; + let status = crate::commands::delivery_status::get_delivery_status(&conn, pid, &root); + serde_json::to_string_pretty(&status).map_err(|e| e.to_string()) + } "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); @@ -1431,6 +1452,7 @@ mod tests { ); assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp"); assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); + assert!(names.contains(&"get_delivery_status"), "缺少 get_delivery_status"); assert!(names.contains(&"poll_now"), "缺少 poll_now"); assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk"); assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history"); @@ -1444,6 +1466,20 @@ mod tests { } } + #[tokio::test] + async fn delivery_status_tool_requires_project_id() { + let result = tools_call(Some(&json!({ + "name": "get_delivery_status", + "arguments": {} + }))) + .await; + assert_eq!(result["isError"], true); + assert!(result["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains("project_id 不能为空")); + } + /// notes 档案测试用最小 schema(in-memory 或文件库共用) fn setup_notes_schema(conn: &rusqlite::Connection) { conn.execute_batch(