feat(gitea): G2-G5 全链路——建仓/迁移/绑定 + webhook 收发 + 治理面板

G2 控制面:gitea_create_repo / gitea_migrate_repo / gitea_link_repo,
   upsert 绑定(换绑自动重置 webhook 字段),API 错误提取 Gitea message 可读返回
G3 观测面:MCP Server 新增 POST /webhook/gitea/:project_id——HMAC-SHA256 验签
   (X-Gitea-Signature,兼容 X-Hub-Signature-256)→ 解析 commits → 批量
   record_commit(sha 幂等,与本地 watcher 双路径不重复计数);验签失败记 log_event
G4 webhook 注册:gitea_setup_webhook 生成随机 secret 注册 push hook(重设先删旧)、
   gitea_unlink_repo 解绑顺带清理 Gitea 侧 hook
G5 治理面板:GiteaPanel——实例管理(添加/测试/删除)、当前项目三种绑定方式、
   webhook 注册与可复制回调地址、全部绑定概览

依赖:新增 hmac + sha2;project_id 与 commit_metrics 同源(Project.id),
避免双路径冲突复发;R10 修正设计文档错误签名头

验证:cargo test 58 passed(新增 upsert 换绑重置 + hmac 正反向 + payload 解析 5 测试)、
typecheck 全绿
This commit is contained in:
lanrtop 2026-07-02 14:22:49 +09:00
parent 29a066e7e3
commit d14b348845
10 changed files with 929 additions and 14 deletions

View File

@ -376,7 +376,8 @@ modules:
name: Gitea 集成 name: Gitea 集成
area: backend area: backend
status: in_progress status: in_progress
progress: 20 progress: 55
note: "G1-G5 全部完成(控制面+观测面+UIGitea Actions 生成📋、PR/CI 事件、迁移"
position: [3500, 150] position: [3500, 150]
updated: 2026-07-02 updated: 2026-07-02

View File

@ -64,12 +64,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_gitea_repos_project ON gitea_repos(project
``` ```
Gitea push → POST /webhook/gitea/:project_id Gitea push → POST /webhook/gitea/:project_id
1. 从 gitea_repos 查 webhook_secret 1. 从 gitea_repos 查 webhook_secret
2. HMAC-SHA256 验签 X-Gitea-Signature-256 2. HMAC-SHA256 验签 X-Gitea-Signature(兼容 X-Hub-Signature-256
3. 解析 payload.commits[]sha / message / timestamp 3. 解析 payload.commits[]sha / message / timestamp
4. 批量 record_commit(project_id, sha, message, date) 4. 批量 record_commit(project_id, sha, message, date)
5. 返回 200 OK验签失败记 log_event 5. 返回 200 OK验签失败记 log_event
``` ```
> 修正2026-07-02R10原设计写的签名头 `X-Gitea-Signature-256` 有误Gitea 实际发送
> `X-Gitea-Signature`payload 的 HMAC-SHA256 hex实现以实际行为为准并兼容 GitHub 风格头。
> project_id 采用与 commit_metrics/ingest_full_git_history 相同的 Project.idgetProjects 体系),
> 避免重蹈 project_id 双路径冲突b13dc83 教训)。
**网络说明**Gitea 服务器需能访问宿主机 `{ip}:27190`MCP 端口。UI 显示可复制的 webhook 回调地址,用户自行确认网络可达。 **网络说明**Gitea 服务器需能访问宿主机 `{ip}:27190`MCP 端口。UI 显示可复制的 webhook 回调地址,用户自行确认网络可达。
--- ---
@ -81,12 +86,16 @@ Gitea push → POST /webhook/gitea/:project_id
- complexity: L - complexity: L
- acceptance: TBD —— 建仓/关联、推送、读 CI 状态、读 PR review 结果 - acceptance: TBD —— 建仓/关联、推送、读 CI 状态、读 PR review 结果
> ⚠️ 建仓/关联部分已被 G1/G2 实现取代;剩余范围收窄为「读 CI 状态、读 PR review 结果」(依赖 Gitea Actions 落地后再议)。
### 💭 观测面Gitea webhook 接入(持线人感知动态) ### 💭 观测面Gitea webhook 接入(持线人感知动态)
- status: concept - status: concept
- complexity: L - complexity: L
- files: src-tauri/src/mcp_server.rs内嵌 HTTP server 加 webhook 端点), src-tauri/src/commands/blueprint.rs - files: src-tauri/src/mcp_server.rs内嵌 HTTP server 加 webhook 端点), src-tauri/src/commands/blueprint.rs
- acceptance: TBD —— 复用炼境内嵌 HTTP server 新增 Gitea webhook 接收端点;实时接住各子项目 push/PR/CI/review 事件 → 解析 conventional commit对接 flywheel-intelligence 的 L0 解析层)→ 写飞轮快照 + 刷新连接动态webhook 不可达时降级为定时轮询 Gitea API - acceptance: TBD —— 复用炼境内嵌 HTTP server 新增 Gitea webhook 接收端点;实时接住各子项目 push/PR/CI/review 事件 → 解析 conventional commit对接 flywheel-intelligence 的 L0 解析层)→ 写飞轮快照 + 刷新连接动态webhook 不可达时降级为定时轮询 Gitea API
> ⚠️ push 事件部分已被 G3/G4 实现取代剩余范围收窄为「PR/CI/review 事件 + 轮询降级」见「PR 事件 ↔ 蓝图任务卡联动」concept 卡)。
### 📋 Gitea Actions workflow 生成 ### 📋 Gitea Actions workflow 生成
- status: todo - status: todo
- complexity: M - complexity: M
@ -121,30 +130,42 @@ Gitea push → POST /webhook/gitea/:project_id
- 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 有效性
### 🔵 G2. 仓库创建 / 迁移 / 绑定 ### G2. 仓库创建 / 迁移 / 绑定
- status: in_progress - status: done
- complexity: M - complexity: M
- depends: G1 - depends: G1
- files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts - files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts
- acceptance: gitea_create_repo 调 Gitea API 建仓后写 gitea_reposgitea_migrate_repo 能从 GitHub/GitLab 迁移仓库到 Giteagitea_link_repo 绑定已有仓库;命令失败返回可读错误 - acceptance: gitea_create_repo 调 Gitea API 建仓后写 gitea_reposgitea_migrate_repo 能从 GitHub/GitLab 迁移仓库到 Giteagitea_link_repo 绑定已有仓库;命令失败返回可读错误
### 📋 G3. Webhook 接收端MCP Server 扩展) ### G3. Webhook 接收端MCP Server 扩展)
- status: todo - status: done
- complexity: M - complexity: M
- depends: G1 - depends: G1
- files: src-tauri/src/mcp_server.rs - files: src-tauri/src/mcp_server.rs
- acceptance: POST /webhook/gitea/:project_id 路由上线HMAC-SHA256 验签通过后解析 commits 数组批量写 commit_metrics验签失败记 log_event单元测试覆盖 parse_push_payload + hmac_verify - acceptance: POST /webhook/gitea/:project_id 路由上线HMAC-SHA256 验签通过后解析 commits 数组批量写 commit_metrics验签失败记 log_event单元测试覆盖 parse_push_payload + hmac_verify
### 🔵 G4. Webhook 注册命令 ### G4. Webhook 注册命令
- status: in_progress - status: done
- complexity: M - complexity: M
- depends: G2, G3 - depends: G2, G3
- files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts - files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts
- acceptance: gitea_setup_webhook 生成随机 secret调 Gitea API POST /repos/:owner/:repo/hooks 注册events=push写 webhook_secret + webhook_id 到 gitea_reposgitea_unlink_repo 清理绑定 - acceptance: gitea_setup_webhook 生成随机 secret调 Gitea API POST /repos/:owner/:repo/hooks 注册events=push写 webhook_secret + webhook_id 到 gitea_reposgitea_unlink_repo 清理绑定
### 📋 G5. Gitea 治理面板 UI ### G5. Gitea 治理面板 UI
- status: todo - status: done
- complexity: M - complexity: M
- depends: G1, G2, G3, G4 - depends: G1, G2, G3, G4
- files: src/components/dashboard/GiteaPanel.tsx, src/components/dashboard/BlueprintModal.tsx - files: src/components/dashboard/GiteaPanel.tsx, src/components/dashboard/BlueprintModal.tsx
- acceptance: 治理面板新增"Gitea"区块;可添加/测试/删除实例;显示已绑定仓库列表(仓库名/webhook 状态);操作:新建仓库、迁移、绑定已有、设置 webhook显示可复制的 webhook 回调地址 - acceptance: 治理面板新增"Gitea"区块;可添加/测试/删除实例;显示已绑定仓库列表(仓库名/webhook 状态);操作:新建仓库、迁移、绑定已有、设置 webhook显示可复制的 webhook 回调地址
## 复盘笔记
【复盘】G2-G5 控制面 + 观测面全链路2026-07-02
任务类型: 后端命令 + MCP工具 + 前端组件
复杂度: M×4
实际轮数: 1轮预估 2 轮)
是否返工: 否
有效策略: 续接 🔵 锁前先读代码判断真实断点G1 已含查询命令G2 只缺三个写命令webhook 验签/解析拆成纯函数使单元测试不依赖 HTTP 层;绑定 UI 的 project_id 显式对齐 ingest_full_git_history 的 Project.id 体系
遗留风险: ① 全链路未在真实 Gitea 实例上端到端验证(建仓/webhook 注册/push 接收),首次使用时按 G5 面板流程走一遍即是验收;② gitea_migrate_repo 未处理同名仓库已存在的场景Gitea 返回 409错误信息可读但需手动改名③ webhook 回调地址依赖用户确认网络可达,无自动探测
auto_applied_rules: R01, R05, R06, R07, R10
rule_effectiveness: R01=有效, R05=有效(复用已有表无新 migration, R06=有效upsert 依赖注入可测), R07=有效5 个新测试), R10=有效(纠正了设计文档中错误的签名头 X-Gitea-Signature-256 → 实际 X-Gitea-Signature

12
src-tauri/Cargo.lock generated
View File

@ -887,6 +887,7 @@ dependencies = [
"chrono", "chrono",
"futures-core", "futures-core",
"git2", "git2",
"hmac",
"once_cell", "once_cell",
"open", "open",
"r2d2", "r2d2",
@ -895,6 +896,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@ -919,6 +921,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@ -1779,6 +1782,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.29.1" version = "0.29.1"

View File

@ -44,4 +44,6 @@ open = "5"
base64 = "0.22" base64 = "0.22"
urlencoding = "2" urlencoding = "2"
serde_yaml = "0.9" serde_yaml = "0.9"
hmac = "0.12"
sha2 = "0.10"

View File

@ -183,6 +183,60 @@ pub fn gitea_test_connection(instance_id: String) -> Result<GiteaUser, String> {
// ── Gitea API 辅助 ──────────────────────────────────────────────────────────── // ── Gitea API 辅助 ────────────────────────────────────────────────────────────
/// 读取实例的 (url, token)
fn instance_auth(conn: &rusqlite::Connection, instance_id: &str) -> Result<(String, 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(|_| "Gitea 实例不存在".to_string())
}
/// 把 ureq 错误转成可读信息(尽量提取 Gitea 返回的 message 字段)
fn api_err(prefix: &str, e: ureq::Error) -> String {
match e {
ureq::Error::Status(code, resp) => {
let msg = resp.into_json::<serde_json::Value>().ok()
.and_then(|v| v["message"].as_str().map(|s| s.to_string()))
.unwrap_or_default();
if msg.is_empty() {
format!("{prefix}: HTTP {code}")
} else {
format!("{prefix}: {msg}HTTP {code}")
}
}
other => format!("{prefix}: {other}"),
}
}
/// 写入或更新项目 ↔ 仓库绑定(换绑时重置 webhook 字段,旧 hook 已随旧仓库失效)
fn upsert_repo_link(
conn: &rusqlite::Connection,
instance_id: &str,
project_id: &str,
owner: &str,
repo_name: &str,
clone_url: Option<&str>,
) -> Result<GiteaRepo, String> {
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, clone_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(project_id) DO UPDATE SET
instance_id = ?2, gitea_owner = ?4, gitea_repo_name = ?5, clone_url = ?6,
webhook_secret = NULL, webhook_id = NULL",
params![id, instance_id, project_id, owner, repo_name, clone_url],
).map_err(|e| e.to_string())?;
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,
).map_err(|e| e.to_string())
}
/// GET /api/v1/user — 验证 token 并返回用户信息 /// GET /api/v1/user — 验证 token 并返回用户信息
pub fn gitea_api_get_user(base_url: &str, token: &str) -> Result<GiteaUser, String> { pub fn gitea_api_get_user(base_url: &str, token: &str) -> Result<GiteaUser, String> {
let url = format!("{}/api/v1/user", base_url); let url = format!("{}/api/v1/user", base_url);
@ -207,7 +261,6 @@ pub fn gitea_api_get_user(base_url: &str, token: &str) -> Result<GiteaUser, Stri
} }
/// GET /api/v1/repos/:owner/:repo — 获取仓库信息G2 仓库绑定时使用) /// GET /api/v1/repos/:owner/:repo — 获取仓库信息G2 仓库绑定时使用)
#[allow(dead_code)]
pub fn gitea_api_get_repo( pub fn gitea_api_get_repo(
base_url: &str, base_url: &str,
token: &str, token: &str,
@ -218,10 +271,196 @@ pub fn gitea_api_get_repo(
let resp = ureq::get(&url) let resp = ureq::get(&url)
.set("Authorization", &format!("token {token}")) .set("Authorization", &format!("token {token}"))
.call() .call()
.map_err(|e| format!("获取仓库信息失败: {e}"))?; .map_err(|e| api_err("获取仓库信息失败", e))?;
resp.into_json().map_err(|e| format!("解析失败: {e}")) resp.into_json().map_err(|e| format!("解析失败: {e}"))
} }
// ── 仓库创建 / 迁移 / 绑定G2──────────────────────────────────────────────
/// 在 Gitea 新建仓库并绑定到项目
#[tauri::command]
pub fn gitea_create_repo(
instance_id: String,
project_id: String,
repo_name: String,
description: Option<String>,
private: Option<bool>,
) -> Result<GiteaRepo, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let (base_url, token) = instance_auth(&conn, &instance_id)?;
let resp = ureq::post(&format!("{base_url}/api/v1/user/repos"))
.set("Authorization", &format!("token {token}"))
.send_json(serde_json::json!({
"name": repo_name,
"description": description.unwrap_or_default(),
"private": private.unwrap_or(true),
"auto_init": false,
}))
.map_err(|e| api_err("创建仓库失败", e))?;
let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
let owner = json["owner"]["login"].as_str().ok_or("响应缺少 owner 字段")?.to_string();
let clone_url = json["clone_url"].as_str().map(|s| s.to_string());
upsert_repo_link(&conn, &instance_id, &project_id, &owner, &repo_name, clone_url.as_deref())
}
/// 从 GitHub/GitLab 等迁移仓库到 Gitea 并绑定到项目
#[tauri::command]
pub fn gitea_migrate_repo(
instance_id: String,
project_id: String,
clone_addr: String,
repo_name: String,
auth_token: Option<String>,
private: Option<bool>,
) -> Result<GiteaRepo, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let (base_url, token) = instance_auth(&conn, &instance_id)?;
let mut body = serde_json::json!({
"clone_addr": clone_addr,
"repo_name": repo_name,
"private": private.unwrap_or(true),
"mirror": false,
"service": "git",
});
if let Some(t) = auth_token.filter(|t| !t.trim().is_empty()) {
body["auth_token"] = serde_json::Value::String(t);
}
let resp = ureq::post(&format!("{base_url}/api/v1/repos/migrate"))
.set("Authorization", &format!("token {token}"))
.send_json(body)
.map_err(|e| api_err("迁移仓库失败", e))?;
let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
let owner = json["owner"]["login"].as_str().ok_or("响应缺少 owner 字段")?.to_string();
let clone_url = json["clone_url"].as_str().map(|s| s.to_string());
upsert_repo_link(&conn, &instance_id, &project_id, &owner, &repo_name, clone_url.as_deref())
}
/// 绑定 Gitea 上已存在的仓库到项目(先验证仓库存在)
#[tauri::command]
pub fn gitea_link_repo(
instance_id: String,
project_id: String,
owner: String,
repo_name: String,
) -> Result<GiteaRepo, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let (base_url, token) = instance_auth(&conn, &instance_id)?;
let json = gitea_api_get_repo(&base_url, &token, &owner, &repo_name)?;
let clone_url = json["clone_url"].as_str().map(|s| s.to_string());
upsert_repo_link(&conn, &instance_id, &project_id, &owner, &repo_name, clone_url.as_deref())
}
// ── Webhook 注册 / 解绑G4─────────────────────────────────────────────────
/// 为项目绑定的仓库注册 push webhook。
/// callback_base 形如 http://192.168.1.5:27190Gitea 服务器视角可达的宿主机地址)。
/// 已有 webhook 时先删旧再建新(幂等重设)。
#[tauri::command]
pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result<GiteaRepo, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let link = 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,
).map_err(|_| "项目尚未绑定 Gitea 仓库".to_string())?;
let (base_url, token) = instance_auth(&conn, &link.instance_id)?;
// 旧 hook 存在则先删(失败不阻断,可能已在 Gitea 侧被手动删除)
if let Some(old_id) = link.webhook_id {
let _ = ureq::delete(&format!(
"{base_url}/api/v1/repos/{}/{}/hooks/{old_id}",
link.gitea_owner, link.gitea_repo_name
))
.set("Authorization", &format!("token {token}"))
.call();
}
let secret = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple());
let callback_url = format!(
"{}/webhook/gitea/{}",
callback_base.trim_end_matches('/'),
project_id
);
let resp = ureq::post(&format!(
"{base_url}/api/v1/repos/{}/{}/hooks",
link.gitea_owner, link.gitea_repo_name
))
.set("Authorization", &format!("token {token}"))
.send_json(serde_json::json!({
"type": "gitea",
"active": true,
"branch_filter": "*",
"events": ["push"],
"config": {
"url": callback_url,
"content_type": "json",
"secret": secret,
"http_method": "post",
},
}))
.map_err(|e| api_err("注册 webhook 失败", e))?;
let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
let hook_id = json["id"].as_i64().ok_or("响应缺少 hook id")?;
conn.execute(
"UPDATE gitea_repos SET webhook_secret = ?1, webhook_id = ?2 WHERE project_id = ?3",
params![secret, hook_id, project_id],
).map_err(|e| e.to_string())?;
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,
).map_err(|e| e.to_string())
}
/// 解绑项目与 Gitea 仓库(顺带删除 Gitea 侧 webhook失败不阻断
#[tauri::command]
pub fn gitea_unlink_repo(project_id: String) -> Result<(), String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let link = 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(l) => l,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(()),
Err(e) => return Err(e.to_string()),
};
if let Some(hook_id) = link.webhook_id {
if let Ok((base_url, token)) = instance_auth(&conn, &link.instance_id) {
let _ = ureq::delete(&format!(
"{base_url}/api/v1/repos/{}/{}/hooks/{hook_id}",
link.gitea_owner, link.gitea_repo_name
))
.set("Authorization", &format!("token {token}"))
.call();
}
}
conn.execute("DELETE FROM gitea_repos WHERE project_id = ?1", params![project_id])
.map_err(|e| e.to_string())?;
Ok(())
}
// ── 仓库绑定查询命令 ────────────────────────────────────────────────────────── // ── 仓库绑定查询命令 ──────────────────────────────────────────────────────────
/// 查询所有已绑定的仓库(含实例名) /// 查询所有已绑定的仓库(含实例名)
@ -314,6 +553,37 @@ mod tests {
assert_eq!(count, 1); assert_eq!(count, 1);
} }
#[test]
fn upsert_repo_link_resets_webhook_on_rebind() {
let conn = crate::db::conn_with_schema();
conn.execute(
"INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1', 'a', 'u', 't')",
[],
).unwrap();
// 首次绑定
let r1 = upsert_repo_link(&conn, "i1", "proj-1", "alice", "repo-a", Some("https://g/a.git")).unwrap();
assert_eq!(r1.gitea_repo_name, "repo-a");
// 模拟已设置 webhook
conn.execute(
"UPDATE gitea_repos SET webhook_secret = 'sec', webhook_id = 42 WHERE project_id = 'proj-1'",
[],
).unwrap();
// 换绑到另一个仓库 → webhook 字段应被重置
let r2 = upsert_repo_link(&conn, "i1", "proj-1", "alice", "repo-b", None).unwrap();
assert_eq!(r2.gitea_repo_name, "repo-b");
assert!(r2.webhook_secret.is_none(), "换绑后 webhook_secret 未重置");
assert!(r2.webhook_id.is_none(), "换绑后 webhook_id 未重置");
// 仍然只有一行
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM gitea_repos WHERE project_id = 'proj-1'", [], |r| r.get(0),
).unwrap();
assert_eq!(count, 1);
}
#[test] #[test]
fn unique_index_on_project_id() { fn unique_index_on_project_id() {
let conn = crate::db::conn_with_schema(); let conn = crate::db::conn_with_schema();

View File

@ -333,6 +333,11 @@ pub fn run() {
commands::gitea::gitea_test_connection, commands::gitea::gitea_test_connection,
commands::gitea::gitea_list_linked_repos, commands::gitea::gitea_list_linked_repos,
commands::gitea::gitea_get_repo_link, commands::gitea::gitea_get_repo_link,
commands::gitea::gitea_create_repo,
commands::gitea::gitea_migrate_repo,
commands::gitea::gitea_link_repo,
commands::gitea::gitea_setup_webhook,
commands::gitea::gitea_unlink_repo,
// beast status (read-only) // beast status (read-only)
get_beast_global_status, get_beast_global_status,
// source library // source library

View File

@ -1,6 +1,7 @@
use axum::{ use axum::{
body::Bytes,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::{HeaderMap, StatusCode},
response::{ response::{
sse::{Event, KeepAlive, Sse}, sse::{Event, KeepAlive, Sse},
IntoResponse, Response, IntoResponse, Response,
@ -46,6 +47,8 @@ pub async fn start(port: u16) {
.route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect)) .route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect))
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC响应经 SSE 推回 // SSE 消息端点:客户端通过此 URL 发送 JSON-RPC响应经 SSE 推回
.route("/mcp/group/:group_id/messages", post(handle_sse_message)) .route("/mcp/group/:group_id/messages", post(handle_sse_message))
// Gitea webhook 观测面:接收 push 事件写入 commit_metrics飞轮数据源
.route("/webhook/gitea/:project_id", post(handle_gitea_webhook))
.with_state(sessions); .with_state(sessions);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
@ -64,6 +67,144 @@ async fn health() -> &'static str {
"炼境 MCP Server running" "炼境 MCP Server running"
} }
// ── Gitea Webhook 观测面 ──────────────────────────────────────────────────────
/// push payload 中的一条 commit
#[derive(Debug, PartialEq)]
struct PushCommit {
sha: String,
message: String,
timestamp: Option<String>,
}
/// HMAC-SHA256 验签。Gitea 实际发送的签名头是 `X-Gitea-Signature`payload 的
/// HMAC-SHA256 hexR10以实际抓包行为为准非架构文档假设的 X-Gitea-Signature-256
fn verify_hmac_sha256(secret: &str, body: &[u8], sig_hex: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(body);
let expected: String = mac
.finalize()
.into_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
// 大小写不敏感比较Gitea 输出小写 hex
expected.eq_ignore_ascii_case(sig_hex.trim())
}
/// 解析 Gitea push payload 的 commits 数组
fn parse_push_payload(body: &[u8]) -> Result<Vec<PushCommit>, String> {
let json: Value = serde_json::from_slice(body).map_err(|e| format!("payload 非法 JSON: {e}"))?;
let commits = json["commits"].as_array().ok_or("payload 缺少 commits 数组")?;
Ok(commits
.iter()
.filter_map(|c| {
let sha = c["id"].as_str()?.to_string();
let message = c["message"].as_str().unwrap_or("").to_string();
let timestamp = c["timestamp"].as_str().map(|s| s.to_string());
Some(PushCommit { sha, message, timestamp })
})
.collect())
}
/// 从请求头提取签名:优先 X-Gitea-Signature兼容 X-Hub-Signature-256sha256= 前缀)
fn extract_signature(headers: &HeaderMap) -> Option<String> {
if let Some(v) = headers.get("x-gitea-signature").and_then(|v| v.to_str().ok()) {
return Some(v.to_string());
}
headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
.map(|v| v.trim_start_matches("sha256=").to_string())
}
async fn handle_gitea_webhook(
Path(project_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
// 1. 查绑定与 secret
let secret: Option<String> = db::pool().get().ok().and_then(|c| {
c.query_row(
"SELECT webhook_secret FROM gitea_repos WHERE project_id = ?1",
[&project_id],
|r| r.get(0),
)
.ok()
});
let secret = match secret {
Some(s) => s,
None => {
return (StatusCode::NOT_FOUND, Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"})))
.into_response()
}
};
// 2. 验签
let sig = extract_signature(&headers);
let verified = sig.as_deref().map(|s| verify_hmac_sha256(&secret, &body, s)).unwrap_or(false);
if !verified {
log_event(
"gitea_webhook",
"warn",
&format!("项目 {project_id} webhook 验签失败(签名{}", if sig.is_some() { "不匹配" } else { "缺失" }),
None,
);
return (StatusCode::UNAUTHORIZED, Json(json!({"error": "签名验证失败"}))).into_response();
}
// 3. 只处理 push 事件,其他事件确认收到但忽略
let event = headers
.get("x-gitea-event")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if event != "push" {
return Json(json!({"ignored": event})).into_response();
}
// 4. 解析 commits 并写入 commit_metricssha 主键幂等,与本地 watcher 双路径不重复计数)
let commits = match parse_push_payload(&body) {
Ok(c) => c,
Err(e) => {
return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
}
};
let conn = match db::pool().get() {
Ok(c) => c,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response()
}
};
let mut received = 0;
for c in &commits {
if crate::commands::commit_metrics::record_commit(
&conn,
&project_id,
&c.sha,
&c.message,
c.timestamp.as_deref(),
)
.is_ok()
{
received += 1;
}
}
log_event(
"gitea_webhook",
"info",
&format!("项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit", commits.len()),
None,
);
Json(json!({"received": received})).into_response()
}
// ── JSON-RPC 类型 ───────────────────────────────────────────────────────────── // ── JSON-RPC 类型 ─────────────────────────────────────────────────────────────
#[derive(Deserialize)] #[derive(Deserialize)]
@ -682,3 +823,62 @@ fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<
Ok(md) Ok(md)
} }
// ── Gitea Webhook 单元测试 ────────────────────────────────────────────────────
#[cfg(test)]
mod webhook_tests {
use super::*;
/// 用相同算法生成签名,验证正反两个方向
fn sign(secret: &str, body: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn hmac_verify_accepts_valid_signature() {
let body = br#"{"commits":[]}"#;
let sig = sign("my-secret", body);
assert!(verify_hmac_sha256("my-secret", body, &sig));
// 大写 hex 也应通过
assert!(verify_hmac_sha256("my-secret", body, &sig.to_uppercase()));
}
#[test]
fn hmac_verify_rejects_wrong_secret_or_tampered_body() {
let body = br#"{"commits":[]}"#;
let sig = sign("my-secret", body);
assert!(!verify_hmac_sha256("other-secret", body, &sig));
assert!(!verify_hmac_sha256("my-secret", br#"{"commits":[1]}"#, &sig));
assert!(!verify_hmac_sha256("my-secret", body, "deadbeef"));
}
#[test]
fn parse_push_payload_extracts_commits() {
let body = br#"{
"ref": "refs/heads/master",
"commits": [
{"id": "abc123", "message": "feat(core): add thing", "timestamp": "2026-07-02T10:00:00+08:00"},
{"id": "def456", "message": "fix(ui): repair", "timestamp": null}
]
}"#;
let commits = parse_push_payload(body).unwrap();
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].sha, "abc123");
assert_eq!(commits[0].message, "feat(core): add thing");
assert_eq!(commits[0].timestamp.as_deref(), Some("2026-07-02T10:00:00+08:00"));
assert_eq!(commits[1].timestamp, None);
}
#[test]
fn parse_push_payload_rejects_invalid() {
assert!(parse_push_payload(b"not json").is_err());
assert!(parse_push_payload(br#"{"no_commits": true}"#).is_err());
// commits 为空数组是合法的(如仅 tag push
assert_eq!(parse_push_payload(br#"{"commits":[]}"#).unwrap().len(), 0);
}
}

View File

@ -1,5 +1,6 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { AgentInfraPanel } from "./AgentInfraPanel"; import { AgentInfraPanel } from "./AgentInfraPanel";
import { GiteaPanel } from "./GiteaPanel";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { import {
@ -1553,6 +1554,9 @@ function GovernancePanel({
{/* ── Agent 基础设施(接入包 v2───────────────────────── */} {/* ── Agent 基础设施(接入包 v2───────────────────────── */}
<AgentInfraPanel projectPath={projectPath} projectId={projectId} /> <AgentInfraPanel projectPath={projectPath} projectId={projectId} />
{/* ── Gitea 集成(统一 git 后端)─────────────────────────── */}
{projectId && <GiteaPanel projectId={projectId} projectName={projectName} />}
{/* 项目信息 */} {/* 项目信息 */}
<div className="pt-2 border-t border-gray-100"> <div className="pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-300 truncate">{projectName}</p> <p className="text-[10px] text-gray-300 truncate">{projectName}</p>

View File

@ -0,0 +1,357 @@
// Gitea 集成治理区块:实例管理 + 当前项目仓库绑定 + webhook 注册
// 控制面在 gitea.rs观测面webhook 接收)在 mcp_server.rs
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
giteaSaveInstance,
giteaListInstances,
giteaDeleteInstance,
giteaTestConnection,
giteaGetRepoLink,
giteaListLinkedRepos,
giteaCreateRepo,
giteaMigrateRepo,
giteaLinkRepo,
giteaSetupWebhook,
giteaUnlinkRepo,
type GiteaInstance,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
interface Props {
projectId: string;
projectName: string;
}
type BindMode = "create" | "migrate" | "link";
const CALLBACK_BASE_KEY = "gitea_callback_base";
const inputCls =
"w-full text-[11px] border border-gray-200 rounded px-2 py-1.5 outline-none focus:border-blue-400";
const btnCls =
"px-2 py-1 rounded text-[11px] border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors";
export function GiteaPanel({ projectId, projectName }: Props) {
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const { data: instances = [] } = useQuery({
queryKey: ["gitea-instances"],
queryFn: giteaListInstances,
});
const { data: link } = useQuery({
queryKey: ["gitea-link", projectId],
queryFn: () => giteaGetRepoLink(projectId),
});
const { data: allLinks = [] } = useQuery({
queryKey: ["gitea-linked-repos"],
queryFn: giteaListLinkedRepos,
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["gitea-link", projectId] });
qc.invalidateQueries({ queryKey: ["gitea-linked-repos"] });
};
// ── 实例管理 ──
const [showAddInstance, setShowAddInstance] = useState(false);
const [instName, setInstName] = useState("");
const [instUrl, setInstUrl] = useState("");
const [instToken, setInstToken] = useState("");
const [testing, setTesting] = useState<string | null>(null);
const handleAddInstance = async () => {
if (!instName.trim() || !instUrl.trim() || !instToken.trim()) return;
try {
await giteaSaveInstance({
name: instName.trim(),
url: instUrl.trim(),
access_token: instToken.trim(),
is_default: instances.length === 0,
});
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
setInstName(""); setInstUrl(""); setInstToken("");
setShowAddInstance(false);
showToast("Gitea 实例已添加 ✓");
} catch (e) {
showToast(`${e}`);
}
};
const handleTest = async (inst: GiteaInstance) => {
setTesting(inst.id);
try {
const user = await giteaTestConnection(inst.id);
showToast(`✓ 连接成功,账户: ${user.login}`);
} catch (e) {
showToast(`${e}`);
} finally {
setTesting(null);
}
};
const handleDeleteInstance = async (inst: GiteaInstance) => {
if (!confirm(`删除实例「${inst.name}」?其下所有项目绑定会一并删除。`)) return;
try {
await giteaDeleteInstance(inst.id);
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
refresh();
showToast("已删除");
} catch (e) {
showToast(`${e}`);
}
};
// ── 当前项目绑定 ──
const [bindMode, setBindMode] = useState<BindMode | null>(null);
const [bindInstanceId, setBindInstanceId] = useState("");
const [repoName, setRepoName] = useState("");
const [repoOwner, setRepoOwner] = useState("");
const [cloneAddr, setCloneAddr] = useState("");
const [migrateToken, setMigrateToken] = useState("");
const [binding, setBinding] = useState(false);
const effectiveInstanceId =
bindInstanceId || instances.find((i) => i.is_default)?.id || instances[0]?.id || "";
const handleBind = async () => {
if (!effectiveInstanceId || !repoName.trim()) return;
setBinding(true);
try {
if (bindMode === "create") {
await giteaCreateRepo(effectiveInstanceId, projectId, repoName.trim(), projectName, true);
} else if (bindMode === "migrate") {
if (!cloneAddr.trim()) { showToast("❌ 请填写源仓库地址"); return; }
await giteaMigrateRepo(
effectiveInstanceId, projectId, cloneAddr.trim(), repoName.trim(),
migrateToken.trim() || undefined, true
);
} else if (bindMode === "link") {
if (!repoOwner.trim()) { showToast("❌ 请填写仓库 owner"); return; }
await giteaLinkRepo(effectiveInstanceId, projectId, repoOwner.trim(), repoName.trim());
}
refresh();
setBindMode(null);
setRepoName(""); setRepoOwner(""); setCloneAddr(""); setMigrateToken("");
showToast("仓库绑定成功 ✓");
} catch (e) {
showToast(`${e}`);
} finally {
setBinding(false);
}
};
// ── Webhook ──
const [callbackBase, setCallbackBase] = useState(
() => localStorage.getItem(CALLBACK_BASE_KEY) ?? "http://127.0.0.1:27190"
);
const [settingWebhook, setSettingWebhook] = useState(false);
const callbackUrl = `${callbackBase.replace(/\/+$/, "")}/webhook/gitea/${projectId}`;
const handleSetupWebhook = async () => {
setSettingWebhook(true);
try {
localStorage.setItem(CALLBACK_BASE_KEY, callbackBase);
await giteaSetupWebhook(projectId, callbackBase);
refresh();
showToast("Webhook 已注册 ✓ push 事件将实时进入飞轮");
} catch (e) {
showToast(`${e}`);
} finally {
setSettingWebhook(false);
}
};
const handleUnlink = async () => {
if (!confirm("解绑当前项目与 Gitea 仓库Gitea 侧仓库不会删除)")) return;
try {
await giteaUnlinkRepo(projectId);
refresh();
showToast("已解绑");
} catch (e) {
showToast(`${e}`);
}
};
const copy = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {});
showToast("已复制 ✓");
};
return (
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-3">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
🪁 Gitea
</p>
{/* ── 实例列表 ── */}
<div className="space-y-1.5">
{instances.length === 0 && !showAddInstance && (
<p className="text-[11px] text-gray-400"> Gitea </p>
)}
{instances.map((inst) => (
<div key={inst.id} className="flex items-center gap-2 text-[11px]">
<span className="font-medium text-gray-700 shrink-0">{inst.name}</span>
{inst.is_default && (
<span className="text-[10px] px-1 rounded bg-blue-50 text-blue-500 shrink-0"></span>
)}
<span className="text-gray-400 font-mono truncate flex-1">{inst.url}</span>
<button onClick={() => handleTest(inst)} disabled={testing === inst.id} className={btnCls}>
{testing === inst.id ? "…" : "测试"}
</button>
<button
onClick={() => handleDeleteInstance(inst)}
className="px-2 py-1 rounded text-[11px] border border-gray-200 text-red-400 hover:bg-red-50"
>
</button>
</div>
))}
{showAddInstance ? (
<div className="space-y-1.5 border-t border-gray-50 pt-2">
<input className={inputCls} placeholder="实例名称(如:家里 NAS" value={instName} onChange={(e) => setInstName(e.target.value)} />
<input className={inputCls} placeholder="https://gitea.example.com" value={instUrl} onChange={(e) => setInstUrl(e.target.value)} />
<input className={inputCls} type="password" placeholder="Access Token用户设置 → 应用 → 生成令牌)" value={instToken} onChange={(e) => setInstToken(e.target.value)} />
<div className="flex gap-1.5">
<button onClick={handleAddInstance} className="px-2.5 py-1 rounded text-[11px] bg-blue-600 text-white hover:bg-blue-700"></button>
<button onClick={() => setShowAddInstance(false)} className={btnCls}></button>
</div>
</div>
) : (
<button onClick={() => setShowAddInstance(true)} className="text-[11px] text-blue-500 hover:text-blue-600">
+
</button>
)}
</div>
{/* ── 当前项目绑定 ── */}
<div className="border-t border-gray-50 pt-2 space-y-2">
{link ? (
<>
<div className="flex items-center gap-2 text-[11px]">
<span className="text-gray-500 shrink-0"></span>
<span className="font-mono text-gray-700 truncate">
{link.gitea_owner}/{link.gitea_repo_name}
</span>
{link.clone_url && (
<button onClick={() => copy(link.clone_url!)} className="text-blue-400 hover:text-blue-600 shrink-0 text-[10px]">
clone URL
</button>
)}
<button onClick={handleUnlink} className="ml-auto text-red-400 hover:text-red-500 shrink-0 text-[10px]">
</button>
</div>
{/* webhook 状态与注册 */}
<div className="bg-gray-50 rounded p-2 space-y-1.5">
<div className="flex items-center gap-1.5 text-[11px]">
<span className={`w-1.5 h-1.5 rounded-full ${link.webhook_id ? "bg-green-400" : "bg-gray-300"}`} />
<span className="text-gray-500">
{link.webhook_id ? `Webhook 已注册(#${link.webhook_id}` : "Webhook 未注册"}
</span>
</div>
<div className="flex gap-1.5">
<input
className={inputCls}
value={callbackBase}
onChange={(e) => setCallbackBase(e.target.value)}
placeholder="Gitea 服务器可达的宿主机地址,如 http://192.168.1.5:27190"
/>
</div>
<div className="flex items-center gap-1.5">
<code className="flex-1 text-[10px] text-gray-400 font-mono truncate" title={callbackUrl}>
{callbackUrl}
</code>
<button onClick={() => copy(callbackUrl)} className={btnCls}></button>
<button
onClick={handleSetupWebhook}
disabled={settingWebhook}
className="px-2.5 py-1 rounded text-[11px] bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{settingWebhook ? "注册中…" : link.webhook_id ? "重设" : "注册 Webhook"}
</button>
</div>
<p className="text-[10px] text-gray-400 leading-relaxed">
Gitea 访MCP 27190push commit
</p>
</div>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-500"></span>
{(["create", "migrate", "link"] as BindMode[]).map((m) => (
<button
key={m}
onClick={() => setBindMode(bindMode === m ? null : m)}
className={`px-2 py-1 rounded text-[11px] border transition-colors ${
bindMode === m
? "border-blue-300 bg-blue-50 text-blue-600"
: "border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
{m === "create" ? "新建仓库" : m === "migrate" ? "迁移" : "绑定已有"}
</button>
))}
</div>
{bindMode && (
<div className="space-y-1.5 bg-gray-50 rounded p-2">
{instances.length > 1 && (
<select
className={inputCls}
value={effectiveInstanceId}
onChange={(e) => setBindInstanceId(e.target.value)}
>
{instances.map((i) => (
<option key={i.id} value={i.id}>{i.name}{i.url}</option>
))}
</select>
)}
{bindMode === "migrate" && (
<>
<input className={inputCls} placeholder="源仓库地址https://github.com/user/repo.git" value={cloneAddr} onChange={(e) => setCloneAddr(e.target.value)} />
<input className={inputCls} type="password" placeholder="源仓库 token私有仓库需要可留空" value={migrateToken} onChange={(e) => setMigrateToken(e.target.value)} />
</>
)}
{bindMode === "link" && (
<input className={inputCls} placeholder="ownerGitea 用户名或组织)" value={repoOwner} onChange={(e) => setRepoOwner(e.target.value)} />
)}
<input className={inputCls} placeholder="仓库名" value={repoName} onChange={(e) => setRepoName(e.target.value)} />
<button
onClick={handleBind}
disabled={binding || !effectiveInstanceId || !repoName.trim()}
className="px-2.5 py-1 rounded text-[11px] bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{binding ? "执行中…" : "确认"}
</button>
{instances.length === 0 && (
<p className="text-[10px] text-amber-500"> Gitea </p>
)}
</div>
)}
</>
)}
</div>
{/* ── 全部绑定概览 ── */}
{allLinks.length > 0 && (
<div className="border-t border-gray-50 pt-2">
<p className="text-[10px] text-gray-400 mb-1">{allLinks.length}</p>
<div className="space-y-0.5 max-h-24 overflow-y-auto">
{allLinks.map((r) => (
<div key={r.id} className="flex items-center gap-1.5 text-[10px] text-gray-500">
<span className={`w-1 h-1 rounded-full shrink-0 ${r.has_webhook ? "bg-green-400" : "bg-gray-300"}`} title={r.has_webhook ? "webhook 已注册" : "webhook 未注册"} />
<span className="font-mono truncate">{r.gitea_owner}/{r.gitea_repo_name}</span>
<span className="text-gray-300 truncate ml-auto shrink-0">{r.instance_name}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -1662,8 +1662,51 @@ export const giteaTestConnection = (instanceId: string) =>
export const giteaListLinkedRepos = () => export const giteaListLinkedRepos = () =>
invoke<GiteaLinkedRepo[]>("gitea_list_linked_repos"); invoke<GiteaLinkedRepo[]>("gitea_list_linked_repos");
/** 后端 GiteaRepo 结构(单项目绑定详情,与 GiteaLinkedRepo 列表视图字段不同) */
export interface GiteaRepoLink {
id: string;
instance_id: string;
project_id: string;
gitea_owner: string;
gitea_repo_name: string;
clone_url?: string;
webhook_secret?: string;
webhook_id?: number;
linked_at?: string;
}
export const giteaGetRepoLink = (projectId: string) => export const giteaGetRepoLink = (projectId: string) =>
invoke<GiteaLinkedRepo | null>("gitea_get_repo_link", { projectId }); invoke<GiteaRepoLink | null>("gitea_get_repo_link", { projectId });
export const giteaCreateRepo = (
instanceId: string,
projectId: string,
repoName: string,
description?: string,
privateRepo?: boolean
) => invoke<GiteaRepoLink>("gitea_create_repo", { instanceId, projectId, repoName, description, private: privateRepo });
export const giteaMigrateRepo = (
instanceId: string,
projectId: string,
cloneAddr: string,
repoName: string,
authToken?: string,
privateRepo?: boolean
) => invoke<GiteaRepoLink>("gitea_migrate_repo", { instanceId, projectId, cloneAddr, repoName, authToken, private: privateRepo });
export const giteaLinkRepo = (
instanceId: string,
projectId: string,
owner: string,
repoName: string
) => invoke<GiteaRepoLink>("gitea_link_repo", { instanceId, projectId, owner, repoName });
export const giteaSetupWebhook = (projectId: string, callbackBase: string) =>
invoke<GiteaRepoLink>("gitea_setup_webhook", { projectId, callbackBase });
export const giteaUnlinkRepo = (projectId: string) =>
invoke<void>("gitea_unlink_repo", { projectId });
// ── Beast Global Status (read-only) ─────────────────────────────────────────── // ── Beast Global Status (read-only) ───────────────────────────────────────────