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:
parent
6fdac2ec2f
commit
dcbf1f9355
@ -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
|
||||
|
||||
|
||||
@ -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 有效性
|
||||
|
||||
337
src-tauri/src/commands/gitea.rs
Normal file
337
src-tauri/src/commands/gitea.rs
Normal 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 唯一索引未生效");
|
||||
}
|
||||
}
|
||||
@ -35,3 +35,4 @@ pub mod cloud_products;
|
||||
pub mod cloud_db_databases;
|
||||
pub mod docker_apps;
|
||||
pub mod agent_infra;
|
||||
pub mod gitea;
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1721,6 +1721,67 @@ export interface CommitHealthWithName extends ProjectCommitHealth {
|
||||
export const getAllCommitHealth = () =>
|
||||
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) ───────────────────────────────────────────
|
||||
|
||||
export interface BeastGlobalStatus {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user