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:
parent
29a066e7e3
commit
d14b348845
@ -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 全部完成(控制面+观测面+UI);余:Gitea Actions 生成(📋)、PR/CI 事件、迁移"
|
||||||
position: [3500, 150]
|
position: [3500, 150]
|
||||||
updated: 2026-07-02
|
updated: 2026-07-02
|
||||||
|
|
||||||
|
|||||||
@ -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-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 回调地址,用户自行确认网络可达。
|
**网络说明**: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 表建好(migrate);save/list/delete/test_connection 命令可调;test_connection 调 GET /api/v1/user 返回 Gitea 用户名验证 token 有效性
|
- acceptance: gitea_instances + gitea_repos 表建好(migrate);save/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_repos;gitea_migrate_repo 能从 GitHub/GitLab 迁移仓库到 Gitea;gitea_link_repo 绑定已有仓库;命令失败返回可读错误
|
- acceptance: gitea_create_repo 调 Gitea API 建仓后写 gitea_repos;gitea_migrate_repo 能从 GitHub/GitLab 迁移仓库到 Gitea;gitea_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_repos;gitea_unlink_repo 清理绑定
|
- 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
|
### ✅ 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
12
src-tauri/Cargo.lock
generated
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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:27190(Gitea 服务器视角可达的宿主机地址)。
|
||||||
|
/// 已有 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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 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::<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-256(sha256= 前缀)
|
||||||
|
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_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 类型 ─────────────────────────────────────────────────────────────
|
// ── 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
357
src/components/dashboard/GiteaPanel.tsx
Normal file
357
src/components/dashboard/GiteaPanel.tsx
Normal 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 端口默认 27190)。push 事件将实时写入飞轮 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="owner(Gitea 用户名或组织)" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user