diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index d3b2c87..7036610 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -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 { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + + // 项目基本信息 + let (project_name, win_path): (String, Option) = 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 { + 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::*; diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 5f21718..9055b60 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -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("?");