dev-manager-tauri/src-tauri/src/commands/gitea.rs
lanrtop dcbf1f9355 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 个单元测试全绿。
2026-06-30 17:44:39 +09:00

338 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 唯一索引未生效");
}
}