feat(mcp): 新增 setup_gitea_repo agent 按钮——一键建仓库配 remote
调用默认 Gitea 实例为项目创建远程仓库,同时配置本地 git remote origin。 幂等设计:仓库已存在(409)直接绑定,remote 已有则 set-url 更新。 背景:此前 agent 无法自助完成"项目在 Gitea 建仓库"流程,需要手动 拼凑 curl + git 命令,本工具将整个流程收归炼境 MCP 调用层。
This commit is contained in:
parent
892e5977ed
commit
38d5cec53e
@ -827,6 +827,133 @@ pub fn gitea_api_merge_pull_request(project_id: &str, pr_number: i64) -> Result<
|
||||
Ok(format!("PR #{pr_number} 已成功合并({owner}/{repo_name})"))
|
||||
}
|
||||
|
||||
// ── MCP 工具:一键建仓库 + 配置 remote ───────────────────────────────────────
|
||||
|
||||
/// 为项目在默认 Gitea 实例上创建远程仓库,并配置本地 git remote origin。
|
||||
/// 幂等:仓库已存在(409)则直接绑定;remote 已配置则 set-url 更新。
|
||||
pub fn gitea_api_setup_repo(
|
||||
project_id: &str,
|
||||
repo_name: Option<&str>,
|
||||
private: bool,
|
||||
) -> Result<String, String> {
|
||||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
// 项目基本信息
|
||||
let (project_name, win_path): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT pp.name, pw.win_path
|
||||
FROM project_workspaces pw
|
||||
JOIN project_profiles pp ON pp.id = pw.profile_id
|
||||
WHERE pw.id = ?1",
|
||||
params![project_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||||
|
||||
// 默认 Gitea 实例
|
||||
let (instance_id, base_url, token): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT id, url, access_token FROM gitea_instances WHERE is_default = 1 LIMIT 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.map_err(|_| "未配置默认 Gitea 实例,请先在炼境 UI 中添加并设为默认".to_string())?;
|
||||
|
||||
// 仓库名 slug 化(保留 ASCII 字母数字 + - _ .);空字符串等同未传
|
||||
let final_repo_name = repo_name.filter(|s| !s.trim().is_empty()).map(|s| s.to_string()).unwrap_or_else(|| {
|
||||
let slug: String = project_name
|
||||
.to_ascii_lowercase()
|
||||
.replace(' ', "-")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
|
||||
.collect();
|
||||
if slug.is_empty() {
|
||||
format!("project-{}", &project_id[..8.min(project_id.len())])
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
});
|
||||
|
||||
// 仓库拥有者(从 token 获取登录名)
|
||||
let owner = gitea_api_get_user(&base_url, &token)?.login;
|
||||
|
||||
// 创建仓库,409=已存在时直接绑定
|
||||
let (clone_url, already_existed) = match ureq::post(&format!("{base_url}/api/v1/user/repos"))
|
||||
.set("Authorization", &format!("token {token}"))
|
||||
.send_json(serde_json::json!({
|
||||
"name": final_repo_name,
|
||||
"description": format!("{} — 由炼境自动创建", project_name),
|
||||
"private": private,
|
||||
"auto_init": false,
|
||||
})) {
|
||||
Ok(resp) => {
|
||||
let json: serde_json::Value =
|
||||
resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
|
||||
let url = json["clone_url"].as_str().ok_or("响应缺少 clone_url")?.to_string();
|
||||
(url, false)
|
||||
}
|
||||
Err(ureq::Error::Status(409, _)) => {
|
||||
let json = gitea_api_get_repo(&base_url, &token, &owner, &final_repo_name)?;
|
||||
let url = json["clone_url"].as_str().ok_or("响应缺少 clone_url")?.to_string();
|
||||
(url, true)
|
||||
}
|
||||
Err(e) => return Err(api_err("创建仓库失败", e)),
|
||||
};
|
||||
|
||||
// 写入炼境 DB 绑定关系
|
||||
upsert_repo_link(&conn, &instance_id, project_id, &owner, &final_repo_name, Some(&clone_url))?;
|
||||
|
||||
// 配置本地 git remote origin
|
||||
let remote_status = configure_git_remote(win_path.as_deref(), &clone_url);
|
||||
|
||||
let existed_tag = if already_existed { "已存在,直接绑定" } else { "新建" };
|
||||
let remote_tag = match &remote_status {
|
||||
Ok(action) => format!("✅ {action}"),
|
||||
Err(e) => format!("⚠️ {e}"),
|
||||
};
|
||||
let mut out = format!(
|
||||
"Gitea 仓库已就绪:{base_url}/{owner}/{final_repo_name}\n\n\
|
||||
- 仓库:{owner}/{final_repo_name}({existed_tag})\n\
|
||||
- 炼境绑定:✅ project_id 已关联\n\
|
||||
- git remote origin:{remote_tag}\n"
|
||||
);
|
||||
if remote_status.is_ok() {
|
||||
out.push_str("\n建议下一步:git push -u origin main,然后调用 setup_webhook 注册 webhook。");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 配置本地仓库的 git remote origin(add 或 set-url,幂等)
|
||||
fn configure_git_remote(win_path: Option<&str>, clone_url: &str) -> Result<String, String> {
|
||||
let path = win_path.ok_or_else(|| "项目无本地路径".to_string())?;
|
||||
if !std::path::Path::new(path).join(".git").exists() {
|
||||
return Err("本地目录无 .git,跳过 remote 配置".to_string());
|
||||
}
|
||||
|
||||
let has_origin = std::process::Command::new("git")
|
||||
.args(["-C", path, "remote", "get-url", "origin"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
let out = if has_origin {
|
||||
std::process::Command::new("git")
|
||||
.args(["-C", path, "remote", "set-url", "origin", clone_url])
|
||||
.output()
|
||||
} else {
|
||||
std::process::Command::new("git")
|
||||
.args(["-C", path, "remote", "add", "origin", clone_url])
|
||||
.output()
|
||||
}
|
||||
.map_err(|e| format!("执行 git remote 失败: {e}"))?;
|
||||
|
||||
if out.status.success() {
|
||||
Ok(if has_origin { "origin set-url 更新成功".to_string() } else { "origin 已添加".to_string() })
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -280,6 +280,19 @@ pub fn tools_list_result() -> Value {
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "setup_gitea_repo",
|
||||
"description": "为项目在默认 Gitea 实例上创建远程仓库,并将本地 git remote origin 指向新仓库(幂等:仓库已存在则直接绑定,remote 已有则 set-url 更新)。创建后炼境自动记录绑定关系,后续可调用 setup_webhook 注册 webhook。需先在炼境 UI 配置一个默认 Gitea 实例。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
|
||||
"repo_name": { "type": "string", "description": "Gitea 仓库名;省略则用项目名自动 slug 化(非 ASCII 字符过滤掉)" },
|
||||
"private": { "type": "boolean", "description": "是否创建私有仓库,默认 false(公开)" }
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
@ -569,6 +582,12 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
||||
crate::commands::risk::predict_task_risk(&conn, pid, scope, &files)
|
||||
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
|
||||
}
|
||||
"setup_gitea_repo" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let repo_name = args.get("repo_name").and_then(|v| v.as_str());
|
||||
let private = args.get("private").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
crate::commands::gitea::gitea_api_setup_repo(pid, repo_name, private)
|
||||
}
|
||||
"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| {
|
||||
@ -1149,6 +1168,7 @@ mod tests {
|
||||
assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk");
|
||||
assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history");
|
||||
assert!(names.contains(&"unregister_project"), "缺少 unregister_project");
|
||||
assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo");
|
||||
// 每个工具都有 description 和 inputSchema
|
||||
for tool in tools {
|
||||
let name = tool["name"].as_str().unwrap_or("?");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user