feat(gitea): G1 DB schema + 实例 CRUD + 连接测试

新增 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 个单元测试全绿。
This commit is contained in:
lanrtop 2026-06-30 17:44:39 +09:00
parent 6fdac2ec2f
commit dcbf1f9355
7 changed files with 436 additions and 4 deletions

View File

@ -375,8 +375,8 @@ modules:
- id: gitea-integration - id: gitea-integration
name: Gitea 集成 name: Gitea 集成
area: backend area: backend
status: planned status: in_progress
progress: 0 progress: 20
position: [3500, 150] position: [3500, 150]
updated: 2026-06-30 updated: 2026-06-30

View File

@ -106,8 +106,8 @@ Gitea push → POST /webhook/gitea/:project_id
## 任务卡(可执行,拆自 2026-06-30 /architect ## 任务卡(可执行,拆自 2026-06-30 /architect
### 📋 G1. DB schema + Gitea 实例 CRUD ### G1. DB schema + Gitea 实例 CRUD
- status: todo - status: done
- complexity: M - complexity: M
- files: src-tauri/src/db.rs, src-tauri/src/commands/gitea.rs, src-tauri/src/lib.rs, src/lib/commands.ts - 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 表建好migratesave/list/delete/test_connection 命令可调test_connection 调 GET /api/v1/user 返回 Gitea 用户名验证 token 有效性 - acceptance: gitea_instances + gitea_repos 表建好migratesave/list/delete/test_connection 命令可调test_connection 调 GET /api/v1/user 返回 Gitea 用户名验证 token 有效性

View File

@ -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<String>,
}
#[derive(Debug, Deserialize)]
pub struct SaveGiteaInstanceInput {
pub name: String,
pub url: String,
pub access_token: String,
pub is_default: Option<bool>,
}
#[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<String>,
pub webhook_secret: Option<String>,
pub webhook_id: Option<i64>,
pub linked_at: Option<String>,
}
#[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<GiteaInstance> {
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<GiteaRepo> {
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<GiteaInstance, String> {
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<Vec<GiteaInstance>, 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::<rusqlite::Result<Vec<_>>>()
.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<String>,
url: Option<String>,
access_token: Option<String>,
is_default: Option<bool>,
) -> Result<GiteaInstance, String> {
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<GiteaUser, String> {
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<GiteaUser, String> {
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<serde_json::Value, String> {
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<Vec<serde_json::Value>, 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<String>>(7)?,
"has_webhook": row.get::<_, Option<String>>(8)?.is_some(),
"webhook_id": row.get::<_, Option<i64>>(9)?,
"linked_at": row.get::<_, Option<String>>(10)?,
}))
}).map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
/// 查询单个项目的 Gitea 绑定
#[tauri::command]
pub fn gitea_get_repo_link(project_id: String) -> Result<Option<GiteaRepo>, 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 唯一索引未生效");
}
}

View File

@ -35,3 +35,4 @@ pub mod cloud_products;
pub mod cloud_db_databases; pub mod cloud_db_databases;
pub mod docker_apps; pub mod docker_apps;
pub mod agent_infra; pub mod agent_infra;
pub mod gitea;

View File

@ -844,6 +844,31 @@ fn migrate(conn: &rusqlite::Connection) {
checked_at TEXT NOT NULL DEFAULT (datetime('now')) checked_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
").expect("create doc_freshness_cache table"); ").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)] #[cfg(test)]

View File

@ -335,6 +335,14 @@ pub fn run() {
commands::service_config::add_service_config, commands::service_config::add_service_config,
commands::service_config::edit_service_config, commands::service_config::edit_service_config,
commands::service_config::delete_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) // beast status (read-only)
get_beast_global_status, get_beast_global_status,
// source library // source library

View File

@ -1721,6 +1721,67 @@ export interface CommitHealthWithName extends ProjectCommitHealth {
export const getAllCommitHealth = () => export const getAllCommitHealth = () =>
invoke<CommitHealthWithName[]>("get_all_commit_health"); invoke<CommitHealthWithName[]>("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<GiteaInstance>("gitea_save_instance", { input });
export const giteaListInstances = () =>
invoke<GiteaInstance[]>("gitea_list_instances");
export const giteaDeleteInstance = (id: string) =>
invoke<void>("gitea_delete_instance", { id });
export const giteaUpdateInstance = (
id: string,
fields: { name?: string; url?: string; access_token?: string; is_default?: boolean }
) => invoke<GiteaInstance>("gitea_update_instance", { id, ...fields });
export const giteaTestConnection = (instanceId: string) =>
invoke<GiteaUser>("gitea_test_connection", { instanceId });
export const giteaListLinkedRepos = () =>
invoke<GiteaLinkedRepo[]>("gitea_list_linked_repos");
export const giteaGetRepoLink = (projectId: string) =>
invoke<GiteaLinkedRepo | null>("gitea_get_repo_link", { projectId });
// ── Beast Global Status (read-only) ─────────────────────────────────────────── // ── Beast Global Status (read-only) ───────────────────────────────────────────
export interface BeastGlobalStatus { export interface BeastGlobalStatus {