diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index e627343..8ea1660 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -376,7 +376,8 @@ modules: name: Gitea 集成 area: backend status: in_progress - progress: 20 + progress: 55 + note: "G1-G5 全部完成(控制面+观测面+UI);余:Gitea Actions 生成(📋)、PR/CI 事件、迁移" position: [3500, 150] updated: 2026-07-02 diff --git a/.blueprint/modules/gitea-integration.md b/.blueprint/modules/gitea-integration.md index 269610b..624fbc8 100644 --- a/.blueprint/modules/gitea-integration.md +++ b/.blueprint/modules/gitea-integration.md @@ -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 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) 4. 批量 record_commit(project_id, sha, message, date) 5. 返回 200 OK;验签失败记 log_event ``` +> 修正(2026-07-02,R10):原设计写的签名头 `X-Gitea-Signature-256` 有误,Gitea 实际发送 +> `X-Gitea-Signature`(payload 的 HMAC-SHA256 hex);实现以实际行为为准并兼容 GitHub 风格头。 +> project_id 采用与 commit_metrics/ingest_full_git_history 相同的 Project.id(getProjects 体系), +> 避免重蹈 project_id 双路径冲突(b13dc83 教训)。 + **网络说明**:Gitea 服务器需能访问宿主机 `{ip}:27190`(MCP 端口)。UI 显示可复制的 webhook 回调地址,用户自行确认网络可达。 --- @@ -81,12 +86,16 @@ Gitea push → POST /webhook/gitea/:project_id - complexity: L - acceptance: TBD —— 建仓/关联、推送、读 CI 状态、读 PR review 结果 +> ⚠️ 建仓/关联部分已被 G1/G2 实现取代;剩余范围收窄为「读 CI 状态、读 PR review 结果」(依赖 Gitea Actions 落地后再议)。 + ### 💭 观测面:Gitea webhook 接入(持线人感知动态) - status: concept - complexity: L - 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 +> ⚠️ push 事件部分已被 G3/G4 实现取代;剩余范围收窄为「PR/CI/review 事件 + 轮询降级」(见「PR 事件 ↔ 蓝图任务卡联动」concept 卡)。 + ### 📋 Gitea Actions workflow 生成 - status: todo - 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 - acceptance: gitea_instances + gitea_repos 表建好(migrate);save/list/delete/test_connection 命令可调;test_connection 调 GET /api/v1/user 返回 Gitea 用户名验证 token 有效性 -### 🔵 G2. 仓库创建 / 迁移 / 绑定 -- status: in_progress +### ✅ G2. 仓库创建 / 迁移 / 绑定 +- status: done - complexity: M - depends: G1 - files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts - acceptance: gitea_create_repo 调 Gitea API 建仓后写 gitea_repos;gitea_migrate_repo 能从 GitHub/GitLab 迁移仓库到 Gitea;gitea_link_repo 绑定已有仓库;命令失败返回可读错误 -### 📋 G3. Webhook 接收端(MCP Server 扩展) -- status: todo +### ✅ G3. Webhook 接收端(MCP Server 扩展) +- status: done - complexity: M - depends: G1 - 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 -### 🔵 G4. Webhook 注册命令 -- status: in_progress +### ✅ G4. Webhook 注册命令 +- status: done - complexity: M - depends: G2, G3 - 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_repos;gitea_unlink_repo 清理绑定 -### 📋 G5. Gitea 治理面板 UI -- status: todo +### ✅ G5. Gitea 治理面板 UI +- status: done - complexity: M - depends: G1, G2, G3, G4 - files: src/components/dashboard/GiteaPanel.tsx, src/components/dashboard/BlueprintModal.tsx - 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) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cced25b..0477e5d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -887,6 +887,7 @@ dependencies = [ "chrono", "futures-core", "git2", + "hmac", "once_cell", "open", "r2d2", @@ -895,6 +896,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -919,6 +921,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1779,6 +1782,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.29.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bcefa57..90ee4a7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,4 +44,6 @@ open = "5" base64 = "0.22" urlencoding = "2" serde_yaml = "0.9" +hmac = "0.12" +sha2 = "0.10" diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 511b0b7..5f1247d 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -183,6 +183,60 @@ pub fn gitea_test_connection(instance_id: String) -> Result { // ── 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::().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 { + 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 并返回用户信息 pub fn gitea_api_get_user(base_url: &str, token: &str) -> Result { let url = format!("{}/api/v1/user", base_url); @@ -207,7 +261,6 @@ pub fn gitea_api_get_user(base_url: &str, token: &str) -> Result, + private: Option, +) -> Result { + 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, + private: Option, +) -> Result { + 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 { + 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:27190(Gitea 服务器视角可达的宿主机地址)。 +/// 已有 webhook 时先删旧再建新(幂等重设)。 +#[tauri::command] +pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result { + 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); } + #[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] fn unique_index_on_project_id() { let conn = crate::db::conn_with_schema(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b66f33..2c8cfca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -333,6 +333,11 @@ pub fn run() { commands::gitea::gitea_test_connection, commands::gitea::gitea_list_linked_repos, 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) get_beast_global_status, // source library diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index c29a29b..ae55852 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1,6 +1,7 @@ use axum::{ + body::Bytes, extract::{Path, Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::{ sse::{Event, KeepAlive, Sse}, IntoResponse, Response, @@ -46,6 +47,8 @@ pub async fn start(port: u16) { .route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect)) // SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回 .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); let addr = SocketAddr::from(([0, 0, 0, 0], port)); @@ -64,6 +67,144 @@ async fn health() -> &'static str { "炼境 MCP Server running" } +// ── Gitea Webhook 观测面 ────────────────────────────────────────────────────── + +/// push payload 中的一条 commit +#[derive(Debug, PartialEq)] +struct PushCommit { + sha: String, + message: String, + timestamp: Option, +} + +/// HMAC-SHA256 验签。Gitea 实际发送的签名头是 `X-Gitea-Signature`(payload 的 +/// HMAC-SHA256 hex,R10:以实际抓包行为为准,非架构文档假设的 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::::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, 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-256(sha256= 前缀) +fn extract_signature(headers: &HeaderMap) -> Option { + 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, + headers: HeaderMap, + body: Bytes, +) -> Response { + // 1. 查绑定与 secret + let secret: Option = 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_metrics(sha 主键幂等,与本地 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 类型 ───────────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -682,3 +823,62 @@ fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result< 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::::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); + } +} diff --git a/src/components/dashboard/BlueprintModal.tsx b/src/components/dashboard/BlueprintModal.tsx index fa96367..c5db0f5 100644 --- a/src/components/dashboard/BlueprintModal.tsx +++ b/src/components/dashboard/BlueprintModal.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { AgentInfraPanel } from "./AgentInfraPanel"; +import { GiteaPanel } from "./GiteaPanel"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { listen } from "@tauri-apps/api/event"; import { @@ -1553,6 +1554,9 @@ function GovernancePanel({ {/* ── Agent 基础设施(接入包 v2)───────────────────────── */} + {/* ── Gitea 集成(统一 git 后端)─────────────────────────── */} + {projectId && } + {/* 项目信息 */}

{projectName}

diff --git a/src/components/dashboard/GiteaPanel.tsx b/src/components/dashboard/GiteaPanel.tsx new file mode 100644 index 0000000..4665ae1 --- /dev/null +++ b/src/components/dashboard/GiteaPanel.tsx @@ -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(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(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 ( +
+

+ 🪁 Gitea 集成 +

+ + {/* ── 实例列表 ── */} +
+ {instances.length === 0 && !showAddInstance && ( +

尚未添加 Gitea 实例

+ )} + {instances.map((inst) => ( +
+ {inst.name} + {inst.is_default && ( + 默认 + )} + {inst.url} + + +
+ ))} + {showAddInstance ? ( +
+ setInstName(e.target.value)} /> + setInstUrl(e.target.value)} /> + setInstToken(e.target.value)} /> +
+ + +
+
+ ) : ( + + )} +
+ + {/* ── 当前项目绑定 ── */} +
+ {link ? ( + <> +
+ 已绑定 + + {link.gitea_owner}/{link.gitea_repo_name} + + {link.clone_url && ( + + )} + +
+ + {/* webhook 状态与注册 */} +
+
+ + + {link.webhook_id ? `Webhook 已注册(#${link.webhook_id})` : "Webhook 未注册"} + +
+
+ setCallbackBase(e.target.value)} + placeholder="Gitea 服务器可达的宿主机地址,如 http://192.168.1.5:27190" + /> +
+
+ + {callbackUrl} + + + +
+

+ 需 Gitea 服务器能访问上述地址(MCP 端口默认 27190)。push 事件将实时写入飞轮 commit 数据。 +

+
+ + ) : ( + <> +
+ 本项目未绑定: + {(["create", "migrate", "link"] as BindMode[]).map((m) => ( + + ))} +
+ {bindMode && ( +
+ {instances.length > 1 && ( + + )} + {bindMode === "migrate" && ( + <> + setCloneAddr(e.target.value)} /> + setMigrateToken(e.target.value)} /> + + )} + {bindMode === "link" && ( + setRepoOwner(e.target.value)} /> + )} + setRepoName(e.target.value)} /> + + {instances.length === 0 && ( +

请先添加 Gitea 实例

+ )} +
+ )} + + )} +
+ + {/* ── 全部绑定概览 ── */} + {allLinks.length > 0 && ( +
+

全部绑定({allLinks.length})

+
+ {allLinks.map((r) => ( +
+ + {r.gitea_owner}/{r.gitea_repo_name} + {r.instance_name} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index cf16af6..1f21186 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1662,8 +1662,51 @@ export const giteaTestConnection = (instanceId: string) => export const giteaListLinkedRepos = () => invoke("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) => - invoke("gitea_get_repo_link", { projectId }); + invoke("gitea_get_repo_link", { projectId }); + +export const giteaCreateRepo = ( + instanceId: string, + projectId: string, + repoName: string, + description?: string, + privateRepo?: boolean +) => invoke("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("gitea_migrate_repo", { instanceId, projectId, cloneAddr, repoName, authToken, private: privateRepo }); + +export const giteaLinkRepo = ( + instanceId: string, + projectId: string, + owner: string, + repoName: string +) => invoke("gitea_link_repo", { instanceId, projectId, owner, repoName }); + +export const giteaSetupWebhook = (projectId: string, callbackBase: string) => + invoke("gitea_setup_webhook", { projectId, callbackBase }); + +export const giteaUnlinkRepo = (projectId: string) => + invoke("gitea_unlink_repo", { projectId }); // ── Beast Global Status (read-only) ───────────────────────────────────────────