//! Gitea 集成——控制面:实例管理 + 仓库操作 + webhook 注册。 //! //! 观测面(webhook 接收端)在 mcp_server.rs 实现。 use rusqlite::params; use serde::{Deserialize, Serialize}; // ── 数据结构 ────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GiteaInstance { pub id: String, pub name: String, pub url: String, pub access_token: String, pub is_default: bool, pub created_at: Option, } #[derive(Debug, Deserialize)] pub struct SaveGiteaInstanceInput { pub name: String, pub url: String, pub access_token: String, pub is_default: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GiteaRepo { pub id: String, pub instance_id: String, pub project_id: String, pub gitea_owner: String, pub gitea_repo_name: String, pub clone_url: Option, pub webhook_secret: Option, pub webhook_id: Option, pub linked_at: Option, } #[derive(Debug, Serialize)] pub struct GiteaUser { pub login: String, pub full_name: String, pub email: String, } // ── 辅助函数 ────────────────────────────────────────────────────────────────── fn row_to_instance(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(GiteaInstance { id: row.get(0)?, name: row.get(1)?, url: row.get(2)?, access_token: row.get(3)?, is_default: row.get::<_, i64>(4)? != 0, created_at: row.get(5)?, }) } fn row_to_repo(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(GiteaRepo { id: row.get(0)?, instance_id: row.get(1)?, project_id: row.get(2)?, gitea_owner: row.get(3)?, gitea_repo_name: row.get(4)?, clone_url: row.get(5)?, webhook_secret: row.get(6)?, webhook_id: row.get(7)?, linked_at: row.get(8)?, }) } /// 规范化 Gitea URL:去掉末尾 / fn normalize_url(url: &str) -> String { url.trim_end_matches('/').to_string() } // ── 实例管理命令 ────────────────────────────────────────────────────────────── /// 保存 Gitea 实例(新增或覆盖同名实例) #[tauri::command] pub fn gitea_save_instance(input: SaveGiteaInstanceInput) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let id = uuid::Uuid::new_v4().to_string(); let url = normalize_url(&input.url); let is_default = input.is_default.unwrap_or(false) as i64; // 若设为默认,先取消其他默认 if is_default != 0 { let _ = conn.execute("UPDATE gitea_instances SET is_default = 0", []); } conn.execute( "INSERT INTO gitea_instances (id, name, url, access_token, is_default) VALUES (?1, ?2, ?3, ?4, ?5)", params![id, input.name, url, input.access_token, is_default], ) .map_err(|e| e.to_string())?; conn.query_row( "SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances WHERE id = ?1", params![id], row_to_instance, ).map_err(|e| e.to_string()) } /// 列出所有 Gitea 实例 #[tauri::command] pub fn gitea_list_instances() -> Result, String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances ORDER BY is_default DESC, created_at ASC" ).map_err(|e| e.to_string())?; let rows = stmt .query_map([], row_to_instance) .map_err(|e| e.to_string())? .collect::>>() .map_err(|e| e.to_string())?; Ok(rows) } /// 删除 Gitea 实例(级联删除 gitea_repos) #[tauri::command] pub fn gitea_delete_instance(id: String) -> Result<(), String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; conn.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } /// 更新 Gitea 实例(仅更新非空字段) #[tauri::command] pub fn gitea_update_instance( id: String, name: Option, url: Option, access_token: Option, is_default: Option, ) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; if let Some(ref _n) = name { conn.execute( "UPDATE gitea_instances SET name = ?1 WHERE id = ?2", params![_n, id], ) .map_err(|e| e.to_string())?; } if let Some(ref u) = url { let u = normalize_url(u); conn.execute( "UPDATE gitea_instances SET url = ?1 WHERE id = ?2", params![u, id], ) .map_err(|e| e.to_string())?; } if let Some(ref t) = access_token { conn.execute( "UPDATE gitea_instances SET access_token = ?1 WHERE id = ?2", params![t, id], ) .map_err(|e| e.to_string())?; } if let Some(def) = is_default { if def { let _ = conn.execute("UPDATE gitea_instances SET is_default = 0", []); } conn.execute( "UPDATE gitea_instances SET is_default = ?1 WHERE id = ?2", params![def as i64, id], ) .map_err(|e| e.to_string())?; } conn.query_row( "SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances WHERE id = ?1", params![id], row_to_instance, ).map_err(|e| e.to_string()) } /// 测试 Gitea 连接:调 GET /api/v1/user,返回用户信息验证 token 有效性 #[tauri::command] pub fn gitea_test_connection(instance_id: String) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let (url, token): (String, String) = conn .query_row( "SELECT url, access_token FROM gitea_instances WHERE id = ?1", params![instance_id], |r| Ok((r.get(0)?, r.get(1)?)), ) .map_err(|_| "实例不存在".to_string())?; gitea_api_get_user(&url, &token) } // ── Gitea API 辅助 ──────────────────────────────────────────────────────────── /// 读取实例的 (url, token) fn instance_auth( conn: &rusqlite::Connection, instance_id: &str, ) -> Result<(String, String), String> { conn.query_row( "SELECT url, access_token FROM gitea_instances WHERE id = ?1", params![instance_id], |r| Ok((r.get(0)?, r.get(1)?)), ) .map_err(|_| "Gitea 实例不存在".to_string()) } /// 把 ureq 错误转成可读信息(尽量提取 Gitea 返回的 message 字段) fn api_err(prefix: &str, e: ureq::Error) -> String { match e { ureq::Error::Status(code, resp) => { let msg = resp .into_json::() .ok() .and_then(|v| v["message"].as_str().map(|s| s.to_string())) .unwrap_or_default(); if msg.is_empty() { format!("{prefix}: HTTP {code}") } else { format!("{prefix}: {msg}(HTTP {code})") } } other => format!("{prefix}: {other}"), } } /// `get_delivery_status` 使用的 Gitea 投影。它只反映远端信号,不给出发布决策。 #[derive(Debug, Clone)] pub struct GiteaDeliverySignals { pub pull_request: crate::commands::delivery_status::DeliverySignal, pub ci: crate::commands::delivery_status::DeliverySignal, pub review: crate::commands::delivery_status::DeliverySignal, } type DeliverySignal = crate::commands::delivery_status::DeliverySignal; fn unavailable_delivery_signals(detail: impl Into) -> GiteaDeliverySignals { let detail = detail.into(); GiteaDeliverySignals { pull_request: DeliverySignal::new( "unavailable", "gitea", detail.clone(), serde_json::json!({}), ), ci: DeliverySignal::new( "unavailable", "gitea", detail.clone(), serde_json::json!({}), ), review: DeliverySignal::new("unavailable", "gitea", detail, serde_json::json!({})), } } fn gitea_get_json( base_url: &str, token: &str, path: &str, label: &str, ) -> Result { ureq::get(&format!("{base_url}{path}")) .set("Authorization", &format!("token {token}")) .call() .map_err(|e| api_err(label, e))? .into_json() .map_err(|e| format!("{label}: 解析响应失败: {e}")) } /// 从 Gitea 拉取当前分支的 PR、当前 SHA 的 CI 以及该 PR 的 review 摘要。 /// /// 远端不可用必须作为数据返回,以便上层继续展示本地 Git 与部署未知状态。 pub fn get_delivery_signals( conn: &rusqlite::Connection, project_id: &str, branch: Option<&str>, commit_sha: Option<&str>, ) -> GiteaDeliverySignals { let binding: rusqlite::Result<(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)?)), ); let (instance_id, owner, repo) = match binding { Ok(binding) => binding, Err(rusqlite::Error::QueryReturnedNoRows) => { let detail = "项目未绑定 Gitea 仓库"; return GiteaDeliverySignals { pull_request: DeliverySignal::new( "not_configured", "gitea", detail, serde_json::json!({}), ), ci: DeliverySignal::new("not_configured", "gitea", detail, serde_json::json!({})), review: DeliverySignal::new( "not_configured", "gitea", detail, serde_json::json!({}), ), }; } Err(error) => return unavailable_delivery_signals(format!("读取 Gitea 绑定失败:{error}")), }; let (base_url, token) = match instance_auth(conn, &instance_id) { Ok(auth) => auth, Err(error) => return unavailable_delivery_signals(error), }; let pull_path = format!("/api/v1/repos/{owner}/{repo}/pulls?state=open&limit=50&type=pullrequest"); let pull_requests = match gitea_get_json(&base_url, &token, &pull_path, "读取 Gitea PR 失败") { Ok(value) => value, Err(error) => return unavailable_delivery_signals(error), }; if !pull_requests.is_array() { return unavailable_delivery_signals("Gitea PR 响应不是数组"); } let matched_pr = branch.and_then(|name| select_open_pull_request(&pull_requests, name)); let pull_request = match (branch, matched_pr) { (None, _) => DeliverySignal::new( "unknown", "gitea", "本地 Git 未提供当前分支,无法匹配开放 PR", serde_json::json!({}), ), (Some(_), Some(pr)) => DeliverySignal::new( "open", "gitea", "当前分支存在开放 PR", pull_request_summary(pr), ), (Some(_), None) => DeliverySignal::new( "none", "gitea", "当前分支没有开放 PR", serde_json::json!({}), ), }; let ci = match commit_sha { Some(sha) => { let path = format!("/api/v1/repos/{owner}/{repo}/statuses/{sha}?sort=recentupdate&limit=50"); match gitea_get_json(&base_url, &token, &path, "读取 Gitea CI 状态失败") { Ok(statuses) => summarize_ci_statuses(&statuses, Some(sha)), Err(error) => { DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})) } } } None => DeliverySignal::new( "unknown", "gitea", "本地 Git 未提供最近 commit SHA,无法查询 CI", serde_json::json!({}), ), }; let review = match matched_pr.and_then(|pr| pr["number"].as_i64()) { Some(number) => { let path = format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"); match gitea_get_json(&base_url, &token, &path, "读取 Gitea review 状态失败") { Ok(reviews) => summarize_reviews(&reviews), Err(error) => { DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})) } } } None if branch.is_some() => DeliverySignal::new( "not_applicable", "gitea", "当前分支没有开放 PR,因此无 review 状态", serde_json::json!({}), ), None => DeliverySignal::new( "unknown", "gitea", "无法确定当前分支,未查询 review", serde_json::json!({}), ), }; GiteaDeliverySignals { pull_request, ci, review, } } pub fn select_open_pull_request<'a>( pull_requests: &'a serde_json::Value, branch: &str, ) -> Option<&'a serde_json::Value> { pull_requests.as_array()?.iter().find(|pr| { pr["head"]["ref"].as_str() == Some(branch) || pr["head"]["label"] .as_str() .map(|label| label.ends_with(&format!(":{branch}")) || label == branch) .unwrap_or(false) }) } fn pull_request_summary(pr: &serde_json::Value) -> serde_json::Value { serde_json::json!({ "number": pr["number"], "title": pr["title"], "url": pr["html_url"], "head": pr["head"]["ref"], "base": pr["base"]["ref"], "updated_at": pr["updated_at"], }) } fn status_timestamp(status: &serde_json::Value) -> Option> { status["updated_at"] .as_str() .or_else(|| status["created_at"].as_str()) .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) } fn normalized_status_state(status: &serde_json::Value) -> String { let raw_state = status["status"] .as_str() .or_else(|| status["state"].as_str()) .unwrap_or("unknown") .to_ascii_lowercase(); let description = status["description"] .as_str() .unwrap_or("") .trim() .to_ascii_lowercase(); // Gitea 1.22 Actions 会把条件未命中的 job 记录为 status=success, // 仅在 description 中标明 "Has been skipped"。部署信号必须还原成 skipped, // 否则会把“未部署”误报为“部署成功”。 if raw_state == "skipped" || matches!(description.as_str(), "has been skipped" | "skipped") { "skipped".to_string() } else { raw_state } } fn latest_statuses_by_context<'a>( items: &'a [serde_json::Value], ) -> std::collections::BTreeMap { let mut latest: std::collections::BTreeMap = std::collections::BTreeMap::new(); for item in items { let context = item["context"].as_str().unwrap_or("unknown").to_string(); let should_replace = latest .get(&context) .map( |existing| match (status_timestamp(item), status_timestamp(existing)) { (Some(candidate), Some(current)) => candidate > current, (Some(_), None) => true, _ => false, }, ) .unwrap_or(true); if should_replace { latest.insert(context, item); } } latest } fn status_context_summary(context: &str, item: &serde_json::Value) -> serde_json::Value { let state = normalized_status_state(item); serde_json::json!({ "context": context, "state": state, "description": item["description"], "target_url": item["target_url"], "created_at": item["created_at"], "updated_at": item["updated_at"], }) } /// 把 Gitea commit status 归并成稳定枚举;保留每个 context 的可追溯字段,供 Agent 自行判断门禁。 pub fn summarize_ci_statuses( statuses: &serde_json::Value, target_sha: Option<&str>, ) -> DeliverySignal { let Some(items) = statuses.as_array() else { return DeliverySignal::new( "unknown", "gitea", "Gitea CI 响应不是数组", serde_json::json!({}), ); }; if items.is_empty() { return DeliverySignal::new( "unknown", "gitea", "当前 commit 暂无 CI 记录", serde_json::json!({ "target_sha": target_sha, "contexts": [] }), ); } let latest = latest_statuses_by_context(items); let contexts: Vec = latest .iter() .map(|(context, item)| status_context_summary(context, item)) .collect(); let states: Vec = contexts .iter() .filter_map(|item| item["state"].as_str()) .map(|state| state.to_ascii_lowercase()) .collect(); let state = if states .iter() .any(|state| state == "failure" || state == "error") { "failed" } else if states.iter().all(|state| state == "skipped") { "not_applicable" } else if states .iter() .all(|state| state == "success" || state == "skipped") { "passed" } else { "pending" }; let detail = match state { "passed" => "所有最新 CI context 均成功", "failed" => "至少一个最新 CI context 失败", "not_applicable" => "所有最新 CI context 均未执行", _ => "存在尚未成功的 CI context", }; DeliverySignal::new( state, "gitea", detail, serde_json::json!({ "target_sha": target_sha, "contexts": contexts }), ) } /// 从同一批 commit statuses 中按项目声明的精确 context 提取部署过程信号。 /// /// 本函数不猜测 job 名,也不把普通 CI 成功当作部署成功;context 的所有权属于项目 profile。 /// P3-D 接入双证据聚合前先由 P3-A 固化并验证纯函数契约。 #[allow(dead_code)] pub fn summarize_status_context( statuses: &serde_json::Value, target_sha: &str, expected_context: &str, ) -> DeliverySignal { let Some(items) = statuses.as_array() else { return DeliverySignal::new( "unknown", "gitea", "Gitea status 响应不是数组", serde_json::json!({ "target_sha": target_sha, "expected_context": expected_context }), ); }; let latest = latest_statuses_by_context(items); let available_contexts: Vec<&str> = latest.keys().map(String::as_str).collect(); let Some(status) = latest.get(expected_context) else { return DeliverySignal::new( "unknown", "gitea", "目标 deployment context 尚无状态", serde_json::json!({ "target_sha": target_sha, "expected_context": expected_context, "available_contexts": available_contexts, }), ); }; let raw_state = normalized_status_state(status); let state = match raw_state.as_str() { "success" => "success", "failure" | "error" => "failure", "pending" => "pending", "warning" => "warning", "skipped" => "skipped", _ => "unknown", }; let detail = match state { "success" => "目标 deployment context 已成功", "failure" => "目标 deployment context 已失败", "pending" => "目标 deployment context 尚未结束", "warning" => "目标 deployment context 带警告结束", "skipped" => "目标 deployment context 未执行", _ => "目标 deployment context 返回未知状态", }; DeliverySignal::new( state, "gitea", detail, serde_json::json!({ "target_sha": target_sha, "status": status_context_summary(expected_context, status), "available_contexts": available_contexts, }), ) } /// 对每位 reviewer 仅保留提交时间最新的状态,避免旧的 approve 掩盖后续 request changes。 pub fn summarize_reviews(reviews: &serde_json::Value) -> DeliverySignal { let Some(items) = reviews.as_array() else { return DeliverySignal::new( "unknown", "gitea", "Gitea review 响应不是数组", serde_json::json!({}), ); }; let mut latest: std::collections::BTreeMap = std::collections::BTreeMap::new(); for (index, review) in items.iter().enumerate() { let reviewer = review["user"]["login"] .as_str() .map(str::to_string) .unwrap_or_else(|| format!("unknown-{index}")); let should_replace = latest .get(&reviewer) .map(|existing| review_timestamp(review) >= review_timestamp(existing)) .unwrap_or(true); if should_replace { latest.insert(reviewer, review); } } let reviewers: Vec = latest .iter() .map(|(reviewer, review)| { serde_json::json!({ "reviewer": reviewer, "state": review["state"], "submitted_at": review["submitted_at"], }) }) .collect(); let states: Vec = reviewers .iter() .filter_map(|review| review["state"].as_str()) .map(|state| state.to_ascii_uppercase()) .collect(); let state = if states.iter().any(|state| state == "REQUEST_CHANGES") { "changes_requested" } else if states.iter().any(|state| state == "APPROVED") { "approved" } else { "pending" }; let detail = match state { "changes_requested" => "存在 reviewer 请求修改", "approved" => "至少一位 reviewer 已批准,未发现后续请求修改", _ => "尚无有效 approve/reject review", }; DeliverySignal::new( state, "gitea", detail, serde_json::json!({ "reviewers": reviewers }), ) } fn review_timestamp(review: &serde_json::Value) -> &str { review["submitted_at"].as_str().unwrap_or("") } /// 写入或更新项目 ↔ 仓库绑定(换绑时重置 webhook 字段,旧 hook 已随旧仓库失效) fn upsert_repo_link( conn: &rusqlite::Connection, instance_id: &str, project_id: &str, owner: &str, repo_name: &str, clone_url: Option<&str>, ) -> Result { let id = uuid::Uuid::new_v4().to_string(); conn.execute( "INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, clone_url) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(project_id) DO UPDATE SET instance_id = ?2, gitea_owner = ?4, gitea_repo_name = ?5, clone_url = ?6, webhook_secret = NULL, webhook_id = NULL", params![id, instance_id, project_id, owner, repo_name, clone_url], ).map_err(|e| e.to_string())?; conn.query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, clone_url, webhook_secret, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, ) .map_err(|e| e.to_string()) } /// GET /api/v1/user — 验证 token 并返回用户信息 pub fn gitea_api_get_user(base_url: &str, token: &str) -> Result { let url = format!("{}/api/v1/user", base_url); let resp = ureq::get(&url) .set("Authorization", &format!("token {token}")) .set("Content-Type", "application/json") .call() .map_err(|e| format!("Gitea 连接失败: {e}"))?; let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; let login = json["login"] .as_str() .ok_or_else(|| "token 无效或权限不足".to_string())? .to_string(); Ok(GiteaUser { login, full_name: json["full_name"].as_str().unwrap_or("").to_string(), email: json["email"].as_str().unwrap_or("").to_string(), }) } /// GET /api/v1/repos/:owner/:repo — 获取仓库信息(G2 仓库绑定时使用) pub fn gitea_api_get_repo( base_url: &str, token: &str, owner: &str, repo: &str, ) -> Result { let url = format!("{}/api/v1/repos/{owner}/{repo}", base_url); let resp = ureq::get(&url) .set("Authorization", &format!("token {token}")) .call() .map_err(|e| api_err("获取仓库信息失败", e))?; resp.into_json().map_err(|e| format!("解析失败: {e}")) } // ── 仓库创建 / 迁移 / 绑定(G2)────────────────────────────────────────────── /// 在 Gitea 新建仓库并绑定到项目 #[tauri::command] pub fn gitea_create_repo( instance_id: String, project_id: String, repo_name: String, description: Option, private: Option, ) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let (base_url, token) = instance_auth(&conn, &instance_id)?; let resp = ureq::post(&format!("{base_url}/api/v1/user/repos")) .set("Authorization", &format!("token {token}")) .send_json(serde_json::json!({ "name": repo_name, "description": description.unwrap_or_default(), "private": private.unwrap_or(true), "auto_init": false, })) .map_err(|e| api_err("创建仓库失败", e))?; let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; let owner = json["owner"]["login"] .as_str() .ok_or("响应缺少 owner 字段")? .to_string(); let clone_url = json["clone_url"].as_str().map(|s| s.to_string()); upsert_repo_link( &conn, &instance_id, &project_id, &owner, &repo_name, clone_url.as_deref(), ) } /// 从 GitHub/GitLab 等迁移仓库到 Gitea 并绑定到项目 #[tauri::command] pub fn gitea_migrate_repo( instance_id: String, project_id: String, clone_addr: String, repo_name: String, auth_token: Option, private: Option, ) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let (base_url, token) = instance_auth(&conn, &instance_id)?; let mut body = serde_json::json!({ "clone_addr": clone_addr, "repo_name": repo_name, "private": private.unwrap_or(true), "mirror": false, "service": "git", }); if let Some(t) = auth_token.filter(|t| !t.trim().is_empty()) { body["auth_token"] = serde_json::Value::String(t); } let resp = ureq::post(&format!("{base_url}/api/v1/repos/migrate")) .set("Authorization", &format!("token {token}")) .send_json(body) .map_err(|e| api_err("迁移仓库失败", e))?; let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; let owner = json["owner"]["login"] .as_str() .ok_or("响应缺少 owner 字段")? .to_string(); let clone_url = json["clone_url"].as_str().map(|s| s.to_string()); upsert_repo_link( &conn, &instance_id, &project_id, &owner, &repo_name, clone_url.as_deref(), ) } /// 绑定 Gitea 上已存在的仓库到项目(先验证仓库存在) #[tauri::command] pub fn gitea_link_repo( instance_id: String, project_id: String, owner: String, repo_name: String, ) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let (base_url, token) = instance_auth(&conn, &instance_id)?; let json = gitea_api_get_repo(&base_url, &token, &owner, &repo_name)?; let clone_url = json["clone_url"].as_str().map(|s| s.to_string()); upsert_repo_link( &conn, &instance_id, &project_id, &owner, &repo_name, clone_url.as_deref(), ) } // ── Webhook 注册 / 解绑(G4)───────────────────────────────────────────────── /// 为项目绑定的仓库注册 push webhook。 /// callback_base 形如 http://192.168.1.5:27190(Gitea 服务器视角可达的宿主机地址)。 /// 已有 webhook 时先删旧再建新(幂等重设)。 #[tauri::command] pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let link = conn .query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, clone_url, webhook_secret, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, ) .map_err(|_| "项目尚未绑定 Gitea 仓库".to_string())?; let (base_url, token) = instance_auth(&conn, &link.instance_id)?; // 旧 hook 存在则先删(失败不阻断,可能已在 Gitea 侧被手动删除) if let Some(old_id) = link.webhook_id { let _ = ureq::delete(&format!( "{base_url}/api/v1/repos/{}/{}/hooks/{old_id}", link.gitea_owner, link.gitea_repo_name )) .set("Authorization", &format!("token {token}")) .call(); } let secret = format!( "{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple() ); let callback_url = format!( "{}/webhook/gitea/{}", callback_base.trim_end_matches('/'), project_id ); let resp = ureq::post(&format!( "{base_url}/api/v1/repos/{}/{}/hooks", link.gitea_owner, link.gitea_repo_name )) .set("Authorization", &format!("token {token}")) .send_json(serde_json::json!({ "type": "gitea", "active": true, "branch_filter": "*", "events": ["push", "pull_request"], "config": { "url": callback_url, "content_type": "json", "secret": secret, "http_method": "post", }, })) .map_err(|e| api_err("注册 webhook 失败", e))?; let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; let hook_id = json["id"].as_i64().ok_or("响应缺少 hook id")?; conn.execute( "UPDATE gitea_repos SET webhook_secret = ?1, webhook_id = ?2 WHERE project_id = ?3", params![secret, hook_id, project_id], ) .map_err(|e| e.to_string())?; conn.query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, clone_url, webhook_secret, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, ) .map_err(|e| e.to_string()) } /// 解绑项目与 Gitea 仓库(顺带删除 Gitea 侧 webhook,失败不阻断) #[tauri::command] pub fn gitea_unlink_repo(project_id: String) -> Result<(), String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let link = match conn.query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, clone_url, webhook_secret, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, ) { Ok(l) => l, Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(()), Err(e) => return Err(e.to_string()), }; if let Some(hook_id) = link.webhook_id { if let Ok((base_url, token)) = instance_auth(&conn, &link.instance_id) { let _ = ureq::delete(&format!( "{base_url}/api/v1/repos/{}/{}/hooks/{hook_id}", link.gitea_owner, link.gitea_repo_name )) .set("Authorization", &format!("token {token}")) .call(); } } conn.execute( "DELETE FROM gitea_repos WHERE project_id = ?1", params![project_id], ) .map_err(|e| e.to_string())?; Ok(()) } // ── 远端轮询采集(G6:webhook 不可达时的降级路径)──────────────────────────── /// 从 Gitea commits API 响应解析出 (sha, message, date) 列表(纯函数,可测) pub fn parse_gitea_commits(json: &serde_json::Value) -> Vec<(String, String, Option)> { json.as_array() .map(|arr| { arr.iter() .filter_map(|c| { let sha = c["sha"].as_str()?.to_string(); let message = c["commit"]["message"].as_str().unwrap_or("").to_string(); let date = c["commit"]["committer"]["date"] .as_str() .or_else(|| c["created"].as_str()) .map(|s| s.to_string()); Some((sha, message, date)) }) .collect() }) .unwrap_or_default() } /// 轮询所有绑定仓库的最新 commit,幂等写入 commit_metrics。 /// 返回 (轮询仓库数, 新写入 commit 数)。纯出站 HTTPS,适配公网 Gitea + 内网炼境拓扑。 pub fn poll_all_repos() -> Result<(usize, usize), String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let repos: Vec<(String, String, String, String, String)> = { let mut stmt = conn.prepare( "SELECT gr.project_id, gr.gitea_owner, gr.gitea_repo_name, gi.url, gi.access_token FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id", ).map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)) }) .map_err(|e| e.to_string())?; rows.collect::>>() .map_err(|e| e.to_string())? }; let mut ingested = 0usize; let mut polled = 0usize; for (project_id, owner, repo, base_url, token) in &repos { let url = format!( "{base_url}/api/v1/repos/{owner}/{repo}/commits?limit=50&stat=false&verification=false" ); let resp = match ureq::get(&url) .set("Authorization", &format!("token {token}")) .call() { Ok(r) => r, Err(e) => { crate::db::log_event( "gitea_poll", "warn", &format!("轮询 {owner}/{repo} 失败: {}", api_err("拉取 commits", e)), None, ); continue; } }; let json: serde_json::Value = match resp.into_json() { Ok(j) => j, Err(_) => continue, }; polled += 1; // U3:按 project_id 加载规则 ID 集合(每仓库一次),剔除 trailer 里的未知 ID let valid_rules = crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id); for (sha, message, date) in parse_gitea_commits(&json) { let before: i64 = conn .query_row( "SELECT COUNT(*) FROM commit_metrics WHERE sha = ?1", params![sha], |r| r.get(0), ) .unwrap_or(0); if before == 0 && crate::commands::commit_metrics::record_commit_validated( &conn, project_id, &sha, &message, date.as_deref(), valid_rules.as_ref(), ) .is_ok() { ingested += 1; } } } if ingested > 0 { crate::db::log_event( "gitea_poll", "info", &format!("轮询 {polled} 个仓库,新采集 {ingested} 条 commit"), None, ); } // PR 状态轮询:弥补公网 Gitea + 内网炼境时 webhook 不可达的场景 let mut pr_ingested = 0usize; for (project_id, owner, repo, base_url, token) in &repos { pr_ingested += poll_pr_events(&conn, project_id, owner, repo, base_url, token); } if pr_ingested > 0 { crate::db::log_event( "gitea_poll", "info", &format!("PR 轮询新采集 {pr_ingested} 条事件"), None, ); } // staleness 自知:至少成功拉到一个仓库才算一次有效同步 if polled > 0 { crate::db::touch_sync_state(&conn, "flywheel"); } Ok((polled, ingested)) } /// 轮询单个仓库的 PR 状态,将未记录的事件写入 pr_events,返回新增条数 fn poll_pr_events( conn: &rusqlite::Connection, project_id: &str, owner: &str, repo: &str, base_url: &str, token: &str, ) -> usize { let mut count = 0; for state in ["open", "closed"] { let url = format!( "{base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}&limit=50&type=pullrequest" ); let resp = match ureq::get(&url) .set("Authorization", &format!("token {token}")) .call() { Ok(r) => r, Err(_) => continue, }; let prs: serde_json::Value = match resp.into_json() { Ok(j) => j, Err(_) => continue, }; let prs = match prs.as_array() { Some(a) => a, None => continue, }; for pr in prs { let pr_number = match pr["number"].as_i64() { Some(n) => n, None => continue, }; let title = pr["title"].as_str().unwrap_or("").to_string(); let head_branch = pr["head"]["ref"].as_str().unwrap_or("").to_string(); let base_branch = pr["base"]["ref"].as_str().unwrap_or("").to_string(); let author = pr["user"]["login"].as_str().unwrap_or("").to_string(); let merged = pr["merged"].as_bool().unwrap_or(false); let pr_state = pr["state"].as_str().unwrap_or("open"); // 确定动作:已合并 > 关闭 > 开启 let action = if merged { "merged" } else if pr_state == "closed" { "closed" } else { "opened" }; let existing: i64 = conn.query_row( "SELECT COUNT(*) FROM pr_events WHERE project_id = ?1 AND pr_number = ?2 AND action = ?3", params![project_id, pr_number, action], |r| r.get(0), ).unwrap_or(0); if existing == 0 { let id = uuid::Uuid::new_v4().to_string(); if conn.execute( "INSERT INTO pr_events (id, project_id, pr_number, title, action, head_branch, base_branch, author) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![id, project_id, pr_number, title, action, head_branch, base_branch, author], ).is_ok() { count += 1; } } } } count } /// 手动触发一次轮询(UI「立即拉取」按钮) #[tauri::command] pub fn gitea_poll_now() -> Result { let (polled, ingested) = poll_all_repos()?; Ok(serde_json::json!({ "polled": polled, "ingested": ingested })) } // ── 仓库绑定查询命令 ────────────────────────────────────────────────────────── /// 查询所有已绑定的仓库(含实例名) #[tauri::command] pub fn gitea_list_linked_repos() -> Result, String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT gr.id, gr.instance_id, gi.name as instance_name, gi.url as instance_url, gr.project_id, gr.gitea_owner, gr.gitea_repo_name, gr.clone_url, gr.webhook_secret, gr.webhook_id, gr.linked_at FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id ORDER BY gr.linked_at DESC", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, String>(0)?, "instance_id": row.get::<_, String>(1)?, "instance_name": row.get::<_, String>(2)?, "instance_url": row.get::<_, String>(3)?, "project_id": row.get::<_, String>(4)?, "gitea_owner": row.get::<_, String>(5)?, "gitea_repo_name": row.get::<_, String>(6)?, "clone_url": row.get::<_, Option>(7)?, "has_webhook": row.get::<_, Option>(8)?.is_some(), "webhook_id": row.get::<_, Option>(9)?, "linked_at": row.get::<_, Option>(10)?, })) }) .map_err(|e| e.to_string())? .collect::>>() .map_err(|e| e.to_string())?; Ok(rows) } /// 通过炼境存储的凭证为项目创建 PR,返回 JSON 字符串(含 pr_url / pr_number / already_exists)。 /// 409 冲突(PR 已存在)时自动查找并返回现有 PR,already_exists=true。 pub fn gitea_api_create_pull_request( project_id: &str, head: &str, base: &str, title: &str, body: &str, ) -> Result { 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 create_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls"); let (json, already_exists) = match ureq::post(&create_url) .set("Authorization", &format!("token {token}")) .set("Content-Type", "application/json") .send_json(serde_json::json!({ "head": head, "base": base, "title": title, "body": body })) { Ok(resp) => ( resp.into_json::() .map_err(|e| format!("解析响应失败: {e}"))?, false, ), Err(ureq::Error::Status(409, _)) => { // PR 已存在,列出开放 PR 找到匹配的分支 let list_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls?state=open&limit=30"); let prs: serde_json::Value = ureq::get(&list_url) .set("Authorization", &format!("token {token}")) .call() .map_err(|e| api_err("获取已有 PR 列表失败", e))? .into_json() .map_err(|e| format!("解析 PR 列表失败: {e}"))?; let arr = prs.as_array().ok_or("PR 列表格式错误")?; let existing = arr .iter() .find(|pr| { pr["head"]["ref"].as_str() == Some(head) || pr["head"]["label"] .as_str() .map(|l| l.ends_with(head)) .unwrap_or(false) }) .cloned() .ok_or_else(|| format!("PR 已存在但找不到(head={head})"))?; (existing, true) } Err(e) => return Err(api_err("创建 PR 失败", e)), }; let pr_number = json["number"].as_u64().unwrap_or(0); let pr_url = json["html_url"].as_str().unwrap_or("").to_string(); let pr_title = json["title"].as_str().unwrap_or(title).to_string(); serde_json::to_string(&serde_json::json!({ "pr_number": pr_number, "pr_url": pr_url, "head": head, "base": base, "title": pr_title, "already_exists": already_exists, })) .map_err(|e| e.to_string()) } /// 查询单个项目的 Gitea 绑定 #[tauri::command] pub fn gitea_get_repo_link(project_id: String) -> Result, String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; match conn.query_row( "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, clone_url, webhook_secret, webhook_id, linked_at FROM gitea_repos WHERE project_id = ?1", params![project_id], row_to_repo, ) { Ok(r) => Ok(Some(r)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.to_string()), } } // ── 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 { 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 = 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; let mut has_failure = false; for (ctx, s) in &latest { // Gitea 1.22 Actions 使用 "status" 字段;传统 commit status API 用 "state" let state = s["status"] .as_str() .or_else(|| 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; has_failure = true; "❌" } _ => { all_pass = false; "⏳" } }; lines.push(format!("- {} `{}` — {} {}", icon, ctx, state, desc)); } let conclusion = if all_pass { "✅ 全部通过,可以合并" } else if has_failure { "❌ 存在失败检查,暂不建议合并" } else { "⏳ 检查进行中,请稍后再查" }; lines.push(format!("\n**结论:{}**", conclusion)); Ok(lines.join("\n")) } fn is_full_git_sha(value: &str) -> bool { value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) } /// 合并门禁的纯函数部分。网络层先抓取同一时刻的 PR / CI / review,再一次性校验。 /// 返回已确认的实际 head SHA,供执行结果与审计信息复用。 fn validate_merge_preconditions( confirmed: bool, expected_head_sha: &str, pull_request: &serde_json::Value, ci: &DeliverySignal, review: &DeliverySignal, ) -> Result { if !confirmed { return Err("合并需要当前用户明确确认(confirmed=true)".to_string()); } if !is_full_git_sha(expected_head_sha) { return Err("expected_head_sha 必须是完整 40 位 Git SHA".to_string()); } let state = pull_request["state"].as_str().unwrap_or("unknown"); if state != "open" || pull_request["merged"].as_bool() == Some(true) { return Err(format!("PR 当前不可合并:state={state}")); } if pull_request["mergeable"].as_bool() == Some(false) { return Err("PR 存在冲突或被 Gitea 判定为不可合并".to_string()); } let actual_head_sha = pull_request["head"]["sha"] .as_str() .ok_or_else(|| "Gitea PR 响应缺少 head.sha,拒绝合并".to_string())?; if actual_head_sha != expected_head_sha { return Err(format!( "PR head SHA 已变化:期望 {expected_head_sha},实际 {actual_head_sha};请重新检查后确认" )); } if ci.state != "passed" { return Err(format!("CI 门禁未通过:{}({})", ci.state, ci.detail)); } if ci.data["target_sha"].as_str() != Some(actual_head_sha) { return Err("CI 证据未绑定当前 PR head SHA,拒绝合并".to_string()); } match review.state.as_str() { "approved" | "pending" => {} "changes_requested" => return Err("review 门禁阻塞:存在 reviewer 请求修改".to_string()), other => { return Err(format!( "review 状态不可用或未知:{other};为避免误合并已拒绝" )) } } Ok(actual_head_sha.to_string()) } fn merge_request_body(pr_number: i64, head_sha: &str) -> serde_json::Value { serde_json::json!({ "Do": "merge", "merge_title_field": format!("Merge PR #{pr_number}"), "merge_message_field": "", "head_commit_id": head_sha, }) } /// 合并指定 PR。所有高影响边界在能力本体内强制校验,MCP/UI 不得绕过。 /// 调用 POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge。 pub fn gitea_api_merge_pull_request( conn: &rusqlite::Connection, project_id: &str, pr_number: i64, expected_head_sha: &str, confirmed: bool, ) -> Result { if !confirmed { return Err("合并需要当前用户明确确认(confirmed=true)".to_string()); } if !is_full_git_sha(expected_head_sha) { return Err("expected_head_sha 必须是完整 40 位 Git SHA".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 pull_path = format!("/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}"); let pull_request = gitea_get_json(&base_url, &token, &pull_path, "读取待合并 PR 失败")?; let actual_head_sha = pull_request["head"]["sha"] .as_str() .ok_or_else(|| "Gitea PR 响应缺少 head.sha,拒绝合并".to_string())?; let status_path = format!( "/api/v1/repos/{owner}/{repo_name}/statuses/{actual_head_sha}?sort=recentupdate&limit=50" ); let statuses = gitea_get_json( &base_url, &token, &status_path, "读取待合并 PR 的 CI 状态失败", )?; let ci = summarize_ci_statuses(&statuses, Some(actual_head_sha)); let reviews_path = format!("/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}/reviews"); let reviews = gitea_get_json( &base_url, &token, &reviews_path, "读取待合并 PR 的 review 状态失败", )?; let review = summarize_reviews(&reviews); let validated_sha = validate_merge_preconditions(confirmed, expected_head_sha, &pull_request, &ci, &review)?; 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(merge_request_body(pr_number, &validated_sha)) .map_err(|e| api_err("合并 PR 失败", e))?; serde_json::to_string_pretty(&serde_json::json!({ "merged": true, "project_id": project_id, "repository": format!("{owner}/{repo_name}"), "pr_number": pr_number, "head_sha": validated_sha, "ci_state": ci.state, "review_state": review.state, })) .map_err(|error| error.to_string()) } // ── 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::*; #[test] fn normalize_url_strips_trailing_slash() { assert_eq!( normalize_url("https://gitea.example.com/"), "https://gitea.example.com" ); assert_eq!( normalize_url("https://gitea.example.com"), "https://gitea.example.com" ); assert_eq!( normalize_url("https://gitea.example.com///"), "https://gitea.example.com" ); } #[test] fn gitea_tables_exist_after_migrate() { let conn = crate::db::conn_with_schema(); let count: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gitea_instances','gitea_repos')", [], |r| r.get(0), ).unwrap(); assert_eq!(count, 2, "gitea_instances 或 gitea_repos 表未建"); } #[test] fn save_and_list_instance() { let conn = crate::db::conn_with_schema(); conn.execute( "INSERT INTO gitea_instances (id, name, url, access_token, is_default) VALUES ('inst-1', '测试实例', 'https://gitea.test', 'token123', 0)", [], ) .unwrap(); let count: i64 = conn .query_row( "SELECT COUNT(*) FROM gitea_instances WHERE id = 'inst-1'", [], |r| r.get(0), ) .unwrap(); assert_eq!(count, 1); } #[test] fn upsert_repo_link_resets_webhook_on_rebind() { let conn = crate::db::conn_with_schema(); conn.execute( "INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1', 'a', 'u', 't')", [], ).unwrap(); // 首次绑定 let r1 = upsert_repo_link( &conn, "i1", "proj-1", "alice", "repo-a", Some("https://g/a.git"), ) .unwrap(); assert_eq!(r1.gitea_repo_name, "repo-a"); // 模拟已设置 webhook conn.execute( "UPDATE gitea_repos SET webhook_secret = 'sec', webhook_id = 42 WHERE project_id = 'proj-1'", [], ).unwrap(); // 换绑到另一个仓库 → webhook 字段应被重置 let r2 = upsert_repo_link(&conn, "i1", "proj-1", "alice", "repo-b", None).unwrap(); assert_eq!(r2.gitea_repo_name, "repo-b"); assert!(r2.webhook_secret.is_none(), "换绑后 webhook_secret 未重置"); assert!(r2.webhook_id.is_none(), "换绑后 webhook_id 未重置"); // 仍然只有一行 let count: i64 = conn .query_row( "SELECT COUNT(*) FROM gitea_repos WHERE project_id = 'proj-1'", [], |r| r.get(0), ) .unwrap(); assert_eq!(count, 1); } #[test] fn unique_index_on_project_id() { let conn = crate::db::conn_with_schema(); conn.execute( "INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1', 'a', 'u', 't')", [], ).unwrap(); conn.execute( "INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r1', 'i1', 'proj-uuid-1', 'owner', 'repo')", [], ) .unwrap(); // 同一 project_id 第二次应失败 let result = conn.execute( "INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r2', 'i1', 'proj-uuid-1', 'owner', 'repo2')", [], ); assert!(result.is_err(), "project_id 唯一索引未生效"); } // ── G6:轮询解析 ────────────────────────────────────────────────────── #[test] fn parse_gitea_commits_extracts_fields() { let json = serde_json::json!([ {"sha": "abc", "commit": {"message": "feat(x): a", "committer": {"date": "2026-07-02T10:00:00+08:00"}}}, {"sha": "def", "commit": {"message": "fix: b", "committer": {}}, "created": "2026-07-01T09:00:00+08:00"} ]); let commits = parse_gitea_commits(&json); assert_eq!(commits.len(), 2); assert_eq!(commits[0].0, "abc"); assert_eq!(commits[0].2.as_deref(), Some("2026-07-02T10:00:00+08:00")); assert_eq!( commits[1].2.as_deref(), Some("2026-07-01T09:00:00+08:00"), "committer.date 缺失时回退 created" ); } #[test] fn parse_gitea_commits_tolerates_garbage() { assert!(parse_gitea_commits(&serde_json::json!({"not": "array"})).is_empty()); assert!(parse_gitea_commits(&serde_json::json!([{"no_sha": true}])).is_empty()); } #[test] fn delivery_ci_summary_uses_latest_context_and_preserves_failure() { let statuses = serde_json::json!([ { "context": "test", "status": "failure", "description": "old failure", "target_url": "https://gitea.test/actions/runs/524/jobs/0", "created_at": "2026-09-22T01:00:00Z", "updated_at": "2026-09-22T01:01:00Z" }, { "context": "test", "status": "success", "description": "green", "target_url": "https://gitea.test/actions/runs/525/jobs/0", "created_at": "2026-09-22T02:00:00Z", "updated_at": "2026-09-22T02:01:00Z" }, {"context": "lint", "state": "failure", "description": "bad"} ]); let summary = summarize_ci_statuses(&statuses, Some("530888eb")); assert_eq!(summary.state, "failed"); assert_eq!(summary.data["target_sha"], "530888eb"); assert_eq!(summary.data["contexts"].as_array().unwrap().len(), 2); let test = summary.data["contexts"] .as_array() .unwrap() .iter() .find(|item| item["context"] == "test") .unwrap(); assert_eq!(test["state"], "success"); assert_eq!( test["target_url"], "https://gitea.test/actions/runs/525/jobs/0" ); assert_eq!(test["updated_at"], "2026-09-22T02:01:00Z"); } #[test] fn delivery_context_summary_uses_exact_context_and_preserves_evidence() { // 字段形状来自 Gitea 1.22 CommitStatus schema;context/job 名与 target URL // 来自 enterprise-system 的真实 run #525 页面,敏感 creator 信息不进入 fixture。 let statuses = serde_json::json!([ { "context": "CI — DeepSeek Agent / agent (push)", "status": "success", "description": "success", "target_url": "https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/0", "created_at": "2026-09-22T03:35:00Z", "updated_at": "2026-09-22T03:36:36Z" }, { "context": "CI — DeepSeek Agent / deploy-api (push)", "status": "success", "description": "success", "target_url": "https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/2", "created_at": "2026-09-22T03:35:00Z", "updated_at": "2026-09-22T03:38:55Z" } ]); let signal = summarize_status_context( &statuses, "530888eb90983f21be4efa8191aae5711e03e2dc", "CI — DeepSeek Agent / deploy-api (push)", ); assert_eq!(signal.state, "success"); assert_eq!( signal.data["target_sha"], "530888eb90983f21be4efa8191aae5711e03e2dc" ); assert_eq!( signal.data["status"]["context"], "CI — DeepSeek Agent / deploy-api (push)" ); assert_eq!( signal.data["status"]["target_url"], "https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/2" ); assert_eq!(signal.data["status"]["updated_at"], "2026-09-22T03:38:55Z"); } #[test] fn delivery_context_summary_normalizes_terminal_and_missing_states() { for (raw_state, expected) in [ ("pending", "pending"), ("failure", "failure"), ("error", "failure"), ("warning", "warning"), ("skipped", "skipped"), ] { let statuses = serde_json::json!([ {"context": "deploy-api", "state": raw_state, "description": raw_state}, {"context": "agent", "status": "success"} ]); let signal = summarize_status_context(&statuses, "abc", "deploy-api"); assert_eq!(signal.state, expected, "raw state={raw_state}"); } let statuses = serde_json::json!([ {"context": "agent", "status": "success"} ]); let missing = summarize_status_context(&statuses, "abc", "deploy-api"); assert_eq!(missing.state, "unknown"); assert_eq!( missing.data["available_contexts"], serde_json::json!(["agent"]) ); } #[test] fn gitea_1_22_skipped_job_is_not_deployment_success() { // 脱敏自 enterprise-system 真实 run #529:Gitea 1.22.6 把 skipped job // 编码为 status=success,仅通过 description 表达未执行,target_url 也是相对路径。 let statuses = serde_json::json!([ { "context": "CI — DeepSeek Agent / deploy-api (push)", "created_at": "2026-09-22T15:12:03+08:00", "description": "Has been skipped", "status": "success", "target_url": "/lanrtop/enterprise-system/actions/runs/529/jobs/2", "updated_at": "2026-09-22T15:12:03+08:00" } ]); let signal = summarize_status_context( &statuses, "a2e85f50b093cbd6af58a09eaa9a927849723724", "CI — DeepSeek Agent / deploy-api (push)", ); assert_eq!(signal.state, "skipped"); assert_eq!(signal.data["status"]["state"], "skipped"); assert_eq!( signal.data["status"]["target_url"], "/lanrtop/enterprise-system/actions/runs/529/jobs/2" ); let ci = summarize_ci_statuses(&statuses, Some("a2e85f50b093cbd6af58a09eaa9a927849723724")); assert_eq!(ci.state, "not_applicable"); assert_eq!(ci.data["contexts"][0]["state"], "skipped"); } #[test] fn delivery_ci_summary_compares_rfc3339_timestamps_across_offsets() { let statuses = serde_json::json!([ { "context": "test", "status": "failure", "description": "older despite later local calendar date", "updated_at": "2026-09-22T02:00:00+09:00" }, { "context": "test", "status": "success", "description": "newer in UTC", "updated_at": "2026-09-21T18:00:00Z" } ]); let summary = summarize_ci_statuses(&statuses, Some("abc")); assert_eq!(summary.state, "passed"); assert_eq!(summary.data["contexts"][0]["description"], "newer in UTC"); } #[test] fn delivery_ci_summary_does_not_infer_skipped_from_unrelated_description() { let statuses = serde_json::json!([{ "context": "test", "status": "success", "description": "success; no checks skipped", "updated_at": "2026-09-22T10:00:00Z" }]); let summary = summarize_ci_statuses(&statuses, Some("abc")); assert_eq!(summary.state, "passed"); assert_eq!(summary.data["contexts"][0]["state"], "success"); } #[test] fn delivery_review_summary_prefers_latest_reviewer_decision() { let reviews = serde_json::json!([ {"user": {"login": "alice"}, "state": "REQUEST_CHANGES", "submitted_at": "2026-09-21T10:00:00Z"}, {"user": {"login": "alice"}, "state": "APPROVED", "submitted_at": "2026-09-21T09:00:00Z"}, {"user": {"login": "bob"}, "state": "APPROVED", "submitted_at": "2026-09-21T10:01:00Z"} ]); let summary = summarize_reviews(&reviews); assert_eq!(summary.state, "changes_requested"); assert_eq!(summary.data["reviewers"].as_array().unwrap().len(), 2); } #[test] fn delivery_pr_selection_matches_current_branch_only() { let pulls = serde_json::json!([ {"number": 2, "head": {"ref": "feat/other"}}, {"number": 3, "head": {"ref": "feat/delivery"}} ]); assert_eq!( select_open_pull_request(&pulls, "feat/delivery").expect("应匹配当前分支")["number"] .as_i64(), Some(3) ); assert!(select_open_pull_request(&pulls, "main").is_none()); } fn merge_gate_fixture() -> (serde_json::Value, DeliverySignal, DeliverySignal) { let sha = "0123456789abcdef0123456789abcdef01234567"; let pull_request = serde_json::json!({ "state": "open", "merged": false, "mergeable": true, "head": { "sha": sha } }); let ci = DeliverySignal::new( "passed", "gitea", "所有最新 CI context 均成功", serde_json::json!({ "target_sha": sha, "contexts": [] }), ); let review = DeliverySignal::new( "pending", "gitea", "尚无有效 approve/reject review", serde_json::json!({ "reviewers": [] }), ); (pull_request, ci, review) } #[test] fn merge_gate_requires_explicit_confirmation_and_full_sha() { let (pull_request, ci, review) = merge_gate_fixture(); let sha = pull_request["head"]["sha"].as_str().unwrap(); assert!( validate_merge_preconditions(false, sha, &pull_request, &ci, &review) .unwrap_err() .contains("明确确认") ); assert!( validate_merge_preconditions(true, "01234567", &pull_request, &ci, &review) .unwrap_err() .contains("完整 40 位") ); } #[test] fn merge_gate_rejects_sha_drift_and_non_open_pr() { let (mut pull_request, ci, review) = merge_gate_fixture(); let different_sha = "fedcba9876543210fedcba9876543210fedcba98"; assert!( validate_merge_preconditions(true, different_sha, &pull_request, &ci, &review,) .unwrap_err() .contains("SHA 已变化") ); pull_request["state"] = serde_json::json!("closed"); let original_sha = "0123456789abcdef0123456789abcdef01234567"; assert!( validate_merge_preconditions(true, original_sha, &pull_request, &ci, &review,) .unwrap_err() .contains("不可合并") ); } #[test] fn merge_gate_rejects_unready_ci_and_blocking_review() { let (pull_request, mut ci, mut review) = merge_gate_fixture(); let sha = pull_request["head"]["sha"].as_str().unwrap(); for state in ["pending", "failed", "unavailable"] { ci.state = state.to_string(); assert!( validate_merge_preconditions(true, sha, &pull_request, &ci, &review) .unwrap_err() .contains("CI 门禁未通过") ); } ci.state = "passed".to_string(); review.state = "changes_requested".to_string(); assert!( validate_merge_preconditions(true, sha, &pull_request, &ci, &review) .unwrap_err() .contains("请求修改") ); review.state = "unavailable".to_string(); assert!( validate_merge_preconditions(true, sha, &pull_request, &ci, &review) .unwrap_err() .contains("不可用或未知") ); } #[test] fn merge_gate_allows_current_sha_with_passed_ci_and_no_blocking_review() { let (pull_request, ci, mut review) = merge_gate_fixture(); let sha = pull_request["head"]["sha"].as_str().unwrap(); assert_eq!( validate_merge_preconditions(true, sha, &pull_request, &ci, &review).unwrap(), sha ); review.state = "approved".to_string(); assert_eq!( validate_merge_preconditions(true, sha, &pull_request, &ci, &review).unwrap(), sha ); } #[test] fn merge_request_body_binds_the_confirmed_head_sha() { let sha = "0123456789abcdef0123456789abcdef01234567"; let body = merge_request_body(13, sha); assert_eq!(body["Do"], "merge"); assert_eq!(body["head_commit_id"], sha); } }