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:
parent
d4cb236ace
commit
14fe02ec7d
@ -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 友好基础设施生成
|
||||
|
||||
@ -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("项目未配置本地路径")?
|
||||
};
|
||||
|
||||
// 最小 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<String>) {
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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<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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user