From 14fe02ec7d955dffa15c3f81cd8a3f273992c9ac Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 3 Jul 2026 19:55:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E8=A1=A5=E5=85=A8=20agent=20?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E4=BD=93=E7=B3=BB=E2=80=94=E2=80=945=20?= =?UTF-8?q?=E4=B8=AA=E6=96=B0=E5=B7=A5=E5=85=B7=E5=85=B3=E9=97=AD=E4=B8=89?= =?UTF-8?q?=E9=A1=B9=E7=BB=93=E6=9E=84=E6=80=A7=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 PR 生命周期闭环: - get_ci_status(project_id, commit_sha):查 Gitea CI pass/fail/pending - merge_pull_request(project_id, pr_number):CI 绿灯后合并 PR 至此 /ship 流程全自主:推送→开 PR→查 CI→合并→poll_now P1 飞轮数据对子项目透明: - get_pr_events(project_id, limit?):读 pr_events 表,PR 生命周期历史 - get_commit_metrics(project_id, days?):读 commit_metrics,提交类型/返工分布 P2 分发层 agent 可自修复: - distribute_cicd(project_id, workflow_type):写入 CI 模板,无需预存配置 --- .blueprint/manifest.yaml | 6 +- src-tauri/src/commands/cicd.rs | 62 +++++++++++ src-tauri/src/commands/gitea.rs | 88 ++++++++++++++++ src-tauri/src/mcp_server.rs | 178 ++++++++++++++++++++++++++++++++ 4 files changed, 331 insertions(+), 3 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 17ebb14..52d42a3 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -377,10 +377,10 @@ modules: name: Gitea 集成 area: backend status: in_progress - progress: 85 - note: "G1-G6+R0 完成(2026-07-02):全链路含发版实测通过(v0.1.16 已上 OSS);余:PR/CI 事件联动、DeepSeek review、存量迁移(concept)" + progress: 97 + note: "MCP agent 按钮完善(2026-07-03):新增 get_ci_status/merge_pull_request/get_pr_events/get_commit_metrics/distribute_cicd 五个工具,/ship 闭环完整;余:存量迁移(concept)" position: [3500, 150] - updated: 2026-07-02 + updated: 2026-07-03 - id: agent-infrastructure name: Agent 友好基础设施生成 diff --git a/src-tauri/src/commands/cicd.rs b/src-tauri/src/commands/cicd.rs index 8299038..69372e3 100644 --- a/src-tauri/src/commands/cicd.rs +++ b/src-tauri/src/commands/cicd.rs @@ -99,6 +99,68 @@ pub fn generate_workflow(project_id: String, project_path: String) -> Result Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + + let (win_path, wsl_path, platform): (Option, Option, Option) = conn + .query_row( + "SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1", + params![project_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .map_err(|_| format!("项目 {} 不存在", project_id))?; + + let project_path = if platform.as_deref() == Some("wsl") { + let wsl = wsl_path.ok_or("WSL 项目未配置 wsl_path")?; + 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('/', "\\"); + format!("\\\\wsl.localhost\\{}\\{}", distro, inner) + } else { + win_path.ok_or("项目未配置本地路径")? + }; + + // 最小 config,pr_ci 用 trigger_branch;deepseek_review 不使用 config 字段 + let minimal_cfg = crate::models::CicdConfig { + id: String::new(), + project_id: project_id.to_string(), + config_type: workflow_type.to_string(), + trigger_branch: "main".to_string(), + build_command: None, + node_version: None, + server_host: None, + server_user: None, + deploy_path: None, + start_command: None, + oss_endpoint: None, + created_at: None, + }; + + let (content, filename, secrets) = match workflow_type { + "pr_ci" => build_pr_ci_workflow(&minimal_cfg), + "deepseek_review" => build_deepseek_review_workflow(&minimal_cfg), + other => return Err(format!("不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review)")), + }; + + let workflows_dir = Path::new(&project_path).join(".gitea").join("workflows"); + std::fs::create_dir_all(&workflows_dir).map_err(|e| format!("创建目录失败: {e}"))?; + let file_path = workflows_dir.join(&filename); + std::fs::write(&file_path, &content).map_err(|e| format!("写入文件失败: {e}"))?; + + let secrets_hint = if secrets.is_empty() { + String::new() + } else { + format!(",需要配置 secrets: {}", secrets.join(" / ")) + }; + + Ok(format!( + "CI 模板已写入:.gitea/workflows/{filename}{secrets_hint}\n提交并推送后 CI 将自动触发。" + )) +} + // ── workflow 模板生成 ────────────────────────────────────────────────────────── fn build_web_ssh_workflow(cfg: &CicdConfig) -> (String, String, Vec) { diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index f691417..efe6a90 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -719,6 +719,94 @@ pub fn gitea_get_repo_link(project_id: String) -> Result, Stri } } +// ── MCP 工具层:CI 状态查询 + PR 合并 ──────────────────────────────────────── + +/// 查询某 commit 的所有 CI 检查状态,返回 Markdown 格式摘要。 +/// 调用 GET /api/v1/repos/{owner}/{repo}/statuses/{sha} +pub fn gitea_api_get_ci_status(project_id: &str, commit_sha: &str) -> Result { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + + let (instance_id, owner, repo_name): (String, String, String) = conn.query_row( + "SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ).map_err(|_| format!("项目 {} 未绑定 Gitea 仓库", project_id))?; + + let (base_url, token) = instance_auth(&conn, &instance_id)?; + + let url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/statuses/{commit_sha}?limit=50"); + let resp = ureq::get(&url) + .set("Authorization", &format!("token {token}")) + .call() + .map_err(|e| api_err("查询 CI 状态失败", e))?; + + let statuses: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; + let arr = statuses.as_array().ok_or_else(|| "响应格式错误".to_string())?; + + let short_sha = &commit_sha[..8.min(commit_sha.len())]; + if arr.is_empty() { + return Ok(format!( + "commit `{}` 暂无 CI 记录(CI 可能尚未触发,或 commit 不存在)", + short_sha + )); + } + + // 同名 context 只取最新一条 + let mut latest: std::collections::HashMap = + std::collections::HashMap::new(); + for s in arr { + let ctx = s["context"].as_str().unwrap_or("unknown").to_string(); + latest.entry(ctx).or_insert(s); + } + + let mut lines = vec![format!("## CI 状态:commit `{}`\n", short_sha)]; + let mut all_pass = true; + for (ctx, s) in &latest { + let state = s["state"].as_str().unwrap_or("unknown"); + let desc = s["description"].as_str().unwrap_or(""); + let icon = match state { + "success" => "✅", + "failure" | "error" => { all_pass = false; "❌" }, + _ => { all_pass = false; "⏳" }, + }; + lines.push(format!("- {} `{}` — {} {}", icon, ctx, state, desc)); + } + let conclusion = if all_pass { + "✅ 全部通过,可以合并" + } else { + "❌ 存在失败或等待中的检查,暂不建议合并" + }; + lines.push(format!("\n**结论:{}**", conclusion)); + + Ok(lines.join("\n")) +} + +/// 合并指定 PR。调用 POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge +pub fn gitea_api_merge_pull_request(project_id: &str, pr_number: i64) -> Result { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + + let (instance_id, owner, repo_name): (String, String, String) = conn.query_row( + "SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ).map_err(|_| format!("项目 {} 未绑定 Gitea 仓库", project_id))?; + + let (base_url, token) = instance_auth(&conn, &instance_id)?; + + let url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}/merge"); + ureq::post(&url) + .set("Authorization", &format!("token {token}")) + .set("Content-Type", "application/json") + .send_json(serde_json::json!({ + "Do": "merge", + "merge_title_field": format!("Merge PR #{}", pr_number), + "merge_message_field": "", + })) + .map_err(|e| api_err("合并 PR 失败", e))?; + + Ok(format!("PR #{pr_number} 已成功合并({owner}/{repo_name})")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index e3ed563..2858a7e 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -579,6 +579,97 @@ fn tools_list_result() -> Value { }, "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"] + } } ] }) @@ -690,6 +781,93 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { 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) + } _ => Err(format!("未知工具: {}", tool_name)), }) .await;