//! 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 辅助 ──────────────────────────────────────────────────────────── /// 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 仓库绑定时使用) #[allow(dead_code)] 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| format!("获取仓库信息失败: {e}"))?; resp.into_json().map_err(|e| format!("解析失败: {e}")) } // ── 仓库绑定查询命令 ────────────────────────────────────────────────────────── /// 查询所有已绑定的仓库(含实例名) #[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) } /// 查询单个项目的 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()), } } #[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 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 唯一索引未生效"); } }