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})"))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -280,6 +280,19 @@ pub fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
"required": ["project_id"]
|
"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)
|
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()))
|
.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" => {
|
"apply_onboarding_pack" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
resolve_project_root(&gid, pid).and_then(|root| {
|
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(&"predict_task_risk"), "缺少 predict_task_risk");
|
||||||
assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history");
|
assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history");
|
||||||
assert!(names.contains(&"unregister_project"), "缺少 unregister_project");
|
assert!(names.contains(&"unregister_project"), "缺少 unregister_project");
|
||||||
|
assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo");
|
||||||
// 每个工具都有 description 和 inputSchema
|
// 每个工具都有 description 和 inputSchema
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
let name = tool["name"].as_str().unwrap_or("?");
|
let name = tool["name"].as_str().unwrap_or("?");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user