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:
lanrtop 2026-07-04 18:27:01 +09:00
parent 892e5977ed
commit 38d5cec53e
2 changed files with 147 additions and 0 deletions

View File

@ -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 originadd 或 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::*;

View File

@ -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("?");