From dcbf1f935532e92a68526de1cf52f7e26a1e2759 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Tue, 30 Jun 2026 17:44:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(gitea):=20G1=20DB=20schema=20+=20=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=20CRUD=20+=20=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 gitea_instances / gitea_repos 两张表(db.rs migrate); 实现 save/list/delete/update/test_connection 命令(ureq 调 GET /api/v1/user 验证 token); gitea_list_linked_repos / gitea_get_repo_link 查询绑定状态; 前端 commands.ts 完整类型封装;4 个单元测试全绿。 --- .blueprint/manifest.yaml | 4 +- .blueprint/modules/gitea-integration.md | 4 +- src-tauri/src/commands/gitea.rs | 337 ++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/db.rs | 25 ++ src-tauri/src/lib.rs | 8 + src/lib/commands.ts | 61 +++++ 7 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/commands/gitea.rs diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 3c0ee7e..63b1216 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -375,8 +375,8 @@ modules: - id: gitea-integration name: Gitea 集成 area: backend - status: planned - progress: 0 + status: in_progress + progress: 20 position: [3500, 150] updated: 2026-06-30 diff --git a/.blueprint/modules/gitea-integration.md b/.blueprint/modules/gitea-integration.md index 2af2852..f33edad 100644 --- a/.blueprint/modules/gitea-integration.md +++ b/.blueprint/modules/gitea-integration.md @@ -106,8 +106,8 @@ Gitea push → POST /webhook/gitea/:project_id ## 任务卡(可执行,拆自 2026-06-30 /architect) -### 📋 G1. DB schema + Gitea 实例 CRUD -- status: todo +### ✅ G1. DB schema + Gitea 实例 CRUD +- status: done - complexity: M - files: src-tauri/src/db.rs, src-tauri/src/commands/gitea.rs, src-tauri/src/lib.rs, src/lib/commands.ts - acceptance: gitea_instances + gitea_repos 表建好(migrate);save/list/delete/test_connection 命令可调;test_connection 调 GET /api/v1/user 返回 Gitea 用户名验证 token 有效性 diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs new file mode 100644 index 0000000..511b0b7 --- /dev/null +++ b/src-tauri/src/commands/gitea.rs @@ -0,0 +1,337 @@ +//! 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 唯一索引未生效"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4a50b57..a2d5ad7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -35,3 +35,4 @@ pub mod cloud_products; pub mod cloud_db_databases; pub mod docker_apps; pub mod agent_infra; +pub mod gitea; diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 9fd8746..c898e45 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -844,6 +844,31 @@ fn migrate(conn: &rusqlite::Connection) { checked_at TEXT NOT NULL DEFAULT (datetime('now')) ); ").expect("create doc_freshness_cache table"); + + // 增量迁移:Gitea 实例 + 项目-仓库绑定 + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS gitea_instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + access_token TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS gitea_repos ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL REFERENCES gitea_instances(id) ON DELETE CASCADE, + project_id TEXT NOT NULL, + gitea_owner TEXT NOT NULL, + gitea_repo_name TEXT NOT NULL, + clone_url TEXT, + webhook_secret TEXT, + webhook_id INTEGER, + linked_at TEXT DEFAULT (datetime('now')) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_gitea_repos_project ON gitea_repos(project_id); + ").expect("create gitea tables"); } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c67beb..8ea7857 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -335,6 +335,14 @@ pub fn run() { commands::service_config::add_service_config, commands::service_config::edit_service_config, commands::service_config::delete_service_config, + // gitea integration + commands::gitea::gitea_save_instance, + commands::gitea::gitea_list_instances, + commands::gitea::gitea_delete_instance, + commands::gitea::gitea_update_instance, + commands::gitea::gitea_test_connection, + commands::gitea::gitea_list_linked_repos, + commands::gitea::gitea_get_repo_link, // beast status (read-only) get_beast_global_status, // source library diff --git a/src/lib/commands.ts b/src/lib/commands.ts index ce9754b..c7f00ba 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1721,6 +1721,67 @@ export interface CommitHealthWithName extends ProjectCommitHealth { export const getAllCommitHealth = () => invoke("get_all_commit_health"); +// ── Gitea Integration ──────────────────────────────────────────────────────── + +export interface GiteaInstance { + id: string; + name: string; + url: string; + access_token: string; + is_default: boolean; + created_at?: string; +} + +export interface SaveGiteaInstanceInput { + name: string; + url: string; + access_token: string; + is_default?: boolean; +} + +export interface GiteaUser { + login: string; + full_name: string; + email: string; +} + +export interface GiteaLinkedRepo { + id: string; + instance_id: string; + instance_name: string; + instance_url: string; + project_id: string; + gitea_owner: string; + gitea_repo_name: string; + clone_url?: string; + has_webhook: boolean; + webhook_id?: number; + linked_at?: string; +} + +export const giteaSaveInstance = (input: SaveGiteaInstanceInput) => + invoke("gitea_save_instance", { input }); + +export const giteaListInstances = () => + invoke("gitea_list_instances"); + +export const giteaDeleteInstance = (id: string) => + invoke("gitea_delete_instance", { id }); + +export const giteaUpdateInstance = ( + id: string, + fields: { name?: string; url?: string; access_token?: string; is_default?: boolean } +) => invoke("gitea_update_instance", { id, ...fields }); + +export const giteaTestConnection = (instanceId: string) => + invoke("gitea_test_connection", { instanceId }); + +export const giteaListLinkedRepos = () => + invoke("gitea_list_linked_repos"); + +export const giteaGetRepoLink = (projectId: string) => + invoke("gitea_get_repo_link", { projectId }); + // ── Beast Global Status (read-only) ─────────────────────────────────────────── export interface BeastGlobalStatus {