feat(mcp): 补全 agent 按钮体系——5 个新工具关闭三项结构性缺口

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 模板,无需预存配置
This commit is contained in:
lanrtop 2026-07-03 19:55:12 +09:00
parent d4cb236ace
commit 14fe02ec7d
4 changed files with 331 additions and 3 deletions

View File

@ -377,10 +377,10 @@ modules:
name: Gitea 集成
area: backend
status: in_progress
progress: 85
note: "G1-G6+R0 完成2026-07-02全链路含发版实测通过v0.1.16 已上 OSSPR/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 友好基础设施生成

View File

@ -99,6 +99,68 @@ pub fn generate_workflow(project_id: String, project_path: String) -> Result<Wor
})
}
/// MCP 用:按 workflow_type 直接分发 CI 模板,自动解析项目路径,无需预存 cicd_config。
/// workflow_type: "pr_ci" | "deepseek_review"
pub fn distribute_workflow_for_project(project_id: &str, workflow_type: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let (win_path, wsl_path, platform): (Option<String>, Option<String>, Option<String>) = 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("项目未配置本地路径")?
};
// 最小 configpr_ci 用 trigger_branchdeepseek_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<String>) {

View File

@ -719,6 +719,94 @@ pub fn gitea_get_repo_link(project_id: String) -> Result<Option<GiteaRepo>, 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<String, String> {
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<String, &serde_json::Value> =
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<String, String> {
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::*;

View File

@ -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_ciPR 质量检查)和 deepseek_reviewAI 代码审查)两种类型。",
"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<String> = 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<String> = 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;