新增 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 个单元测试全绿。
338 lines
12 KiB
Rust
338 lines
12 KiB
Rust
//! 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 唯一索引未生效");
|
||
}
|
||
}
|