From 3b9de9a23cfb06859bd27bfca813e2c732e0ecb0 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 3 Jul 2026 21:38:20 +0900 Subject: [PATCH] =?UTF-8?q?refactor(mcp)+fix(ci)+fix(test):=20=E4=B8=89?= =?UTF-8?q?=E9=A1=B9=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI: pr-check 改为 push master 触发 + tsc --noEmit(原 branches: [main] 永不触发) - mcp_server.rs 拆分:mcp_tools.rs(工具schema+dispatch+实现)+ mcp_webhook.rs(webhook+测试) mcp_server.rs 从 1385 行瘦身至 145 行(纯协议路由层) - mcp_tools 新增工具 schema 完整性测试(73 passed,较之前 +2 条) - onboarding 测试版本号跟进 v1.1.0 → v1.2.0(模板版本升级后断言未同步) --- .gitea/workflows/pr-check.yml | 12 +- AGENTS.md | 31 +- src-tauri/src/commands/onboarding.rs | 4 +- src-tauri/src/lib.rs | 2 + src-tauri/src/mcp_server.rs | 1237 +------------------------- src-tauri/src/mcp_tools.rs | 904 +++++++++++++++++++ src-tauri/src/mcp_webhook.rs | 288 ++++++ 7 files changed, 1244 insertions(+), 1234 deletions(-) create mode 100644 src-tauri/src/mcp_tools.rs create mode 100644 src-tauri/src/mcp_webhook.rs diff --git a/.gitea/workflows/pr-check.yml b/.gitea/workflows/pr-check.yml index 751328f..f4f26e1 100644 --- a/.gitea/workflows/pr-check.yml +++ b/.gitea/workflows/pr-check.yml @@ -1,10 +1,9 @@ -# PR 代码质量检查(Gitea Actions) -# 在 PR 合并前执行 build / lint,确保代码可编译且风格合规 -name: PR Check +# 前端类型检查(每次 push master 触发) +name: Push Check on: - pull_request: - branches: [main] + push: + branches: [master] jobs: check: @@ -23,6 +22,5 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - - run: pnpm build + - run: pnpm tsc --noEmit - run: pnpm lint --if-present - - run: pnpm test --if-present --passWithNoTests diff --git a/AGENTS.md b/AGENTS.md index 427d001..76d7218 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,9 @@ Git 多空间开发管理器,同时也是**项目 Agent 化改造引擎**。 src/lib/commands.ts 所有 Tauri command 的前端封装(唯一调用入口) src-tauri/src/commands/ Rust 后端实现(每功能一个 .rs 文件) src-tauri/src/db.rs SQLite 建表 + migration 逻辑 -src-tauri/src/mcp_server.rs 内嵌 MCP Server(agent 调用炼境的入口) +src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) +src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(21 个工具) +src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) src-tauri/src/mcp_inject.rs MCP 配置注入(写入被管理项目的 .claude/settings.json) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) @@ -169,8 +171,12 @@ docs/templates/ 炼境接入包模板草案(设计层) ## agent 按钮与验证路径 -功能的本体是代码层的能力;UI 按钮是它给人的投影,**agent 按钮**是给 agent 的投影 -(可发现 + 可调用 + 有边界的能力声明)。开发任何功能时遵守: +> 完整体系设计见 `docs/ai-context/agent-button-system.md`。 + +**分工原则**:人定目标 → agent 执行 → agent 自验(走 agent 按钮)→ 人终验。 +人只保留两件事:**需求澄清(目标定义权)** 和 **目标验收(终验权)**。 + +开发任何功能时遵守: 1. **边界单点实现**:业务校验只写在能力本体层(API/command),UI 只呈现不复制逻辑—— 否则人与 agent 的验证路径会分裂 @@ -191,6 +197,25 @@ docs/templates/ 炼境接入包模板草案(设计层) - 正例:`[HTTP API] POST /api/users → 201,body.id 非空` - 反例:`功能正常可用`(模糊,agent 无法自验) +### 炼境自己的 Agent 按钮清单 + +| 能力 | 调用方式 | 通过标准 | +|------|---------|---------| +| TypeScript 类型检查 | `pnpm typecheck` | 无输出,exit 0 | +| 前端单元测试 | `pnpm test` | exit 0,无 FAILED | +| Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED | +| MCP 工具调用 | 见 `src-tauri/src/mcp_server.rs` 工具列表 | 返回 JSON,无 error 字段 | +| 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 | +| 蓝图状态更新 | 编辑 `.blueprint/modules/.md` | 文件写入成功,格式合法 | + +### 自验失败降级约定 + +- **失败 2 次内**:自行修正,继续自验 +- **失败 3 次**:停止执行,输出以下内容后等待人工介入: + 1. 失败原因(具体报错或不通过的断言) + 2. 已尝试的修正路径 + 3. 需要人决策的具体问题 + ## 多 AI CLI 协作 本项目可由多个 AI CLI 协同。交接时把结果总结成 3 条传给下一个: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 489124c..1ceb0af 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -222,7 +222,7 @@ mod tests { // 态二(旧版本):把版本改旧 + 块外加用户内容 → updated,块升级、用户内容保留 let old = agents - .replace("version=\"1.1.0\"", "version=\"1.0.0\"") + .replace("version=\"1.2.0\"", "version=\"1.0.0\"") .replace("## 项目概况", "## 项目概况 我的项目说明") @@ -235,7 +235,7 @@ mod tests { let agents_r = r3.iter().find(|t| t.name == "AGENTS.md").unwrap(); assert_eq!(agents_r.action, "updated"); let upgraded = std::fs::read_to_string(base.join("AGENTS.md")).unwrap(); - assert!(upgraded.contains("version=\"1.1.0\""), "块应升级到模板版本"); + assert!(upgraded.contains("version=\"1.2.0\""), "块应升级到模板版本"); assert!(upgraded.contains("我的项目说明"), "块外用户内容(前)须保留"); assert!(upgraded.contains("用户自加的章节"), "块外用户内容(后)须保留"); assert_eq!(upgraded.matches(MANAGED_START).count(), 1, "只有一个管理块"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 91e8a5b..2391354 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ mod commands; mod db; mod mcp_inject; mod mcp_server; +mod mcp_tools; +mod mcp_webhook; mod models; use git2; diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 18d2335..d3666eb 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1,7 +1,6 @@ use axum::{ - body::Bytes, extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::{ sse::{Event, KeepAlive, Sse}, IntoResponse, Response, @@ -15,9 +14,9 @@ use std::{collections::HashMap, convert::Infallible, net::SocketAddr, sync::Arc} use tokio::sync::{mpsc, Mutex}; use tokio_stream::{wrappers::ReceiverStream, StreamExt as _}; -use crate::{db, db::log_event}; +use crate::db; -/// SSE 会话表:session_id → 发送端((event_name, data)) +/// SSE 会话表:session_id → 发送端 type Sessions = Arc>>>; /// 从 settings 表读取 MCP 端口,默认 27190 @@ -43,23 +42,23 @@ pub async fn start(port: u16) { let app = Router::new() .route("/health", get(health)) - // Streamable HTTP(POST)— 保持向后兼容,Python 直调仍可用 + // Streamable HTTP(POST)— 向后兼容,Python 直调仍可用 .route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect)) // SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回 .route("/mcp/group/:group_id/messages", post(handle_sse_message)) - // Gitea webhook 观测面:接收 push 事件写入 commit_metrics(飞轮数据源) - .route("/webhook/gitea/:project_id", post(handle_gitea_webhook)) + // Gitea webhook:接收 push/pull_request 事件写入飞轮 + .route("/webhook/gitea/:project_id", post(crate::mcp_webhook::handle_gitea_webhook)) .with_state(sessions); let addr = SocketAddr::from(([0, 0, 0, 0], port)); match tokio::net::TcpListener::bind(addr).await { Ok(listener) => { - log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}(HTTP + SSE)"), None); + db::log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}(HTTP + SSE)"), None); if let Err(e) = axum::serve(listener, app).await { - log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None); + db::log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None); } } - Err(e) => log_event("mcp_server", "error", &format!("绑定端口 {port} 失败: {e}"), None), + Err(e) => db::log_event("mcp_server", "error", &format!("绑定端口 {port} 失败: {e}"), None), } } @@ -67,181 +66,6 @@ async fn health() -> &'static str { "炼境 MCP Server running" } -// ── Gitea Webhook 观测面 ────────────────────────────────────────────────────── - -/// push payload 中的一条 commit -#[derive(Debug, PartialEq)] -struct PushCommit { - sha: String, - message: String, - timestamp: Option, -} - -/// HMAC-SHA256 验签。Gitea 实际发送的签名头是 `X-Gitea-Signature`(payload 的 -/// HMAC-SHA256 hex,R10:以实际抓包行为为准,非架构文档假设的 X-Gitea-Signature-256)。 -fn verify_hmac_sha256(secret: &str, body: &[u8], sig_hex: &str) -> bool { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { - Ok(m) => m, - Err(_) => return false, - }; - mac.update(body); - let expected: String = mac - .finalize() - .into_bytes() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); - // 大小写不敏感比较(Gitea 输出小写 hex) - expected.eq_ignore_ascii_case(sig_hex.trim()) -} - -/// 解析 Gitea push payload 的 commits 数组 -fn parse_push_payload(body: &[u8]) -> Result, String> { - let json: Value = serde_json::from_slice(body).map_err(|e| format!("payload 非法 JSON: {e}"))?; - let commits = json["commits"].as_array().ok_or("payload 缺少 commits 数组")?; - Ok(commits - .iter() - .filter_map(|c| { - let sha = c["id"].as_str()?.to_string(); - let message = c["message"].as_str().unwrap_or("").to_string(); - let timestamp = c["timestamp"].as_str().map(|s| s.to_string()); - Some(PushCommit { sha, message, timestamp }) - }) - .collect()) -} - -/// 从请求头提取签名:优先 X-Gitea-Signature,兼容 X-Hub-Signature-256(sha256= 前缀) -fn extract_signature(headers: &HeaderMap) -> Option { - if let Some(v) = headers.get("x-gitea-signature").and_then(|v| v.to_str().ok()) { - return Some(v.to_string()); - } - headers - .get("x-hub-signature-256") - .and_then(|v| v.to_str().ok()) - .map(|v| v.trim_start_matches("sha256=").to_string()) -} - -async fn handle_gitea_webhook( - Path(project_id): Path, - headers: HeaderMap, - body: Bytes, -) -> Response { - // 1. 查绑定与 secret - let secret: Option = db::pool().get().ok().and_then(|c| { - c.query_row( - "SELECT webhook_secret FROM gitea_repos WHERE project_id = ?1", - [&project_id], - |r| r.get(0), - ) - .ok() - }); - let secret = match secret { - Some(s) => s, - None => { - return (StatusCode::NOT_FOUND, Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"}))) - .into_response() - } - }; - - // 2. 验签 - let sig = extract_signature(&headers); - let verified = sig.as_deref().map(|s| verify_hmac_sha256(&secret, &body, s)).unwrap_or(false); - if !verified { - log_event( - "gitea_webhook", - "warn", - &format!("项目 {project_id} webhook 验签失败(签名{})", if sig.is_some() { "不匹配" } else { "缺失" }), - None, - ); - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "签名验证失败"}))).into_response(); - } - - // 3. 按事件类型分发 - let event = headers - .get("x-gitea-event") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - match event { - "push" => handle_push(&project_id, &body).await, - "pull_request" => handle_pull_request(&project_id, &body).await, - other => Json(json!({"ignored": other})).into_response(), - } -} - -async fn handle_push(project_id: &str, body: &Bytes) -> Response { - let commits = match parse_push_payload(body) { - Ok(c) => c, - Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(), - }; - let conn = match db::pool().get() { - Ok(c) => c, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(), - }; - let mut received = 0; - for c in &commits { - if crate::commands::commit_metrics::record_commit( - &conn, project_id, &c.sha, &c.message, c.timestamp.as_deref(), - ).is_ok() { received += 1; } - } - log_event( - "gitea_webhook", "info", - &format!("项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit", commits.len()), - None, - ); - Json(json!({"received": received})).into_response() -} - -async fn handle_pull_request(project_id: &str, body: &Bytes) -> Response { - let payload: serde_json::Value = match serde_json::from_slice(body) { - Ok(v) => v, - Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))).into_response(), - }; - - let raw_action = payload["action"].as_str().unwrap_or(""); - let pr = &payload["pull_request"]; - let pr_number = pr["number"].as_i64().unwrap_or(0) as i64; - - // 只追踪有价值的生命周期节点 - let action = match raw_action { - "opened" | "reopened" => "opened", - "closed" => { - if pr["merged"].as_bool().unwrap_or(false) { "merged" } else { "closed" } - } - _ => return Json(json!({"ignored": raw_action})).into_response(), - }; - - let title = pr["title"].as_str().unwrap_or("").to_string(); - let head_branch = pr["head"]["label"].as_str().unwrap_or("").to_string(); - let base_branch = pr["base"]["label"].as_str().unwrap_or("").to_string(); - let author = pr["user"]["login"].as_str().unwrap_or("").to_string(); - let id = uuid::Uuid::new_v4().to_string(); - - let conn = match db::pool().get() { - Ok(c) => c, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(), - }; - let result = conn.execute( - "INSERT INTO pr_events (id, project_id, pr_number, title, action, head_branch, base_branch, author) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - rusqlite::params![id, project_id, pr_number, title, action, head_branch, base_branch, author], - ); - - match result { - Ok(_) => { - log_event( - "gitea_webhook", "info", - &format!("项目 {project_id} PR #{pr_number} {action}"), - None, - ); - Json(json!({"recorded": action, "pr_number": pr_number})).into_response() - } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(), - } -} - // ── JSON-RPC 类型 ───────────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -266,21 +90,17 @@ async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result "initialize" => Ok(json!({ "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, - "serverInfo": { - "name": "炼境", - "version": env!("CARGO_PKG_VERSION") - } + "serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") } })), - "tools/list" => Ok(tools_list_result()), - "tools/call" => Ok(tools_call(group_id, req.params.as_ref()).await), + "tools/list" => Ok(crate::mcp_tools::tools_list_result()), + "tools/call" => Ok(crate::mcp_tools::tools_call(group_id, req.params.as_ref()).await), _ => Err(format!("Method not found: {}", req.method)), } } -// ── Streamable HTTP(POST)— 向后兼容,Python 直调走此路径 ───────────────────── +// ── Streamable HTTP(POST)───────────────────────────────────────────────────── async fn handle_mcp(Path(group_id): Path, Json(req): Json) -> Response { - // MCP 通知(无 id)→ 202 Accepted if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); } @@ -297,8 +117,7 @@ async fn handle_mcp(Path(group_id): Path, Json(req): Json) - // ── SSE 传输(GET + POST /messages)───────────────────────────────────────── -/// GET /mcp/group/:group_id -/// Claude Code 发起 SSE 握手,建立长连接,首条事件告知后续 POST 的 URL。 +/// GET /mcp/group/:group_id — Claude Code SSE 握手,首条事件告知后续 POST URL async fn handle_sse_connect( State(sessions): State, Path(group_id): Path, @@ -306,14 +125,12 @@ async fn handle_sse_connect( let session_id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = mpsc::channel::<(String, String)>(32); - // 第一条事件:告知客户端后续 POST 的 endpoint URL let port = read_port(); let endpoint_uri = format!( "http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}", port, group_id, session_id ); let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await; - sessions.lock().await.insert(session_id, tx); let stream = ReceiverStream::new(rx) @@ -323,7 +140,7 @@ async fn handle_sse_connect( } /// POST /mcp/group/:group_id/messages?session_id=xxx -/// 客户端通过此 URL 发送 JSON-RPC 请求,立即返回 202,响应经 SSE channel 异步推回。 +/// 立即返回 202,后台异步处理工具调用并经 SSE channel 推回响应 async fn handle_sse_message( State(sessions): State, Path(group_id): Path, @@ -334,7 +151,6 @@ async fn handle_sse_message( return StatusCode::ACCEPTED.into_response(); } - // 立即返回 202,在后台 task 里处理(避免长时间工具调用阻塞 HTTP 连接) let sessions_clone = sessions.clone(); let session_id = query.session_id.clone(); tokio::spawn(async move { @@ -360,1026 +176,3 @@ async fn handle_sse_message( StatusCode::ACCEPTED.into_response() } - -// ── 工具定义 ────────────────────────────────────────────────────────────────── - -fn tools_list_result() -> Value { - json!({ - "tools": [ - { - "name": "get_diagnostics", - "description": "查询炼境运行时诊断信息:MCP Server 启动状态、各项目 MCP 配置注入结果、最近错误日志。排查功能是否正常时首先调用此工具。", - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } - }, - { - "name": "list_group_projects", - "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息", - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } - }, - { - "name": "get_project_blueprint", - "description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml),了解该项目的模块与任务状态", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - } - }, - "required": ["project_id"] - } - }, - { - "name": "get_project_file", - "description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID" - }, - "relative_path": { - "type": "string", - "description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts" - } - }, - "required": ["project_id", "relative_path"] - } - }, - { - "name": "get_project_mentor_context", - "description": "获取产品组内某个项目的完整导师上下文——包含健康状态、蓝图进度摘要、近期错误日志,以及格式化好的 Markdown 报告(可直接用于质量分析和成长建议)", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - } - }, - "required": ["project_id"] - } - }, - { - "name": "append_project_note", - "description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - }, - "content": { - "type": "string", - "description": "笔记内容。完成 M/L 任务后使用以下结构化格式:\n【复盘】<任务标题>\n任务类型: 后端命令|前端组件|数据层|MCP工具|架构调整\n复杂度: S|M|L\n实际轮数: N轮(预估M轮)\n是否返工: 是/否\n返工原因: <若返工填写,否则省略>\n有效策略: <本次奏效的做法>\n遗留风险: <若有填写,否则省略>\n\n其他场景(分析结论、建议、洞察)可自由格式。" - } - }, - "required": ["project_id", "content"] - } - }, - { - "name": "get_blueprint_status", - "description": "检测项目蓝图治理状态:CONVENTIONS 规则版本是否最新、蓝图内容是否可能落后于代码(synced/rules_outdated/content_stale/none)。等价于炼境 UI 项目卡片上的蓝图状态徽章。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - } - }, - "required": ["project_id"] - } - }, - { - "name": "generate_blueprint_prompt", - "description": "生成蓝图初始化/同步提示词(含 CONVENTIONS 规范与 manifest 严格格式约束)。等价于炼境 UI 治理面板的「初始化蓝图/更新蓝图描绘」按钮。拿到提示词后在该项目目录下执行它(或交给该项目的 AI 会话),完成后可用 get_blueprint_status / get_project_blueprint 验证结果。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - }, - "mode": { - "type": "string", - "enum": ["init", "sync"], - "description": "init=项目尚无蓝图时从零初始化;sync=已有蓝图,对照代码现状同步更新" - } - }, - "required": ["project_id", "mode"] - } - }, - { - "name": "sync_blueprint_rules", - "description": "将最新 CONVENTIONS 规则同步到项目(写入 .blueprint/CONVENTIONS.md + 更新 AGENTS.md/CLAUDE.md 的蓝图管理区块)。等价于炼境 UI 治理面板的「同步规则」按钮。仅当 get_blueprint_status 返回 rules_outdated 时需要调用。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - } - }, - "required": ["project_id"] - } - }, - { - "name": "create_pull_request", - "description": "通过炼境存储的 Gitea 凭证为项目创建合并请求(PR)。炼境内部调用 Gitea API,无需 Claude 自行提取 token。/ship 交付流程在推送分支后应调用此工具完成 PR 创建。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - }, - "head": { - "type": "string", - "description": "源分支名,如 dev" - }, - "base": { - "type": "string", - "description": "目标分支名,如 main" - }, - "title": { - "type": "string", - "description": "PR 标题" - }, - "body": { - "type": "string", - "description": "PR 描述(Markdown),可为空字符串" - } - }, - "required": ["project_id", "head", "base", "title"] - } - }, - { - "name": "get_project_snapshots", - "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - }, - "days": { - "type": "integer", - "description": "查询最近 N 天的快照,默认 30" - } - }, - "required": ["project_id"] - } - }, - { - "name": "poll_now", - "description": "立即触发一次 Gitea 轮询:拉取所有绑定仓库的最新 commit 和 PR 状态写入飞轮。等价于炼境 UI「立即拉取」按钮。适用于公网 Gitea + 内网炼境(webhook 不可达)场景。", - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } - }, - { - "name": "setup_webhook", - "description": "为项目重新注册 Gitea webhook(订阅 push + pull_request 事件)。等价于炼境 UI「重设」按钮。导入新项目后、或需要更新事件订阅时调用。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - } - }, - "required": ["project_id"] - } - }, - { - "name": "inject_mcp", - "description": "向项目的 .claude/settings.json 注入炼境 MCP 配置,同时写入 bypassPermissions 和 liangjing.json。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - } - }, - "required": ["project_id"] - } - }, - { - "name": "get_ci_status", - "description": "查询指定 commit 的 CI 检查结果(pass/fail/pending)。/ship 流程推送分支并开 PR 后,用此工具确认 CI 是否通过再决定是否合并。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - }, - "commit_sha": { - "type": "string", - "description": "要查询的 commit SHA(完整 40 位或前 8 位均可)" - } - }, - "required": ["project_id", "commit_sha"] - } - }, - { - "name": "merge_pull_request", - "description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。", - "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)" - } - }, - "required": ["project_id", "pr_number"] - } - }, - { - "name": "get_pr_events", - "description": "查询项目的 PR 生命周期历史(opened/merged/closed)。数据来自炼境飞轮,本地 git 命令无法获取。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - }, - "limit": { - "type": "integer", - "description": "返回最近 N 条记录,默认 20" - } - }, - "required": ["project_id"] - } - }, - { - "name": "get_commit_metrics", - "description": "查询项目的提交飞轮数据:提交类型分布(feat/fix/chore...)、是否返工、近期频率。数据来自炼境 DB,本地 git log 无法直接得到解析后的分类。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - }, - "days": { - "type": "integer", - "description": "查询最近 N 天,默认 30" - } - }, - "required": ["project_id"] - } - }, - { - "name": "distribute_cicd", - "description": "向项目写入 CI 模板文件(.gitea/workflows/)。当项目缺少 CI 配置时调用,自动解析项目路径写入。支持 pr_ci(PR 质量检查)和 deepseek_review(AI 代码审查)两种类型。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - }, - "workflow_type": { - "type": "string", - "enum": ["pr_ci", "deepseek_review"], - "description": "pr_ci=PR 合并前质量检查;deepseek_review=AI 代码审查并评论到 PR" - } - }, - "required": ["project_id", "workflow_type"] - } - }, - { - "name": "list_all_projects", - "description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。", - "inputSchema": { - "type": "object", - "properties": {}, - "required": [] - } - }, - { - "name": "apply_onboarding_pack", - "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。", - "inputSchema": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" - } - }, - "required": ["project_id"] - } - } - ] - }) -} - -// ── 工具调用 ────────────────────────────────────────────────────────────────── - -async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { - let tool_name = params - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) - .unwrap_or("") - .to_string(); - let args = params - .and_then(|p| p.get("arguments")) - .cloned() - .unwrap_or(json!({})); - let gid = group_id.to_string(); - - let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() { - "get_diagnostics" => get_diagnostics(), - "list_group_projects" => list_group_projects(&gid), - "get_project_blueprint" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - get_project_blueprint(&gid, pid) - } - "get_blueprint_status" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - resolve_project_root(&gid, pid).and_then(|root| { - crate::commands::blueprint::get_blueprint_status(root.to_string_lossy().to_string()) - .and_then(|s| serde_json::to_string_pretty(&s).map_err(|e| e.to_string())) - }) - } - "generate_blueprint_prompt" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("sync"); - if mode != "init" && mode != "sync" { - Err(format!("mode 只允许 init 或 sync,收到: {mode}")) - } else { - resolve_project_root(&gid, pid).and_then(|root| { - crate::commands::blueprint::generate_blueprint_prompt( - root.to_string_lossy().to_string(), - mode.to_string(), - ) - }) - } - } - "sync_blueprint_rules" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - resolve_project_root(&gid, pid).and_then(|root| { - crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().to_string()) - .and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string())) - }) - } - "get_project_file" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let rel = args - .get("relative_path") - .and_then(|v| v.as_str()) - .unwrap_or(""); - get_project_file(&gid, pid, rel) - } - "get_project_mentor_context" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - get_project_mentor_context(&gid, pid) - } - "append_project_note" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); - append_project_note(&gid, pid, content) - } - "get_project_snapshots" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32; - get_project_snapshots(&gid, pid, days) - } - "create_pull_request" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let head = args.get("head").and_then(|v| v.as_str()).unwrap_or(""); - let base = args.get("base").and_then(|v| v.as_str()).unwrap_or("main"); - let title = args.get("title").and_then(|v| v.as_str()).unwrap_or(""); - let body = args.get("body").and_then(|v| v.as_str()).unwrap_or(""); - crate::commands::gitea::gitea_api_create_pull_request(pid, head, base, title, body) - } - "poll_now" => { - crate::commands::gitea::poll_all_repos() - .map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested} 条,PR 事件已同步")) - } - "setup_webhook" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let port = read_port(); - let callback_base = format!("http://127.0.0.1:{}", port); - crate::commands::gitea::gitea_setup_webhook(pid.to_string(), callback_base) - .map(|repo| format!( - "webhook 注册成功:{}/{} hook_id={}", - repo.gitea_owner, repo.gitea_repo_name, - repo.webhook_id.map(|id| id.to_string()).unwrap_or_default() - )) - } - "inject_mcp" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let conn = db::pool().get().map_err(|e| e.to_string())?; - let group_id: String = conn.query_row( - "SELECT group_id FROM project_group_map WHERE project_id = ?1 LIMIT 1", - rusqlite::params![pid], - |r| r.get(0), - ).map_err(|_| format!("项目 {} 未加入任何产品组", pid))?; - drop(conn); - crate::mcp_inject::inject_project_mcp(pid, &group_id) - .map(|_| format!("MCP 注入成功:project_id={} group_id={}", pid, group_id)) - } - "get_ci_status" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let sha = args.get("commit_sha").and_then(|v| v.as_str()).unwrap_or(""); - if sha.is_empty() { return Err("commit_sha 不能为空".to_string()); } - crate::commands::gitea::gitea_api_get_ci_status(pid, sha) - } - "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); - if pr_num <= 0 { return Err("pr_number 必须为正整数".to_string()); } - crate::commands::gitea::gitea_api_merge_pull_request(pid, pr_num) - } - "get_pr_events" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; - let conn = db::pool().get().map_err(|e| e.to_string())?; - let mut stmt = conn.prepare( - "SELECT pr_number, title, action, head_branch, base_branch, author, event_at - FROM pr_events WHERE project_id = ?1 ORDER BY event_at DESC LIMIT ?2" - ).map_err(|e| e.to_string())?; - let rows: Vec = stmt.query_map( - rusqlite::params![pid, limit as i64], - |r| Ok((r.get::<_,i64>(0)?, r.get::<_,String>(1)?, r.get::<_,String>(2)?, - r.get::<_,String>(3)?, r.get::<_,String>(4)?, - r.get::<_,String>(5)?, r.get::<_,String>(6)?)), - ).map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .map(|(num, title, action, head, base, author, at)| { - let icon = match action.as_str() { - "merged" => "✅", "closed" => "🚫", _ => "🔵", - }; - format!("{icon} PR #{num} [{action}] {title}\n {head} → {base} by {author} @ {at}") - }).collect(); - if rows.is_empty() { - Ok(format!("项目 {} 暂无 PR 事件记录", pid)) - } else { - Ok(format!("PR 事件(最近 {} 条):\n\n{}", rows.len(), rows.join("\n\n"))) - } - } - "get_commit_metrics" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30); - let conn = db::pool().get().map_err(|e| e.to_string())?; - let offset = format!("-{} days", days); - - // 总体统计 - let total: i64 = conn.query_row( - "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)", - rusqlite::params![pid, offset], - |r| r.get(0), - ).unwrap_or(0); - - // 按 commit_type 分组 - let mut type_stmt = conn.prepare( - "SELECT COALESCE(commit_type,'unknown') as t, COUNT(*) as n, - SUM(is_rework) as rework - FROM commit_metrics WHERE project_id = ?1 - AND committed_at >= datetime('now', ?2) - GROUP BY t ORDER BY n DESC" - ).map_err(|e| e.to_string())?; - let type_rows: Vec = type_stmt.query_map( - rusqlite::params![pid, offset], - |r| Ok((r.get::<_,String>(0)?, r.get::<_,i64>(1)?, r.get::<_,i64>(2)?)), - ).map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .map(|(t, n, rework)| format!(" - {t}: {n} 条(返工 {rework} 条)")) - .collect(); - - let rework_total: i64 = conn.query_row( - "SELECT SUM(is_rework) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)", - rusqlite::params![pid, offset], - |r| r.get(0), - ).unwrap_or(0); - - let mut out = format!("## 提交飞轮数据(最近 {} 天)\n\n", days); - out.push_str(&format!("- **总提交数**:{}\n", total)); - out.push_str(&format!("- **返工提交**:{} 条({:.0}%)\n", rework_total, - if total > 0 { rework_total as f64 / total as f64 * 100.0 } else { 0.0 })); - out.push_str("\n**按类型分布**:\n"); - out.push_str(&type_rows.join("\n")); - Ok(out) - } - "distribute_cicd" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or(""); - crate::commands::cicd::distribute_workflow_for_project(pid, wt) - } - "list_all_projects" => list_all_projects(), - "apply_onboarding_pack" => { - let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - resolve_project_root(&gid, pid).and_then(|root| { - let path = root.to_string_lossy().to_string(); - crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()) - .map(|report| { - let template_lines: Vec = report.templates.iter() - .map(|t| { - let icon = match t.action.as_str() { - "written" => "✅ written", - "updated" => "🔄 updated", - "appended" => "➕ appended", - "skipped" => "⏭ skipped", - _ => "❌ failed", - }; - format!(" {icon}: {}", t.name) - }) - .collect(); - format!( - "接入包分发完成(project_id={})\n\n模板:\n{}\n\nbuff:{}", - pid, - template_lines.join("\n"), - report.buff.status - ) - }) - }) - } - _ => Err(format!("未知工具: {}", tool_name)), - }) - .await; - - match outcome { - Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }), - Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }), - Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }), - } -} - -// ── 工具实现(同步,由 spawn_blocking 包裹)────────────────────────────────── - -fn get_diagnostics() -> Result { - let conn = db::pool().get().map_err(|e| e.to_string())?; - let port = read_port(); - - // 服务启动时间(首条 mcp_server info 日志) - let start_time: Option = conn.query_row( - "SELECT ts FROM runtime_logs WHERE category = 'mcp_server' AND level = 'info' ORDER BY id ASC LIMIT 1", - [], - |r| r.get(0), - ).ok(); - - let mut out = String::new(); - out.push_str("## 炼境 MCP 诊断报告\n\n"); - out.push_str(&format!("- **服务端口**: {}\n", port)); - out.push_str(&format!("- **启动时间**: {}\n\n", start_time.as_deref().unwrap_or("未知(可能在本次日志之前启动)"))); - - // MCP 注入状态:每个 project_id 取最后一条 - out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n"); - let mut stmt = conn.prepare( - "SELECT context, level, message, ts - FROM runtime_logs - WHERE category = 'mcp_inject' - AND id IN ( - SELECT MAX(id) FROM runtime_logs - WHERE category = 'mcp_inject' - GROUP BY json_extract(context, '$.project_id') - ) - ORDER BY ts DESC - LIMIT 50", - ).map_err(|e| e.to_string())?; - - let inject_rows: Vec<(Option, String, String, String)> = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) - .map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .collect(); - - if inject_rows.is_empty() { - out.push_str("_暂无注入记录(成员加入产品组时会产生)_\n\n"); - } else { - for (ctx, level, msg, ts) in &inject_rows { - let project_id = ctx.as_deref() - .and_then(|s| serde_json::from_str::(s).ok()) - .and_then(|v| v.get("project_id").and_then(|x| x.as_str()).map(|s| s.to_string())) - .unwrap_or_else(|| "unknown".to_string()); - let icon = if level == "info" { "✅" } else { "⚠️" }; - out.push_str(&format!("- {} `{}` — {} _({})\n", icon, project_id, msg, ts)); - } - out.push('\n'); - } - - // 最近 20 条 error/warn - out.push_str("### 最近错误日志(最多 20 条)\n\n"); - let mut stmt2 = conn.prepare( - "SELECT ts, category, level, message FROM runtime_logs - WHERE level IN ('error', 'warn') - ORDER BY id DESC LIMIT 20", - ).map_err(|e| e.to_string())?; - - let error_rows: Vec<(String, String, String, String)> = stmt2 - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) - .map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .collect(); - - if error_rows.is_empty() { - out.push_str("_无错误记录_ ✅\n"); - } else { - for (ts, cat, level, msg) in &error_rows { - out.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", ts, level, cat, msg)); - } - } - - // ── 系统健康(复用 health check 逻辑)──────────────────────────────────── - out.push_str("\n### 系统健康\n\n"); - let report = crate::commands::settings::collect_health_report(); - out.push_str(&format!("检查时间:{}\n\n", report.checked_at)); - out.push_str("| 检查项 | 状态 | 详情 |\n|--------|------|------|\n"); - for item in &report.items { - let icon = match item.status.as_str() { - "ok" => "✅", - "warn" => "⚠️", - "error" => "❌", - _ => "—", - }; - out.push_str(&format!( - "| {} | {} {} | {} |\n", - item.name, icon, item.status, - item.detail.as_deref().unwrap_or("-") - )); - } - - // ── 质量指标 ────────────────────────────────────────────────────────────── - out.push_str("\n### 质量指标\n\n"); - let q = &report.quality; - - out.push_str(&format!("- **休眠项目**(>30天未 push):{} 个\n", q.dormant_projects.len())); - for p in &q.dormant_projects { - out.push_str(&format!(" - {} ({} 天)\n", p.name, p.days_since_push)); - } - - out.push_str(&format!("- **孤立项目**(无分组 & 无 repo):{} 个\n", q.orphaned_projects.len())); - for p in &q.orphaned_projects { - out.push_str(&format!(" - {}\n", p.name)); - } - - out.push_str(&format!( - "- **MCP 注入异常产品组**(近 7 天):{} 个\n", - q.groups_with_inject_warn.len() - )); - for gid in &q.groups_with_inject_warn { - out.push_str(&format!(" - `{}`\n", gid)); - } - - out.push_str(&format!("- **无本地路径项目**:{} 个\n", q.projects_without_path)); - - Ok(out) -} - -fn list_group_projects(group_id: &str) -> Result { - let conn = db::pool().get().map_err(|e| e.to_string())?; - let mut stmt = conn - .prepare( - "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, - pp.tech_stack, pgm.role - FROM project_group_map pgm - JOIN project_workspaces pw ON pw.id = pgm.project_id - JOIN project_profiles pp ON pp.id = pw.profile_id - WHERE pgm.group_id = ?1", - ) - .map_err(|e| e.to_string())?; - - let rows: Vec = stmt - .query_map([group_id], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let platform: Option = row.get(2)?; - let win_path: Option = row.get(3)?; - let wsl_path: Option = row.get(4)?; - let tech_stack: Option = row.get(5)?; - let role: Option = row.get(6)?; - Ok(format!( - "- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}", - name, - id, - platform.as_deref().unwrap_or("unknown"), - tech_stack.as_deref().unwrap_or("未配置"), - role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(), - win_path.as_deref().unwrap_or("未配置"), - wsl_path.as_deref().unwrap_or("未配置"), - )) - }) - .map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .collect(); - - if rows.is_empty() { - return Ok("该产品组暂无成员项目。".to_string()); - } - Ok(format!( - "产品组成员(共 {} 个项目):\n\n{}", - rows.len(), - rows.join("\n\n") - )) -} - -fn get_project_blueprint(group_id: &str, project_id: &str) -> Result { - let root = resolve_project_root(group_id, project_id)?; - let path = root.join(".blueprint").join("manifest.yaml"); - std::fs::read_to_string(&path) - .map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e)) -} - -fn get_project_file(group_id: &str, project_id: &str, relative_path: &str) -> Result { - if relative_path.contains("..") { - return Err("路径不允许包含 \"..\"".to_string()); - } - let root = resolve_project_root(group_id, project_id)?; - let path = root.join(relative_path); - std::fs::read_to_string(&path) - .map_err(|e| format!("读取文件失败({}): {}", path.display(), e)) -} - -/// 根据 group_id + project_id 解析项目根目录路径 -/// WSL 项目自动转换为 Windows UNC 路径(\\wsl.localhost\\...) -fn resolve_project_root(group_id: &str, project_id: &str) -> Result { - let conn = db::pool().get().map_err(|e| e.to_string())?; - - let row: rusqlite::Result<(Option, Option, Option)> = conn.query_row( - "SELECT pw.win_path, pw.wsl_path, pw.platform - FROM project_group_map pgm - JOIN project_workspaces pw ON pw.id = pgm.project_id - WHERE pgm.group_id = ?1 AND pgm.project_id = ?2", - [group_id, project_id], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), - ); - - let (win_path, wsl_path, platform) = row.map_err(|_| { - format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id) - })?; - - if platform.as_deref() == Some("wsl") { - let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?; - let distro: String = conn - .query_row( - "SELECT value FROM settings WHERE key = 'wsl_distro'", - [], - |r| r.get(0), - ) - .unwrap_or_else(|_| "Ubuntu".to_string()); - // /home/user/project → \\wsl.localhost\Ubuntu\home\user\project - let inner = wsl.trim_start_matches('/').replace('/', "\\"); - Ok(std::path::PathBuf::from(format!( - "\\\\wsl.localhost\\{}\\{}", - distro, inner - ))) - } else { - let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?; - Ok(std::path::PathBuf::from(win)) - } -} - -fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result { - // 验证项目属于该产品组(与其他 MCP 工具一致) - let conn = db::pool().get().map_err(|e| e.to_string())?; - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", - [group_id, project_id], - |r| r.get::<_, i64>(0), - ) - .map(|n| n > 0) - .unwrap_or(false); - if !exists { - return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); - } - - let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string()) - .map_err(|e| format!("获取导师上下文失败: {e}"))?; - Ok(ctx.mentor_markdown) -} - -fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Result { - if content.trim().is_empty() { - return Err("笔记内容不能为空".to_string()); - } - let conn = db::pool().get().map_err(|e| e.to_string())?; - // 校验 project_id 属于该 group - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", - [group_id, project_id], - |r| r.get::<_, i64>(0), - ) - .map(|n| n > 0) - .unwrap_or(false); - if !exists { - return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); - } - conn.execute( - "INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)", - rusqlite::params![project_id, content], - ) - .map_err(|e| e.to_string())?; - Ok("笔记已保存 ✅".to_string()) -} - -fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result { - let conn = db::pool().get().map_err(|e| e.to_string())?; - - // 校验项目属于该产品组 - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", - [group_id, project_id], - |r| r.get::<_, i64>(0), - ) - .map(|n| n > 0) - .unwrap_or(false); - if !exists { - return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); - } - - let mut stmt = conn - .prepare( - "SELECT snapshot_date, days_since_commit, blueprint_done_rate, - blueprint_in_progress, blueprint_blocked, has_errors - FROM project_snapshots - WHERE project_id = ?1 - AND snapshot_date >= date('now', 'localtime', ?2) - ORDER BY snapshot_date ASC", - ) - .map_err(|e| e.to_string())?; - - let offset = format!("-{} days", days); - let rows: Vec<(String, Option, Option, i64, i64, i64)> = stmt - .query_map(rusqlite::params![project_id, offset], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) - }) - .map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .collect(); - - if rows.is_empty() { - return Ok(format!( - "项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。", - project_id, days - )); - } - - let mut md = format!( - "# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n", - project_id, - days, - rows.len() - ); - md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n"); - md.push_str("|------|--------------|-----------|-----------|---------|------|\n"); - - for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows { - let commit_col = days_commit - .map(|d| d.to_string()) - .unwrap_or_else(|| "—".to_string()); - let rate_col = done_rate - .map(|r| format!("{:.0}%", r * 100.0)) - .unwrap_or_else(|| "—".to_string()); - let err_col = if *has_err == 1 { "⚠️" } else { "✅" }; - md.push_str(&format!( - "| {} | {} | {} | {} | {} | {} |\n", - date, commit_col, rate_col, in_prog, blocked, err_col - )); - } - - md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n"); - - Ok(md) -} - -fn list_all_projects() -> Result { - let conn = db::pool().get().map_err(|e| e.to_string())?; - - let mut stmt = conn.prepare( - "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, - pgm.group_id - FROM project_workspaces pw - JOIN project_profiles pp ON pp.id = pw.profile_id - LEFT JOIN project_group_map pgm ON pgm.project_id = pw.id - ORDER BY pp.name" - ).map_err(|e| e.to_string())?; - - let rows: Vec<(String, String, Option, Option, Option, Option)> = - stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))) - .map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .collect(); - - if rows.is_empty() { - return Ok("炼境暂无登记项目。".to_string()); - } - - let mut out = format!("## 炼境全量项目(共 {} 个)\n\n", rows.len()); - for (id, name, platform, win_path, wsl_path, group_id) in &rows { - // 判断是否已注入:检查 liangjing.json 是否存在 - let project_path = if platform.as_deref() == Some("wsl") { - wsl_path.as_ref().map(|p| { - let conn2 = db::pool().get().ok(); - let distro = conn2.and_then(|c| c.query_row( - "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get::<_,String>(0) - ).ok()).unwrap_or_else(|| "Ubuntu".to_string()); - let inner = p.trim_start_matches('/').replace('/', "\\"); - format!("\\\\wsl.localhost\\{}\\{}", distro, inner) - }) - } else { - win_path.clone() - }; - - let injected = project_path.as_ref().map(|p| { - std::path::Path::new(p).join(".claude").join("liangjing.json").exists() - }).unwrap_or(false); - - let inject_icon = if injected { "✅ 已注入" } else { "⚠️ 未注入" }; - let group_str = group_id.as_deref().unwrap_or("无产品组"); - let path_str = project_path.as_deref().unwrap_or("未配置路径"); - - out.push_str(&format!( - "- **{}** `{}`\n {} | 组: `{}` | {}\n\n", - name, id, inject_icon, group_str, path_str - )); - } - out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。"); - Ok(out) -} - -// ── Gitea Webhook 单元测试 ──────────────────────────────────────────────────── - -#[cfg(test)] -mod webhook_tests { - use super::*; - - /// 用相同算法生成签名,验证正反两个方向 - fn sign(secret: &str, body: &[u8]) -> String { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); - mac.update(body); - mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect() - } - - #[test] - fn hmac_verify_accepts_valid_signature() { - let body = br#"{"commits":[]}"#; - let sig = sign("my-secret", body); - assert!(verify_hmac_sha256("my-secret", body, &sig)); - // 大写 hex 也应通过 - assert!(verify_hmac_sha256("my-secret", body, &sig.to_uppercase())); - } - - #[test] - fn hmac_verify_rejects_wrong_secret_or_tampered_body() { - let body = br#"{"commits":[]}"#; - let sig = sign("my-secret", body); - assert!(!verify_hmac_sha256("other-secret", body, &sig)); - assert!(!verify_hmac_sha256("my-secret", br#"{"commits":[1]}"#, &sig)); - assert!(!verify_hmac_sha256("my-secret", body, "deadbeef")); - } - - #[test] - fn parse_push_payload_extracts_commits() { - let body = br#"{ - "ref": "refs/heads/master", - "commits": [ - {"id": "abc123", "message": "feat(core): add thing", "timestamp": "2026-07-02T10:00:00+08:00"}, - {"id": "def456", "message": "fix(ui): repair", "timestamp": null} - ] - }"#; - let commits = parse_push_payload(body).unwrap(); - assert_eq!(commits.len(), 2); - assert_eq!(commits[0].sha, "abc123"); - assert_eq!(commits[0].message, "feat(core): add thing"); - assert_eq!(commits[0].timestamp.as_deref(), Some("2026-07-02T10:00:00+08:00")); - assert_eq!(commits[1].timestamp, None); - } - - #[test] - fn parse_push_payload_rejects_invalid() { - assert!(parse_push_payload(b"not json").is_err()); - assert!(parse_push_payload(br#"{"no_commits": true}"#).is_err()); - // commits 为空数组是合法的(如仅 tag push) - assert_eq!(parse_push_payload(br#"{"commits":[]}"#).unwrap().len(), 0); - } -} diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs new file mode 100644 index 0000000..59d93bb --- /dev/null +++ b/src-tauri/src/mcp_tools.rs @@ -0,0 +1,904 @@ +use serde_json::{json, Value}; + +use crate::db; + +// ── 工具 Schema 定义 ────────────────────────────────────────────────────────── + +pub fn tools_list_result() -> Value { + json!({ + "tools": [ + { + "name": "get_diagnostics", + "description": "查询炼境运行时诊断信息:MCP Server 启动状态、各项目 MCP 配置注入结果、最近错误日志。排查功能是否正常时首先调用此工具。", + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }, + { + "name": "list_group_projects", + "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息", + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }, + { + "name": "get_project_blueprint", + "description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml),了解该项目的模块与任务状态", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } + }, + "required": ["project_id"] + } + }, + { + "name": "get_project_file", + "description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID" }, + "relative_path": { "type": "string", "description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts" } + }, + "required": ["project_id", "relative_path"] + } + }, + { + "name": "get_project_mentor_context", + "description": "获取产品组内某个项目的完整导师上下文——包含健康状态、蓝图进度摘要、近期错误日志,以及格式化好的 Markdown 报告(可直接用于质量分析和成长建议)", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } + }, + "required": ["project_id"] + } + }, + { + "name": "append_project_note", + "description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "content": { + "type": "string", + "description": "笔记内容。完成 M/L 任务后使用以下结构化格式:\n【复盘】<任务标题>\n任务类型: 后端命令|前端组件|数据层|MCP工具|架构调整\n复杂度: S|M|L\n实际轮数: N轮(预估M轮)\n是否返工: 是/否\n返工原因: <若返工填写,否则省略>\n有效策略: <本次奏效的做法>\n遗留风险: <若有填写,否则省略>\n\n其他场景(分析结论、建议、洞察)可自由格式。" + } + }, + "required": ["project_id", "content"] + } + }, + { + "name": "get_blueprint_status", + "description": "检测项目蓝图治理状态:CONVENTIONS 规则版本是否最新、蓝图内容是否可能落后于代码(synced/rules_outdated/content_stale/none)。等价于炼境 UI 项目卡片上的蓝图状态徽章。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } + }, + "required": ["project_id"] + } + }, + { + "name": "generate_blueprint_prompt", + "description": "生成蓝图初始化/同步提示词(含 CONVENTIONS 规范与 manifest 严格格式约束)。等价于炼境 UI 治理面板的「初始化蓝图/更新蓝图描绘」按钮。拿到提示词后在该项目目录下执行它(或交给该项目的 AI 会话),完成后可用 get_blueprint_status / get_project_blueprint 验证结果。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "mode": { "type": "string", "enum": ["init", "sync"], "description": "init=项目尚无蓝图时从零初始化;sync=已有蓝图,对照代码现状同步更新" } + }, + "required": ["project_id", "mode"] + } + }, + { + "name": "sync_blueprint_rules", + "description": "将最新 CONVENTIONS 规则同步到项目(写入 .blueprint/CONVENTIONS.md + 更新 AGENTS.md/CLAUDE.md 的蓝图管理区块)。等价于炼境 UI 治理面板的「同步规则」按钮。仅当 get_blueprint_status 返回 rules_outdated 时需要调用。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } + }, + "required": ["project_id"] + } + }, + { + "name": "create_pull_request", + "description": "通过炼境存储的 Gitea 凭证为项目创建合并请求(PR)。炼境内部调用 Gitea API,无需 Claude 自行提取 token。/ship 交付流程在推送分支后应调用此工具完成 PR 创建。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "head": { "type": "string", "description": "源分支名,如 dev" }, + "base": { "type": "string", "description": "目标分支名,如 main" }, + "title": { "type": "string", "description": "PR 标题" }, + "body": { "type": "string", "description": "PR 描述(Markdown),可为空字符串" } + }, + "required": ["project_id", "head", "base", "title"] + } + }, + { + "name": "get_project_snapshots", + "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "days": { "type": "integer", "description": "查询最近 N 天的快照,默认 30" } + }, + "required": ["project_id"] + } + }, + { + "name": "poll_now", + "description": "立即触发一次 Gitea 轮询:拉取所有绑定仓库的最新 commit 和 PR 状态写入飞轮。等价于炼境 UI「立即拉取」按钮。适用于公网 Gitea + 内网炼境(webhook 不可达)场景。", + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }, + { + "name": "setup_webhook", + "description": "为项目重新注册 Gitea webhook(订阅 push + pull_request 事件)。等价于炼境 UI「重设」按钮。导入新项目后、或需要更新事件订阅时调用。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } + }, + "required": ["project_id"] + } + }, + { + "name": "inject_mcp", + "description": "向项目的 .claude/settings.json 注入炼境 MCP 配置,同时写入 bypassPermissions 和 liangjing.json。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } + }, + "required": ["project_id"] + } + }, + { + "name": "get_ci_status", + "description": "查询指定 commit 的 CI 检查结果(pass/fail/pending)。/ship 流程推送分支并开 PR 后,用此工具确认 CI 是否通过再决定是否合并。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "commit_sha": { "type": "string", "description": "要查询的 commit SHA(完整 40 位或前 8 位均可)" } + }, + "required": ["project_id", "commit_sha"] + } + }, + { + "name": "merge_pull_request", + "description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。", + "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)" } + }, + "required": ["project_id", "pr_number"] + } + }, + { + "name": "get_pr_events", + "description": "查询项目的 PR 生命周期历史(opened/merged/closed)。数据来自炼境飞轮,本地 git 命令无法获取。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "limit": { "type": "integer", "description": "返回最近 N 条记录,默认 20" } + }, + "required": ["project_id"] + } + }, + { + "name": "get_commit_metrics", + "description": "查询项目的提交飞轮数据:提交类型分布(feat/fix/chore...)、是否返工、近期频率。数据来自炼境 DB,本地 git log 无法直接得到解析后的分类。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "days": { "type": "integer", "description": "查询最近 N 天,默认 30" } + }, + "required": ["project_id"] + } + }, + { + "name": "distribute_cicd", + "description": "向项目写入 CI 模板文件(.gitea/workflows/)。当项目缺少 CI 配置时调用,自动解析项目路径写入。支持 pr_ci(PR 质量检查)和 deepseek_review(AI 代码审查)两种类型。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review"], "description": "pr_ci=PR 合并前质量检查;deepseek_review=AI 代码审查并评论到 PR" } + }, + "required": ["project_id", "workflow_type"] + } + }, + { + "name": "list_all_projects", + "description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。", + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }, + { + "name": "apply_onboarding_pack", + "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } + }, + "required": ["project_id"] + } + } + ] + }) +} + +// ── 工具分发 ────────────────────────────────────────────────────────────────── + +pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { + let tool_name = params + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let args = params + .and_then(|p| p.get("arguments")) + .cloned() + .unwrap_or(json!({})); + let gid = group_id.to_string(); + + let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() { + "get_diagnostics" => get_diagnostics(), + "list_group_projects" => list_group_projects(&gid), + "get_project_blueprint" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + get_project_blueprint(&gid, pid) + } + "get_blueprint_status" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + resolve_project_root(&gid, pid).and_then(|root| { + crate::commands::blueprint::get_blueprint_status(root.to_string_lossy().to_string()) + .and_then(|s| serde_json::to_string_pretty(&s).map_err(|e| e.to_string())) + }) + } + "generate_blueprint_prompt" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("sync"); + if mode != "init" && mode != "sync" { + Err(format!("mode 只允许 init 或 sync,收到: {mode}")) + } else { + resolve_project_root(&gid, pid).and_then(|root| { + crate::commands::blueprint::generate_blueprint_prompt( + root.to_string_lossy().to_string(), + mode.to_string(), + ) + }) + } + } + "sync_blueprint_rules" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + resolve_project_root(&gid, pid).and_then(|root| { + crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().to_string()) + .and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string())) + }) + } + "get_project_file" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let rel = args.get("relative_path").and_then(|v| v.as_str()).unwrap_or(""); + get_project_file(&gid, pid, rel) + } + "get_project_mentor_context" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + get_project_mentor_context(&gid, pid) + } + "append_project_note" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); + append_project_note(&gid, pid, content) + } + "get_project_snapshots" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32; + get_project_snapshots(&gid, pid, days) + } + "create_pull_request" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let head = args.get("head").and_then(|v| v.as_str()).unwrap_or(""); + let base = args.get("base").and_then(|v| v.as_str()).unwrap_or("main"); + let title = args.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let body = args.get("body").and_then(|v| v.as_str()).unwrap_or(""); + crate::commands::gitea::gitea_api_create_pull_request(pid, head, base, title, body) + } + "poll_now" => crate::commands::gitea::poll_all_repos() + .map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested} 条,PR 事件已同步")), + "setup_webhook" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let port = crate::mcp_server::read_port(); + let callback_base = format!("http://127.0.0.1:{}", port); + crate::commands::gitea::gitea_setup_webhook(pid.to_string(), callback_base).map(|repo| { + format!( + "webhook 注册成功:{}/{} hook_id={}", + repo.gitea_owner, + repo.gitea_repo_name, + repo.webhook_id.map(|id| id.to_string()).unwrap_or_default() + ) + }) + } + "inject_mcp" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let conn = db::pool().get().map_err(|e| e.to_string())?; + let group_id: String = conn + .query_row( + "SELECT group_id FROM project_group_map WHERE project_id = ?1 LIMIT 1", + rusqlite::params![pid], + |r| r.get(0), + ) + .map_err(|_| format!("项目 {} 未加入任何产品组", pid))?; + drop(conn); + crate::mcp_inject::inject_project_mcp(pid, &group_id) + .map(|_| format!("MCP 注入成功:project_id={} group_id={}", pid, group_id)) + } + "get_ci_status" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let sha = args.get("commit_sha").and_then(|v| v.as_str()).unwrap_or(""); + if sha.is_empty() { + return Err("commit_sha 不能为空".to_string()); + } + crate::commands::gitea::gitea_api_get_ci_status(pid, sha) + } + "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); + if pr_num <= 0 { + return Err("pr_number 必须为正整数".to_string()); + } + crate::commands::gitea::gitea_api_merge_pull_request(pid, pr_num) + } + "get_pr_events" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; + let conn = db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare( + "SELECT pr_number, title, action, head_branch, base_branch, author, event_at + FROM pr_events WHERE project_id = ?1 ORDER BY event_at DESC LIMIT ?2", + ) + .map_err(|e| e.to_string())?; + let rows: Vec = stmt + .query_map(rusqlite::params![pid, limit as i64], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + r.get::<_, String>(6)?, + )) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .map(|(num, title, action, head, base, author, at)| { + let icon = match action.as_str() { + "merged" => "✅", + "closed" => "🚫", + _ => "🔵", + }; + format!("{icon} PR #{num} [{action}] {title}\n {head} → {base} by {author} @ {at}") + }) + .collect(); + if rows.is_empty() { + Ok(format!("项目 {} 暂无 PR 事件记录", pid)) + } else { + Ok(format!("PR 事件(最近 {} 条):\n\n{}", rows.len(), rows.join("\n\n"))) + } + } + "get_commit_metrics" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30); + let conn = db::pool().get().map_err(|e| e.to_string())?; + let offset = format!("-{} days", days); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)", + rusqlite::params![pid, offset], + |r| r.get(0), + ) + .unwrap_or(0); + let mut type_stmt = conn + .prepare( + "SELECT COALESCE(commit_type,'unknown') as t, COUNT(*) as n, SUM(is_rework) as rework + FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2) + GROUP BY t ORDER BY n DESC", + ) + .map_err(|e| e.to_string())?; + let type_rows: Vec = type_stmt + .query_map(rusqlite::params![pid, offset], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .map(|(t, n, rework)| format!(" - {t}: {n} 条(返工 {rework} 条)")) + .collect(); + let rework_total: i64 = conn + .query_row( + "SELECT SUM(is_rework) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)", + rusqlite::params![pid, offset], + |r| r.get(0), + ) + .unwrap_or(0); + let mut out = format!("## 提交飞轮数据(最近 {} 天)\n\n", days); + out.push_str(&format!("- **总提交数**:{}\n", total)); + out.push_str(&format!( + "- **返工提交**:{} 条({:.0}%)\n", + rework_total, + if total > 0 { rework_total as f64 / total as f64 * 100.0 } else { 0.0 } + )); + out.push_str("\n**按类型分布**:\n"); + out.push_str(&type_rows.join("\n")); + Ok(out) + } + "distribute_cicd" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or(""); + crate::commands::cicd::distribute_workflow_for_project(pid, wt) + } + "list_all_projects" => list_all_projects(), + "apply_onboarding_pack" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + resolve_project_root(&gid, pid).and_then(|root| { + let path = root.to_string_lossy().to_string(); + crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map( + |report| { + let template_lines: Vec = report + .templates + .iter() + .map(|t| { + let icon = match t.action.as_str() { + "written" => "✅ written", + "updated" => "🔄 updated", + "appended" => "➕ appended", + "skipped" => "⏭ skipped", + _ => "❌ failed", + }; + format!(" {icon}: {}", t.name) + }) + .collect(); + format!( + "接入包分发完成(project_id={})\n\n模板:\n{}\n\nbuff:{}", + pid, + template_lines.join("\n"), + report.buff.status + ) + }, + ) + }) + } + _ => Err(format!("未知工具: {}", tool_name)), + }) + .await; + + match outcome { + Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }), + Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }), + Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }), + } +} + +// ── 工具实现 ────────────────────────────────────────────────────────────────── + +fn get_diagnostics() -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let port = crate::mcp_server::read_port(); + + let start_time: Option = conn + .query_row( + "SELECT ts FROM runtime_logs WHERE category = 'mcp_server' AND level = 'info' ORDER BY id ASC LIMIT 1", + [], + |r| r.get(0), + ) + .ok(); + + let mut out = String::new(); + out.push_str("## 炼境 MCP 诊断报告\n\n"); + out.push_str(&format!("- **服务端口**: {}\n", port)); + out.push_str(&format!( + "- **启动时间**: {}\n\n", + start_time.as_deref().unwrap_or("未知(可能在本次日志之前启动)") + )); + + out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n"); + let mut stmt = conn + .prepare( + "SELECT context, level, message, ts + FROM runtime_logs + WHERE category = 'mcp_inject' + AND id IN ( + SELECT MAX(id) FROM runtime_logs + WHERE category = 'mcp_inject' + GROUP BY json_extract(context, '$.project_id') + ) + ORDER BY ts DESC + LIMIT 50", + ) + .map_err(|e| e.to_string())?; + + let inject_rows: Vec<(Option, String, String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + if inject_rows.is_empty() { + out.push_str("_暂无注入记录(成员加入产品组时会产生)_\n\n"); + } else { + for (ctx, level, msg, ts) in &inject_rows { + let project_id = ctx + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .and_then(|v| v.get("project_id").and_then(|x| x.as_str()).map(|s| s.to_string())) + .unwrap_or_else(|| "unknown".to_string()); + let icon = if level == "info" { "✅" } else { "⚠️" }; + out.push_str(&format!("- {} `{}` — {} _({})\n", icon, project_id, msg, ts)); + } + out.push('\n'); + } + + out.push_str("### 最近错误日志(最多 20 条)\n\n"); + let mut stmt2 = conn + .prepare( + "SELECT ts, category, level, message FROM runtime_logs + WHERE level IN ('error', 'warn') + ORDER BY id DESC LIMIT 20", + ) + .map_err(|e| e.to_string())?; + + let error_rows: Vec<(String, String, String, String)> = stmt2 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + if error_rows.is_empty() { + out.push_str("_无错误记录_ ✅\n"); + } else { + for (ts, cat, level, msg) in &error_rows { + out.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", ts, level, cat, msg)); + } + } + + out.push_str("\n### 系统健康\n\n"); + let report = crate::commands::settings::collect_health_report(); + out.push_str(&format!("检查时间:{}\n\n", report.checked_at)); + out.push_str("| 检查项 | 状态 | 详情 |\n|--------|------|------|\n"); + for item in &report.items { + let icon = match item.status.as_str() { + "ok" => "✅", + "warn" => "⚠️", + "error" => "❌", + _ => "—", + }; + out.push_str(&format!( + "| {} | {} {} | {} |\n", + item.name, + icon, + item.status, + item.detail.as_deref().unwrap_or("-") + )); + } + + out.push_str("\n### 质量指标\n\n"); + let q = &report.quality; + out.push_str(&format!("- **休眠项目**(>30天未 push):{} 个\n", q.dormant_projects.len())); + for p in &q.dormant_projects { + out.push_str(&format!(" - {} ({} 天)\n", p.name, p.days_since_push)); + } + out.push_str(&format!("- **孤立项目**(无分组 & 无 repo):{} 个\n", q.orphaned_projects.len())); + for p in &q.orphaned_projects { + out.push_str(&format!(" - {}\n", p.name)); + } + out.push_str(&format!( + "- **MCP 注入异常产品组**(近 7 天):{} 个\n", + q.groups_with_inject_warn.len() + )); + for gid in &q.groups_with_inject_warn { + out.push_str(&format!(" - `{}`\n", gid)); + } + out.push_str(&format!("- **无本地路径项目**:{} 个\n", q.projects_without_path)); + + Ok(out) +} + +fn list_group_projects(group_id: &str) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare( + "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pp.tech_stack, pgm.role + FROM project_group_map pgm + JOIN project_workspaces pw ON pw.id = pgm.project_id + JOIN project_profiles pp ON pp.id = pw.profile_id + WHERE pgm.group_id = ?1", + ) + .map_err(|e| e.to_string())?; + + let rows: Vec = stmt + .query_map([group_id], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let platform: Option = row.get(2)?; + let win_path: Option = row.get(3)?; + let wsl_path: Option = row.get(4)?; + let tech_stack: Option = row.get(5)?; + let role: Option = row.get(6)?; + Ok(format!( + "- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}", + name, + id, + platform.as_deref().unwrap_or("unknown"), + tech_stack.as_deref().unwrap_or("未配置"), + role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(), + win_path.as_deref().unwrap_or("未配置"), + wsl_path.as_deref().unwrap_or("未配置"), + )) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + if rows.is_empty() { + return Ok("该产品组暂无成员项目。".to_string()); + } + Ok(format!("产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n"))) +} + +fn get_project_blueprint(group_id: &str, project_id: &str) -> Result { + let root = resolve_project_root(group_id, project_id)?; + let path = root.join(".blueprint").join("manifest.yaml"); + std::fs::read_to_string(&path) + .map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e)) +} + +fn get_project_file(group_id: &str, project_id: &str, relative_path: &str) -> Result { + if relative_path.contains("..") { + return Err("路径不允许包含 \"..\"".to_string()); + } + let root = resolve_project_root(group_id, project_id)?; + let path = root.join(relative_path); + std::fs::read_to_string(&path) + .map_err(|e| format!("读取文件失败({}): {}", path.display(), e)) +} + +pub fn resolve_project_root(group_id: &str, project_id: &str) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let row: rusqlite::Result<(Option, Option, Option)> = conn.query_row( + "SELECT pw.win_path, pw.wsl_path, pw.platform + FROM project_group_map pgm + JOIN project_workspaces pw ON pw.id = pgm.project_id + WHERE pgm.group_id = ?1 AND pgm.project_id = ?2", + [group_id, project_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ); + let (win_path, wsl_path, platform) = row + .map_err(|_| format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id))?; + + if platform.as_deref() == Some("wsl") { + let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?; + let distro: String = conn + .query_row("SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0)) + .unwrap_or_else(|_| "Ubuntu".to_string()); + let inner = wsl.trim_start_matches('/').replace('/', "\\"); + Ok(std::path::PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}", distro, inner))) + } else { + let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?; + Ok(std::path::PathBuf::from(win)) + } +} + +fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", + [group_id, project_id], + |r| r.get::<_, i64>(0), + ) + .map(|n| n > 0) + .unwrap_or(false); + if !exists { + return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); + } + let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string()) + .map_err(|e| format!("获取导师上下文失败: {e}"))?; + Ok(ctx.mentor_markdown) +} + +fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Result { + if content.trim().is_empty() { + return Err("笔记内容不能为空".to_string()); + } + let conn = db::pool().get().map_err(|e| e.to_string())?; + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", + [group_id, project_id], + |r| r.get::<_, i64>(0), + ) + .map(|n| n > 0) + .unwrap_or(false); + if !exists { + return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); + } + conn.execute( + "INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)", + rusqlite::params![project_id, content], + ) + .map_err(|e| e.to_string())?; + Ok("笔记已保存 ✅".to_string()) +} + +fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", + [group_id, project_id], + |r| r.get::<_, i64>(0), + ) + .map(|n| n > 0) + .unwrap_or(false); + if !exists { + return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); + } + + let mut stmt = conn + .prepare( + "SELECT snapshot_date, days_since_commit, blueprint_done_rate, + blueprint_in_progress, blueprint_blocked, has_errors + FROM project_snapshots + WHERE project_id = ?1 + AND snapshot_date >= date('now', 'localtime', ?2) + ORDER BY snapshot_date ASC", + ) + .map_err(|e| e.to_string())?; + + let offset = format!("-{} days", days); + let rows: Vec<(String, Option, Option, i64, i64, i64)> = stmt + .query_map(rusqlite::params![project_id, offset], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + if rows.is_empty() { + return Ok(format!( + "项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。", + project_id, days + )); + } + + let mut md = format!( + "# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n", + project_id, + days, + rows.len() + ); + md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n"); + md.push_str("|------|--------------|-----------|-----------|---------|------|\n"); + for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows { + let commit_col = days_commit.map(|d| d.to_string()).unwrap_or_else(|| "—".to_string()); + let rate_col = done_rate + .map(|r| format!("{:.0}%", r * 100.0)) + .unwrap_or_else(|| "—".to_string()); + let err_col = if *has_err == 1 { "⚠️" } else { "✅" }; + md.push_str(&format!( + "| {} | {} | {} | {} | {} | {} |\n", + date, commit_col, rate_col, in_prog, blocked, err_col + )); + } + md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n"); + Ok(md) +} + +pub fn list_all_projects() -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare( + "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pgm.group_id + FROM project_workspaces pw + JOIN project_profiles pp ON pp.id = pw.profile_id + LEFT JOIN project_group_map pgm ON pgm.project_id = pw.id + ORDER BY pp.name", + ) + .map_err(|e| e.to_string())?; + + let rows: Vec<(String, String, Option, Option, Option, Option)> = + stmt.query_map([], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + if rows.is_empty() { + return Ok("炼境暂无登记项目。".to_string()); + } + + let mut out = format!("## 炼境全量项目(共 {} 个)\n\n", rows.len()); + for (id, name, platform, win_path, wsl_path, group_id) in &rows { + let project_path = if platform.as_deref() == Some("wsl") { + wsl_path.as_ref().map(|p| { + let conn2 = db::pool().get().ok(); + let distro = conn2 + .and_then(|c| { + c.query_row( + "SELECT value FROM settings WHERE key = 'wsl_distro'", + [], + |r| r.get::<_, String>(0), + ) + .ok() + }) + .unwrap_or_else(|| "Ubuntu".to_string()); + let inner = p.trim_start_matches('/').replace('/', "\\"); + format!("\\\\wsl.localhost\\{}\\{}", distro, inner) + }) + } else { + win_path.clone() + }; + + let injected = project_path + .as_ref() + .map(|p| std::path::Path::new(p).join(".claude").join("liangjing.json").exists()) + .unwrap_or(false); + + let inject_icon = if injected { "✅ 已注入" } else { "⚠️ 未注入" }; + let group_str = group_id.as_deref().unwrap_or("无产品组"); + let path_str = project_path.as_deref().unwrap_or("未配置路径"); + out.push_str(&format!( + "- **{}** `{}`\n {} | 组: `{}` | {}\n\n", + name, id, inject_icon, group_str, path_str + )); + } + out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。"); + Ok(out) +} + +// ── 测试 ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tools_list_contains_expected_tools() { + let result = tools_list_result(); + let tools = result["tools"].as_array().expect("tools 应为数组"); + let names: Vec<&str> = tools + .iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + // 核心工具必须存在 + assert!(names.contains(&"apply_onboarding_pack"), "缺少 apply_onboarding_pack"); + assert!(names.contains(&"list_all_projects"), "缺少 list_all_projects"); + assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp"); + assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); + assert!(names.contains(&"poll_now"), "缺少 poll_now"); + // 每个工具都有 description 和 inputSchema + for tool in tools { + let name = tool["name"].as_str().unwrap_or("?"); + assert!(tool["description"].is_string(), "工具 {name} 缺少 description"); + assert!(tool["inputSchema"].is_object(), "工具 {name} 缺少 inputSchema"); + } + } + + #[test] + fn apply_onboarding_pack_schema_requires_project_id() { + let result = tools_list_result(); + let tools = result["tools"].as_array().unwrap(); + let tool = tools.iter().find(|t| t["name"] == "apply_onboarding_pack").unwrap(); + let required = tool["inputSchema"]["required"].as_array().unwrap(); + let fields: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(fields.contains(&"project_id")); + } +} diff --git a/src-tauri/src/mcp_webhook.rs b/src-tauri/src/mcp_webhook.rs new file mode 100644 index 0000000..de40985 --- /dev/null +++ b/src-tauri/src/mcp_webhook.rs @@ -0,0 +1,288 @@ +use axum::{ + body::Bytes, + extract::Path, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +use crate::{db, db::log_event}; + +/// push payload 中的一条 commit +#[derive(Debug, PartialEq)] +pub struct PushCommit { + pub sha: String, + pub message: String, + pub timestamp: Option, +} + +/// HMAC-SHA256 验签(X-Gitea-Signature 头) +pub fn verify_hmac_sha256(secret: &str, body: &[u8], sig_hex: &str) -> bool { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(body); + let expected: String = mac + .finalize() + .into_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + expected.eq_ignore_ascii_case(sig_hex.trim()) +} + +/// 解析 Gitea push payload 的 commits 数组 +pub fn parse_push_payload(body: &[u8]) -> Result, String> { + let json: serde_json::Value = + serde_json::from_slice(body).map_err(|e| format!("payload 非法 JSON: {e}"))?; + let commits = json["commits"] + .as_array() + .ok_or("payload 缺少 commits 数组")?; + Ok(commits + .iter() + .filter_map(|c| { + let sha = c["id"].as_str()?.to_string(); + let message = c["message"].as_str().unwrap_or("").to_string(); + let timestamp = c["timestamp"].as_str().map(|s| s.to_string()); + Some(PushCommit { sha, message, timestamp }) + }) + .collect()) +} + +/// 从请求头提取签名:优先 X-Gitea-Signature,兼容 X-Hub-Signature-256 +fn extract_signature(headers: &HeaderMap) -> Option { + if let Some(v) = headers.get("x-gitea-signature").and_then(|v| v.to_str().ok()) { + return Some(v.to_string()); + } + headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()) + .map(|v| v.trim_start_matches("sha256=").to_string()) +} + +pub async fn handle_gitea_webhook( + Path(project_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + let secret: Option = db::pool().get().ok().and_then(|c| { + c.query_row( + "SELECT webhook_secret FROM gitea_repos WHERE project_id = ?1", + [&project_id], + |r| r.get(0), + ) + .ok() + }); + let secret = match secret { + Some(s) => s, + None => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"})), + ) + .into_response() + } + }; + + let sig = extract_signature(&headers); + let verified = sig + .as_deref() + .map(|s| verify_hmac_sha256(&secret, &body, s)) + .unwrap_or(false); + if !verified { + log_event( + "gitea_webhook", + "warn", + &format!( + "项目 {project_id} webhook 验签失败(签名{})", + if sig.is_some() { "不匹配" } else { "缺失" } + ), + None, + ); + return (StatusCode::UNAUTHORIZED, Json(json!({"error": "签名验证失败"}))).into_response(); + } + + let event = headers + .get("x-gitea-event") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + match event { + "push" => handle_push(&project_id, &body).await, + "pull_request" => handle_pull_request(&project_id, &body).await, + other => Json(json!({"ignored": other})).into_response(), + } +} + +async fn handle_push(project_id: &str, body: &Bytes) -> Response { + let commits = match parse_push_payload(body) { + Ok(c) => c, + Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(), + }; + let conn = match db::pool().get() { + Ok(c) => c, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + .into_response() + } + }; + let mut received = 0; + for c in &commits { + if crate::commands::commit_metrics::record_commit( + &conn, + project_id, + &c.sha, + &c.message, + c.timestamp.as_deref(), + ) + .is_ok() + { + received += 1; + } + } + log_event( + "gitea_webhook", + "info", + &format!( + "项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit", + commits.len() + ), + None, + ); + Json(json!({"received": received})).into_response() +} + +async fn handle_pull_request(project_id: &str, body: &Bytes) -> Response { + let payload: serde_json::Value = match serde_json::from_slice(body) { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": e.to_string()})), + ) + .into_response() + } + }; + + let raw_action = payload["action"].as_str().unwrap_or(""); + let pr = &payload["pull_request"]; + let pr_number = pr["number"].as_i64().unwrap_or(0); + + let action = match raw_action { + "opened" | "reopened" => "opened", + "closed" => { + if pr["merged"].as_bool().unwrap_or(false) { + "merged" + } else { + "closed" + } + } + _ => return Json(json!({"ignored": raw_action})).into_response(), + }; + + let title = pr["title"].as_str().unwrap_or("").to_string(); + let head_branch = pr["head"]["label"].as_str().unwrap_or("").to_string(); + let base_branch = pr["base"]["label"].as_str().unwrap_or("").to_string(); + let author = pr["user"]["login"].as_str().unwrap_or("").to_string(); + let id = uuid::Uuid::new_v4().to_string(); + + let conn = match db::pool().get() { + Ok(c) => c, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + .into_response() + } + }; + let result = conn.execute( + "INSERT INTO pr_events (id, project_id, pr_number, title, action, head_branch, base_branch, author) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![id, project_id, pr_number, title, action, head_branch, base_branch, author], + ); + + match result { + Ok(_) => { + log_event( + "gitea_webhook", + "info", + &format!("项目 {project_id} PR #{pr_number} {action}"), + None, + ); + Json(json!({"recorded": action, "pr_number": pr_number})).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +// ── 测试 ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn sign(secret: &str, body: &[u8]) -> String { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + mac.finalize() + .into_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + + #[test] + fn hmac_verify_accepts_valid_signature() { + let body = br#"{"commits":[]}"#; + let sig = sign("my-secret", body); + assert!(verify_hmac_sha256("my-secret", body, &sig)); + assert!(verify_hmac_sha256("my-secret", body, &sig.to_uppercase())); + } + + #[test] + fn hmac_verify_rejects_wrong_secret_or_tampered_body() { + let body = br#"{"commits":[]}"#; + let sig = sign("my-secret", body); + assert!(!verify_hmac_sha256("other-secret", body, &sig)); + assert!(!verify_hmac_sha256("my-secret", br#"{"commits":[1]}"#, &sig)); + assert!(!verify_hmac_sha256("my-secret", body, "deadbeef")); + } + + #[test] + fn parse_push_payload_extracts_commits() { + let body = br#"{ + "ref": "refs/heads/master", + "commits": [ + {"id": "abc123", "message": "feat(core): add thing", "timestamp": "2026-07-02T10:00:00+08:00"}, + {"id": "def456", "message": "fix(ui): repair", "timestamp": null} + ] + }"#; + let commits = parse_push_payload(body).unwrap(); + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].sha, "abc123"); + assert_eq!(commits[0].message, "feat(core): add thing"); + assert_eq!(commits[0].timestamp.as_deref(), Some("2026-07-02T10:00:00+08:00")); + assert_eq!(commits[1].timestamp, None); + } + + #[test] + fn parse_push_payload_rejects_invalid() { + assert!(parse_push_payload(b"not json").is_err()); + assert!(parse_push_payload(br#"{"no_commits": true}"#).is_err()); + assert_eq!(parse_push_payload(br#"{"commits":[]}"#).unwrap().len(), 0); + } +}