1613 lines
57 KiB
Rust
1613 lines
57 KiB
Rust
//! Gitea 集成——控制面:实例管理 + 仓库操作 + webhook 注册。
|
||
//!
|
||
//! 观测面(webhook 接收端)在 mcp_server.rs 实现。
|
||
|
||
use rusqlite::params;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
// ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
pub struct GiteaInstance {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub url: String,
|
||
pub access_token: String,
|
||
pub is_default: bool,
|
||
pub created_at: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct SaveGiteaInstanceInput {
|
||
pub name: String,
|
||
pub url: String,
|
||
pub access_token: String,
|
||
pub is_default: Option<bool>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
pub struct GiteaRepo {
|
||
pub id: String,
|
||
pub instance_id: String,
|
||
pub project_id: String,
|
||
pub gitea_owner: String,
|
||
pub gitea_repo_name: String,
|
||
pub clone_url: Option<String>,
|
||
pub webhook_secret: Option<String>,
|
||
pub webhook_id: Option<i64>,
|
||
pub linked_at: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct GiteaUser {
|
||
pub login: String,
|
||
pub full_name: String,
|
||
pub email: String,
|
||
}
|
||
|
||
// ── 辅助函数 ──────────────────────────────────────────────────────────────────
|
||
|
||
fn row_to_instance(row: &rusqlite::Row<'_>) -> rusqlite::Result<GiteaInstance> {
|
||
Ok(GiteaInstance {
|
||
id: row.get(0)?,
|
||
name: row.get(1)?,
|
||
url: row.get(2)?,
|
||
access_token: row.get(3)?,
|
||
is_default: row.get::<_, i64>(4)? != 0,
|
||
created_at: row.get(5)?,
|
||
})
|
||
}
|
||
|
||
fn row_to_repo(row: &rusqlite::Row<'_>) -> rusqlite::Result<GiteaRepo> {
|
||
Ok(GiteaRepo {
|
||
id: row.get(0)?,
|
||
instance_id: row.get(1)?,
|
||
project_id: row.get(2)?,
|
||
gitea_owner: row.get(3)?,
|
||
gitea_repo_name: row.get(4)?,
|
||
clone_url: row.get(5)?,
|
||
webhook_secret: row.get(6)?,
|
||
webhook_id: row.get(7)?,
|
||
linked_at: row.get(8)?,
|
||
})
|
||
}
|
||
|
||
/// 规范化 Gitea URL:去掉末尾 /
|
||
fn normalize_url(url: &str) -> String {
|
||
url.trim_end_matches('/').to_string()
|
||
}
|
||
|
||
// ── 实例管理命令 ──────────────────────────────────────────────────────────────
|
||
|
||
/// 保存 Gitea 实例(新增或覆盖同名实例)
|
||
#[tauri::command]
|
||
pub fn gitea_save_instance(input: SaveGiteaInstanceInput) -> Result<GiteaInstance, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
let id = uuid::Uuid::new_v4().to_string();
|
||
let url = normalize_url(&input.url);
|
||
let is_default = input.is_default.unwrap_or(false) as i64;
|
||
|
||
// 若设为默认,先取消其他默认
|
||
if is_default != 0 {
|
||
let _ = conn.execute("UPDATE gitea_instances SET is_default = 0", []);
|
||
}
|
||
|
||
conn.execute(
|
||
"INSERT INTO gitea_instances (id, name, url, access_token, is_default)
|
||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||
params![id, input.name, url, input.access_token, is_default],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
conn.query_row(
|
||
"SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances WHERE id = ?1",
|
||
params![id],
|
||
row_to_instance,
|
||
).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// 列出所有 Gitea 实例
|
||
#[tauri::command]
|
||
pub fn gitea_list_instances() -> Result<Vec<GiteaInstance>, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn.prepare(
|
||
"SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances ORDER BY is_default DESC, created_at ASC"
|
||
).map_err(|e| e.to_string())?;
|
||
let rows = stmt
|
||
.query_map([], row_to_instance)
|
||
.map_err(|e| e.to_string())?
|
||
.collect::<rusqlite::Result<Vec<_>>>()
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(rows)
|
||
}
|
||
|
||
/// 删除 Gitea 实例(级联删除 gitea_repos)
|
||
#[tauri::command]
|
||
pub fn gitea_delete_instance(id: String) -> Result<(), String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id])
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 更新 Gitea 实例(仅更新非空字段)
|
||
#[tauri::command]
|
||
pub fn gitea_update_instance(
|
||
id: String,
|
||
name: Option<String>,
|
||
url: Option<String>,
|
||
access_token: Option<String>,
|
||
is_default: Option<bool>,
|
||
) -> Result<GiteaInstance, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
if let Some(ref _n) = name {
|
||
conn.execute(
|
||
"UPDATE gitea_instances SET name = ?1 WHERE id = ?2",
|
||
params![_n, id],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
if let Some(ref u) = url {
|
||
let u = normalize_url(u);
|
||
conn.execute(
|
||
"UPDATE gitea_instances SET url = ?1 WHERE id = ?2",
|
||
params![u, id],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
if let Some(ref t) = access_token {
|
||
conn.execute(
|
||
"UPDATE gitea_instances SET access_token = ?1 WHERE id = ?2",
|
||
params![t, id],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
if let Some(def) = is_default {
|
||
if def {
|
||
let _ = conn.execute("UPDATE gitea_instances SET is_default = 0", []);
|
||
}
|
||
conn.execute(
|
||
"UPDATE gitea_instances SET is_default = ?1 WHERE id = ?2",
|
||
params![def as i64, id],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
|
||
conn.query_row(
|
||
"SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances WHERE id = ?1",
|
||
params![id],
|
||
row_to_instance,
|
||
).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// 测试 Gitea 连接:调 GET /api/v1/user,返回用户信息验证 token 有效性
|
||
#[tauri::command]
|
||
pub fn gitea_test_connection(instance_id: String) -> Result<GiteaUser, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
let (url, token): (String, String) = conn
|
||
.query_row(
|
||
"SELECT url, access_token FROM gitea_instances WHERE id = ?1",
|
||
params![instance_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||
)
|
||
.map_err(|_| "实例不存在".to_string())?;
|
||
|
||
gitea_api_get_user(&url, &token)
|
||
}
|
||
|
||
// ── Gitea API 辅助 ────────────────────────────────────────────────────────────
|
||
|
||
/// 读取实例的 (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}"),
|
||
}
|
||
}
|
||
|
||
/// `get_delivery_status` 使用的 Gitea 投影。它只反映远端信号,不给出发布决策。
|
||
#[derive(Debug, Clone)]
|
||
pub struct GiteaDeliverySignals {
|
||
pub pull_request: crate::commands::delivery_status::DeliverySignal,
|
||
pub ci: crate::commands::delivery_status::DeliverySignal,
|
||
pub review: crate::commands::delivery_status::DeliverySignal,
|
||
}
|
||
|
||
type DeliverySignal = crate::commands::delivery_status::DeliverySignal;
|
||
|
||
fn unavailable_delivery_signals(detail: impl Into<String>) -> GiteaDeliverySignals {
|
||
let detail = detail.into();
|
||
GiteaDeliverySignals {
|
||
pull_request: DeliverySignal::new(
|
||
"unavailable",
|
||
"gitea",
|
||
detail.clone(),
|
||
serde_json::json!({}),
|
||
),
|
||
ci: DeliverySignal::new(
|
||
"unavailable",
|
||
"gitea",
|
||
detail.clone(),
|
||
serde_json::json!({}),
|
||
),
|
||
review: DeliverySignal::new("unavailable", "gitea", detail, serde_json::json!({})),
|
||
}
|
||
}
|
||
|
||
fn gitea_get_json(
|
||
base_url: &str,
|
||
token: &str,
|
||
path: &str,
|
||
label: &str,
|
||
) -> Result<serde_json::Value, String> {
|
||
ureq::get(&format!("{base_url}{path}"))
|
||
.set("Authorization", &format!("token {token}"))
|
||
.call()
|
||
.map_err(|e| api_err(label, e))?
|
||
.into_json()
|
||
.map_err(|e| format!("{label}: 解析响应失败: {e}"))
|
||
}
|
||
|
||
/// 从 Gitea 拉取当前分支的 PR、当前 SHA 的 CI 以及该 PR 的 review 摘要。
|
||
///
|
||
/// 远端不可用必须作为数据返回,以便上层继续展示本地 Git 与部署未知状态。
|
||
pub fn get_delivery_signals(
|
||
conn: &rusqlite::Connection,
|
||
project_id: &str,
|
||
branch: Option<&str>,
|
||
commit_sha: Option<&str>,
|
||
) -> GiteaDeliverySignals {
|
||
let binding: rusqlite::Result<(String, String, String)> = conn.query_row(
|
||
"SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1",
|
||
params![project_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
);
|
||
let (instance_id, owner, repo) = match binding {
|
||
Ok(binding) => binding,
|
||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||
let detail = "项目未绑定 Gitea 仓库";
|
||
return GiteaDeliverySignals {
|
||
pull_request: DeliverySignal::new(
|
||
"not_configured",
|
||
"gitea",
|
||
detail,
|
||
serde_json::json!({}),
|
||
),
|
||
ci: DeliverySignal::new("not_configured", "gitea", detail, serde_json::json!({})),
|
||
review: DeliverySignal::new(
|
||
"not_configured",
|
||
"gitea",
|
||
detail,
|
||
serde_json::json!({}),
|
||
),
|
||
};
|
||
}
|
||
Err(error) => return unavailable_delivery_signals(format!("读取 Gitea 绑定失败:{error}")),
|
||
};
|
||
let (base_url, token) = match instance_auth(conn, &instance_id) {
|
||
Ok(auth) => auth,
|
||
Err(error) => return unavailable_delivery_signals(error),
|
||
};
|
||
|
||
let pull_path =
|
||
format!("/api/v1/repos/{owner}/{repo}/pulls?state=open&limit=50&type=pullrequest");
|
||
let pull_requests = match gitea_get_json(&base_url, &token, &pull_path, "读取 Gitea PR 失败")
|
||
{
|
||
Ok(value) => value,
|
||
Err(error) => return unavailable_delivery_signals(error),
|
||
};
|
||
if !pull_requests.is_array() {
|
||
return unavailable_delivery_signals("Gitea PR 响应不是数组");
|
||
}
|
||
|
||
let matched_pr = branch.and_then(|name| select_open_pull_request(&pull_requests, name));
|
||
let pull_request = match (branch, matched_pr) {
|
||
(None, _) => DeliverySignal::new(
|
||
"unknown",
|
||
"gitea",
|
||
"本地 Git 未提供当前分支,无法匹配开放 PR",
|
||
serde_json::json!({}),
|
||
),
|
||
(Some(_), Some(pr)) => DeliverySignal::new(
|
||
"open",
|
||
"gitea",
|
||
"当前分支存在开放 PR",
|
||
pull_request_summary(pr),
|
||
),
|
||
(Some(_), None) => DeliverySignal::new(
|
||
"none",
|
||
"gitea",
|
||
"当前分支没有开放 PR",
|
||
serde_json::json!({}),
|
||
),
|
||
};
|
||
|
||
let ci = match commit_sha {
|
||
Some(sha) => {
|
||
let path = format!("/api/v1/repos/{owner}/{repo}/statuses/{sha}?limit=50");
|
||
match gitea_get_json(&base_url, &token, &path, "读取 Gitea CI 状态失败") {
|
||
Ok(statuses) => summarize_ci_statuses(&statuses),
|
||
Err(error) => {
|
||
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
|
||
}
|
||
}
|
||
}
|
||
None => DeliverySignal::new(
|
||
"unknown",
|
||
"gitea",
|
||
"本地 Git 未提供最近 commit SHA,无法查询 CI",
|
||
serde_json::json!({}),
|
||
),
|
||
};
|
||
|
||
let review = match matched_pr.and_then(|pr| pr["number"].as_i64()) {
|
||
Some(number) => {
|
||
let path = format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews");
|
||
match gitea_get_json(&base_url, &token, &path, "读取 Gitea review 状态失败") {
|
||
Ok(reviews) => summarize_reviews(&reviews),
|
||
Err(error) => {
|
||
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
|
||
}
|
||
}
|
||
}
|
||
None if branch.is_some() => DeliverySignal::new(
|
||
"not_applicable",
|
||
"gitea",
|
||
"当前分支没有开放 PR,因此无 review 状态",
|
||
serde_json::json!({}),
|
||
),
|
||
None => DeliverySignal::new(
|
||
"unknown",
|
||
"gitea",
|
||
"无法确定当前分支,未查询 review",
|
||
serde_json::json!({}),
|
||
),
|
||
};
|
||
|
||
GiteaDeliverySignals {
|
||
pull_request,
|
||
ci,
|
||
review,
|
||
}
|
||
}
|
||
|
||
pub fn select_open_pull_request<'a>(
|
||
pull_requests: &'a serde_json::Value,
|
||
branch: &str,
|
||
) -> Option<&'a serde_json::Value> {
|
||
pull_requests.as_array()?.iter().find(|pr| {
|
||
pr["head"]["ref"].as_str() == Some(branch)
|
||
|| pr["head"]["label"]
|
||
.as_str()
|
||
.map(|label| label.ends_with(&format!(":{branch}")) || label == branch)
|
||
.unwrap_or(false)
|
||
})
|
||
}
|
||
|
||
fn pull_request_summary(pr: &serde_json::Value) -> serde_json::Value {
|
||
serde_json::json!({
|
||
"number": pr["number"],
|
||
"title": pr["title"],
|
||
"url": pr["html_url"],
|
||
"head": pr["head"]["ref"],
|
||
"base": pr["base"]["ref"],
|
||
"updated_at": pr["updated_at"],
|
||
})
|
||
}
|
||
|
||
/// 把 Gitea commit status 归并成稳定枚举;保留每个 context,供 Agent 自行判断门禁。
|
||
pub fn summarize_ci_statuses(statuses: &serde_json::Value) -> DeliverySignal {
|
||
let Some(items) = statuses.as_array() else {
|
||
return DeliverySignal::new(
|
||
"unknown",
|
||
"gitea",
|
||
"Gitea CI 响应不是数组",
|
||
serde_json::json!({}),
|
||
);
|
||
};
|
||
if items.is_empty() {
|
||
return DeliverySignal::new(
|
||
"unknown",
|
||
"gitea",
|
||
"当前 commit 暂无 CI 记录",
|
||
serde_json::json!({ "contexts": [] }),
|
||
);
|
||
}
|
||
|
||
let mut latest: std::collections::BTreeMap<String, &serde_json::Value> =
|
||
std::collections::BTreeMap::new();
|
||
for item in items {
|
||
let context = item["context"].as_str().unwrap_or("unknown").to_string();
|
||
// Gitea statuses API 按新到旧返回,同名 context 只保留第一条。
|
||
latest.entry(context).or_insert(item);
|
||
}
|
||
let contexts: Vec<serde_json::Value> = latest
|
||
.iter()
|
||
.map(|(context, item)| {
|
||
let state = item["status"]
|
||
.as_str()
|
||
.or_else(|| item["state"].as_str())
|
||
.unwrap_or("unknown");
|
||
serde_json::json!({
|
||
"context": context,
|
||
"state": state,
|
||
"description": item["description"],
|
||
})
|
||
})
|
||
.collect();
|
||
let states: Vec<String> = contexts
|
||
.iter()
|
||
.filter_map(|item| item["state"].as_str())
|
||
.map(|state| state.to_ascii_lowercase())
|
||
.collect();
|
||
let state = if states
|
||
.iter()
|
||
.any(|state| state == "failure" || state == "error")
|
||
{
|
||
"failed"
|
||
} else if states.iter().all(|state| state == "success") {
|
||
"passed"
|
||
} else {
|
||
"pending"
|
||
};
|
||
let detail = match state {
|
||
"passed" => "所有最新 CI context 均成功",
|
||
"failed" => "至少一个最新 CI context 失败",
|
||
_ => "存在尚未成功的 CI context",
|
||
};
|
||
DeliverySignal::new(
|
||
state,
|
||
"gitea",
|
||
detail,
|
||
serde_json::json!({ "contexts": contexts }),
|
||
)
|
||
}
|
||
|
||
/// 对每位 reviewer 仅保留提交时间最新的状态,避免旧的 approve 掩盖后续 request changes。
|
||
pub fn summarize_reviews(reviews: &serde_json::Value) -> DeliverySignal {
|
||
let Some(items) = reviews.as_array() else {
|
||
return DeliverySignal::new(
|
||
"unknown",
|
||
"gitea",
|
||
"Gitea review 响应不是数组",
|
||
serde_json::json!({}),
|
||
);
|
||
};
|
||
let mut latest: std::collections::BTreeMap<String, &serde_json::Value> =
|
||
std::collections::BTreeMap::new();
|
||
for (index, review) in items.iter().enumerate() {
|
||
let reviewer = review["user"]["login"]
|
||
.as_str()
|
||
.map(str::to_string)
|
||
.unwrap_or_else(|| format!("unknown-{index}"));
|
||
let should_replace = latest
|
||
.get(&reviewer)
|
||
.map(|existing| review_timestamp(review) >= review_timestamp(existing))
|
||
.unwrap_or(true);
|
||
if should_replace {
|
||
latest.insert(reviewer, review);
|
||
}
|
||
}
|
||
let reviewers: Vec<serde_json::Value> = latest
|
||
.iter()
|
||
.map(|(reviewer, review)| {
|
||
serde_json::json!({
|
||
"reviewer": reviewer,
|
||
"state": review["state"],
|
||
"submitted_at": review["submitted_at"],
|
||
})
|
||
})
|
||
.collect();
|
||
let states: Vec<String> = reviewers
|
||
.iter()
|
||
.filter_map(|review| review["state"].as_str())
|
||
.map(|state| state.to_ascii_uppercase())
|
||
.collect();
|
||
let state = if states.iter().any(|state| state == "REQUEST_CHANGES") {
|
||
"changes_requested"
|
||
} else if states.iter().any(|state| state == "APPROVED") {
|
||
"approved"
|
||
} else {
|
||
"pending"
|
||
};
|
||
let detail = match state {
|
||
"changes_requested" => "存在 reviewer 请求修改",
|
||
"approved" => "至少一位 reviewer 已批准,未发现后续请求修改",
|
||
_ => "尚无有效 approve/reject review",
|
||
};
|
||
DeliverySignal::new(
|
||
state,
|
||
"gitea",
|
||
detail,
|
||
serde_json::json!({ "reviewers": reviewers }),
|
||
)
|
||
}
|
||
|
||
fn review_timestamp(review: &serde_json::Value) -> &str {
|
||
review["submitted_at"].as_str().unwrap_or("")
|
||
}
|
||
|
||
/// 写入或更新项目 ↔ 仓库绑定(换绑时重置 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 并返回用户信息
|
||
pub fn gitea_api_get_user(base_url: &str, token: &str) -> Result<GiteaUser, String> {
|
||
let url = format!("{}/api/v1/user", base_url);
|
||
let resp = ureq::get(&url)
|
||
.set("Authorization", &format!("token {token}"))
|
||
.set("Content-Type", "application/json")
|
||
.call()
|
||
.map_err(|e| format!("Gitea 连接失败: {e}"))?;
|
||
|
||
let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
|
||
|
||
let login = json["login"]
|
||
.as_str()
|
||
.ok_or_else(|| "token 无效或权限不足".to_string())?
|
||
.to_string();
|
||
|
||
Ok(GiteaUser {
|
||
login,
|
||
full_name: json["full_name"].as_str().unwrap_or("").to_string(),
|
||
email: json["email"].as_str().unwrap_or("").to_string(),
|
||
})
|
||
}
|
||
|
||
/// GET /api/v1/repos/:owner/:repo — 获取仓库信息(G2 仓库绑定时使用)
|
||
pub fn gitea_api_get_repo(
|
||
base_url: &str,
|
||
token: &str,
|
||
owner: &str,
|
||
repo: &str,
|
||
) -> Result<serde_json::Value, String> {
|
||
let url = format!("{}/api/v1/repos/{owner}/{repo}", base_url);
|
||
let resp = ureq::get(&url)
|
||
.set("Authorization", &format!("token {token}"))
|
||
.call()
|
||
.map_err(|e| api_err("获取仓库信息失败", 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", "pull_request"],
|
||
"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(())
|
||
}
|
||
|
||
// ── 远端轮询采集(G6:webhook 不可达时的降级路径)────────────────────────────
|
||
|
||
/// 从 Gitea commits API 响应解析出 (sha, message, date) 列表(纯函数,可测)
|
||
pub fn parse_gitea_commits(json: &serde_json::Value) -> Vec<(String, String, Option<String>)> {
|
||
json.as_array()
|
||
.map(|arr| {
|
||
arr.iter()
|
||
.filter_map(|c| {
|
||
let sha = c["sha"].as_str()?.to_string();
|
||
let message = c["commit"]["message"].as_str().unwrap_or("").to_string();
|
||
let date = c["commit"]["committer"]["date"]
|
||
.as_str()
|
||
.or_else(|| c["created"].as_str())
|
||
.map(|s| s.to_string());
|
||
Some((sha, message, date))
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// 轮询所有绑定仓库的最新 commit,幂等写入 commit_metrics。
|
||
/// 返回 (轮询仓库数, 新写入 commit 数)。纯出站 HTTPS,适配公网 Gitea + 内网炼境拓扑。
|
||
pub fn poll_all_repos() -> Result<(usize, usize), String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
let repos: Vec<(String, String, String, String, String)> =
|
||
{
|
||
let mut stmt = conn.prepare(
|
||
"SELECT gr.project_id, gr.gitea_owner, gr.gitea_repo_name, gi.url, gi.access_token
|
||
FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id",
|
||
).map_err(|e| e.to_string())?;
|
||
let rows = stmt
|
||
.query_map([], |r| {
|
||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
|
||
})
|
||
.map_err(|e| e.to_string())?;
|
||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||
.map_err(|e| e.to_string())?
|
||
};
|
||
|
||
let mut ingested = 0usize;
|
||
let mut polled = 0usize;
|
||
for (project_id, owner, repo, base_url, token) in &repos {
|
||
let url = format!(
|
||
"{base_url}/api/v1/repos/{owner}/{repo}/commits?limit=50&stat=false&verification=false"
|
||
);
|
||
let resp = match ureq::get(&url)
|
||
.set("Authorization", &format!("token {token}"))
|
||
.call()
|
||
{
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
crate::db::log_event(
|
||
"gitea_poll",
|
||
"warn",
|
||
&format!("轮询 {owner}/{repo} 失败: {}", api_err("拉取 commits", e)),
|
||
None,
|
||
);
|
||
continue;
|
||
}
|
||
};
|
||
let json: serde_json::Value = match resp.into_json() {
|
||
Ok(j) => j,
|
||
Err(_) => continue,
|
||
};
|
||
polled += 1;
|
||
// U3:按 project_id 加载规则 ID 集合(每仓库一次),剔除 trailer 里的未知 ID
|
||
let valid_rules =
|
||
crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id);
|
||
for (sha, message, date) in parse_gitea_commits(&json) {
|
||
let before: i64 = conn
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM commit_metrics WHERE sha = ?1",
|
||
params![sha],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap_or(0);
|
||
if before == 0
|
||
&& crate::commands::commit_metrics::record_commit_validated(
|
||
&conn,
|
||
project_id,
|
||
&sha,
|
||
&message,
|
||
date.as_deref(),
|
||
valid_rules.as_ref(),
|
||
)
|
||
.is_ok()
|
||
{
|
||
ingested += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ingested > 0 {
|
||
crate::db::log_event(
|
||
"gitea_poll",
|
||
"info",
|
||
&format!("轮询 {polled} 个仓库,新采集 {ingested} 条 commit"),
|
||
None,
|
||
);
|
||
}
|
||
|
||
// PR 状态轮询:弥补公网 Gitea + 内网炼境时 webhook 不可达的场景
|
||
let mut pr_ingested = 0usize;
|
||
for (project_id, owner, repo, base_url, token) in &repos {
|
||
pr_ingested += poll_pr_events(&conn, project_id, owner, repo, base_url, token);
|
||
}
|
||
if pr_ingested > 0 {
|
||
crate::db::log_event(
|
||
"gitea_poll",
|
||
"info",
|
||
&format!("PR 轮询新采集 {pr_ingested} 条事件"),
|
||
None,
|
||
);
|
||
}
|
||
|
||
// staleness 自知:至少成功拉到一个仓库才算一次有效同步
|
||
if polled > 0 {
|
||
crate::db::touch_sync_state(&conn, "flywheel");
|
||
}
|
||
|
||
Ok((polled, ingested))
|
||
}
|
||
|
||
/// 轮询单个仓库的 PR 状态,将未记录的事件写入 pr_events,返回新增条数
|
||
fn poll_pr_events(
|
||
conn: &rusqlite::Connection,
|
||
project_id: &str,
|
||
owner: &str,
|
||
repo: &str,
|
||
base_url: &str,
|
||
token: &str,
|
||
) -> usize {
|
||
let mut count = 0;
|
||
for state in ["open", "closed"] {
|
||
let url = format!(
|
||
"{base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}&limit=50&type=pullrequest"
|
||
);
|
||
let resp = match ureq::get(&url)
|
||
.set("Authorization", &format!("token {token}"))
|
||
.call()
|
||
{
|
||
Ok(r) => r,
|
||
Err(_) => continue,
|
||
};
|
||
let prs: serde_json::Value = match resp.into_json() {
|
||
Ok(j) => j,
|
||
Err(_) => continue,
|
||
};
|
||
let prs = match prs.as_array() {
|
||
Some(a) => a,
|
||
None => continue,
|
||
};
|
||
for pr in prs {
|
||
let pr_number = match pr["number"].as_i64() {
|
||
Some(n) => n,
|
||
None => continue,
|
||
};
|
||
let title = pr["title"].as_str().unwrap_or("").to_string();
|
||
let head_branch = pr["head"]["ref"].as_str().unwrap_or("").to_string();
|
||
let base_branch = pr["base"]["ref"].as_str().unwrap_or("").to_string();
|
||
let author = pr["user"]["login"].as_str().unwrap_or("").to_string();
|
||
let merged = pr["merged"].as_bool().unwrap_or(false);
|
||
let pr_state = pr["state"].as_str().unwrap_or("open");
|
||
|
||
// 确定动作:已合并 > 关闭 > 开启
|
||
let action = if merged {
|
||
"merged"
|
||
} else if pr_state == "closed" {
|
||
"closed"
|
||
} else {
|
||
"opened"
|
||
};
|
||
|
||
let existing: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM pr_events WHERE project_id = ?1 AND pr_number = ?2 AND action = ?3",
|
||
params![project_id, pr_number, action],
|
||
|r| r.get(0),
|
||
).unwrap_or(0);
|
||
|
||
if existing == 0 {
|
||
let id = uuid::Uuid::new_v4().to_string();
|
||
if conn.execute(
|
||
"INSERT INTO pr_events (id, project_id, pr_number, title, action, head_branch, base_branch, author)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||
params![id, project_id, pr_number, title, action, head_branch, base_branch, author],
|
||
).is_ok() {
|
||
count += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
count
|
||
}
|
||
|
||
/// 手动触发一次轮询(UI「立即拉取」按钮)
|
||
#[tauri::command]
|
||
pub fn gitea_poll_now() -> Result<serde_json::Value, String> {
|
||
let (polled, ingested) = poll_all_repos()?;
|
||
Ok(serde_json::json!({ "polled": polled, "ingested": ingested }))
|
||
}
|
||
|
||
// ── 仓库绑定查询命令 ──────────────────────────────────────────────────────────
|
||
|
||
/// 查询所有已绑定的仓库(含实例名)
|
||
#[tauri::command]
|
||
pub fn gitea_list_linked_repos() -> Result<Vec<serde_json::Value>, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT gr.id, gr.instance_id, gi.name as instance_name, gi.url as instance_url,
|
||
gr.project_id, gr.gitea_owner, gr.gitea_repo_name,
|
||
gr.clone_url, gr.webhook_secret, gr.webhook_id, gr.linked_at
|
||
FROM gitea_repos gr
|
||
JOIN gitea_instances gi ON gi.id = gr.instance_id
|
||
ORDER BY gr.linked_at DESC",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let rows = stmt
|
||
.query_map([], |row| {
|
||
Ok(serde_json::json!({
|
||
"id": row.get::<_, String>(0)?,
|
||
"instance_id": row.get::<_, String>(1)?,
|
||
"instance_name": row.get::<_, String>(2)?,
|
||
"instance_url": row.get::<_, String>(3)?,
|
||
"project_id": row.get::<_, String>(4)?,
|
||
"gitea_owner": row.get::<_, String>(5)?,
|
||
"gitea_repo_name": row.get::<_, String>(6)?,
|
||
"clone_url": row.get::<_, Option<String>>(7)?,
|
||
"has_webhook": row.get::<_, Option<String>>(8)?.is_some(),
|
||
"webhook_id": row.get::<_, Option<i64>>(9)?,
|
||
"linked_at": row.get::<_, Option<String>>(10)?,
|
||
}))
|
||
})
|
||
.map_err(|e| e.to_string())?
|
||
.collect::<rusqlite::Result<Vec<_>>>()
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
Ok(rows)
|
||
}
|
||
|
||
/// 通过炼境存储的凭证为项目创建 PR,返回 JSON 字符串(含 pr_url / pr_number / already_exists)。
|
||
/// 409 冲突(PR 已存在)时自动查找并返回现有 PR,already_exists=true。
|
||
pub fn gitea_api_create_pull_request(
|
||
project_id: &str,
|
||
head: &str,
|
||
base: &str,
|
||
title: &str,
|
||
body: &str,
|
||
) -> Result<String, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
let (instance_id, owner, repo_name): (String, String, String) = conn.query_row(
|
||
"SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1",
|
||
params![project_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
).map_err(|_| format!("项目 {} 未绑定 Gitea 仓库", project_id))?;
|
||
|
||
let (base_url, token) = instance_auth(&conn, &instance_id)?;
|
||
|
||
let create_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls");
|
||
let (json, already_exists) = match ureq::post(&create_url)
|
||
.set("Authorization", &format!("token {token}"))
|
||
.set("Content-Type", "application/json")
|
||
.send_json(serde_json::json!({ "head": head, "base": base, "title": title, "body": body }))
|
||
{
|
||
Ok(resp) => (
|
||
resp.into_json::<serde_json::Value>()
|
||
.map_err(|e| format!("解析响应失败: {e}"))?,
|
||
false,
|
||
),
|
||
Err(ureq::Error::Status(409, _)) => {
|
||
// PR 已存在,列出开放 PR 找到匹配的分支
|
||
let list_url =
|
||
format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls?state=open&limit=30");
|
||
let prs: serde_json::Value = ureq::get(&list_url)
|
||
.set("Authorization", &format!("token {token}"))
|
||
.call()
|
||
.map_err(|e| api_err("获取已有 PR 列表失败", e))?
|
||
.into_json()
|
||
.map_err(|e| format!("解析 PR 列表失败: {e}"))?;
|
||
let arr = prs.as_array().ok_or("PR 列表格式错误")?;
|
||
let existing = arr
|
||
.iter()
|
||
.find(|pr| {
|
||
pr["head"]["ref"].as_str() == Some(head)
|
||
|| pr["head"]["label"]
|
||
.as_str()
|
||
.map(|l| l.ends_with(head))
|
||
.unwrap_or(false)
|
||
})
|
||
.cloned()
|
||
.ok_or_else(|| format!("PR 已存在但找不到(head={head})"))?;
|
||
(existing, true)
|
||
}
|
||
Err(e) => return Err(api_err("创建 PR 失败", e)),
|
||
};
|
||
|
||
let pr_number = json["number"].as_u64().unwrap_or(0);
|
||
let pr_url = json["html_url"].as_str().unwrap_or("").to_string();
|
||
let pr_title = json["title"].as_str().unwrap_or(title).to_string();
|
||
|
||
serde_json::to_string(&serde_json::json!({
|
||
"pr_number": pr_number,
|
||
"pr_url": pr_url,
|
||
"head": head,
|
||
"base": base,
|
||
"title": pr_title,
|
||
"already_exists": already_exists,
|
||
}))
|
||
.map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// 查询单个项目的 Gitea 绑定
|
||
#[tauri::command]
|
||
pub fn gitea_get_repo_link(project_id: String) -> Result<Option<GiteaRepo>, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
match conn.query_row(
|
||
"SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name,
|
||
clone_url, webhook_secret, webhook_id, linked_at
|
||
FROM gitea_repos WHERE project_id = ?1",
|
||
params![project_id],
|
||
row_to_repo,
|
||
) {
|
||
Ok(r) => Ok(Some(r)),
|
||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||
Err(e) => Err(e.to_string()),
|
||
}
|
||
}
|
||
|
||
// ── MCP 工具层:CI 状态查询 + PR 合并 ────────────────────────────────────────
|
||
|
||
/// 查询某 commit 的所有 CI 检查状态,返回 Markdown 格式摘要。
|
||
/// 调用 GET /api/v1/repos/{owner}/{repo}/statuses/{sha}
|
||
pub fn gitea_api_get_ci_status(project_id: &str, commit_sha: &str) -> Result<String, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
let (instance_id, owner, repo_name): (String, String, String) = conn.query_row(
|
||
"SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1",
|
||
params![project_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
).map_err(|_| format!("项目 {} 未绑定 Gitea 仓库", project_id))?;
|
||
|
||
let (base_url, token) = instance_auth(&conn, &instance_id)?;
|
||
|
||
let url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/statuses/{commit_sha}?limit=50");
|
||
let resp = ureq::get(&url)
|
||
.set("Authorization", &format!("token {token}"))
|
||
.call()
|
||
.map_err(|e| api_err("查询 CI 状态失败", e))?;
|
||
|
||
let statuses: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
|
||
let arr = statuses
|
||
.as_array()
|
||
.ok_or_else(|| "响应格式错误".to_string())?;
|
||
|
||
let short_sha = &commit_sha[..8.min(commit_sha.len())];
|
||
if arr.is_empty() {
|
||
return Ok(format!(
|
||
"commit `{}` 暂无 CI 记录(CI 可能尚未触发,或 commit 不存在)",
|
||
short_sha
|
||
));
|
||
}
|
||
|
||
// 同名 context 只取最新一条
|
||
let mut latest: std::collections::HashMap<String, &serde_json::Value> =
|
||
std::collections::HashMap::new();
|
||
for s in arr {
|
||
let ctx = s["context"].as_str().unwrap_or("unknown").to_string();
|
||
latest.entry(ctx).or_insert(s);
|
||
}
|
||
|
||
let mut lines = vec![format!("## CI 状态:commit `{}`\n", short_sha)];
|
||
let mut all_pass = true;
|
||
let mut has_failure = false;
|
||
for (ctx, s) in &latest {
|
||
// Gitea 1.22 Actions 使用 "status" 字段;传统 commit status API 用 "state"
|
||
let state = s["status"]
|
||
.as_str()
|
||
.or_else(|| s["state"].as_str())
|
||
.unwrap_or("unknown");
|
||
let desc = s["description"].as_str().unwrap_or("");
|
||
let icon = match state {
|
||
"success" => "✅",
|
||
"failure" | "error" => {
|
||
all_pass = false;
|
||
has_failure = true;
|
||
"❌"
|
||
}
|
||
_ => {
|
||
all_pass = false;
|
||
"⏳"
|
||
}
|
||
};
|
||
lines.push(format!("- {} `{}` — {} {}", icon, ctx, state, desc));
|
||
}
|
||
let conclusion = if all_pass {
|
||
"✅ 全部通过,可以合并"
|
||
} else if has_failure {
|
||
"❌ 存在失败检查,暂不建议合并"
|
||
} else {
|
||
"⏳ 检查进行中,请稍后再查"
|
||
};
|
||
lines.push(format!("\n**结论:{}**", conclusion));
|
||
|
||
Ok(lines.join("\n"))
|
||
}
|
||
|
||
/// 合并指定 PR。调用 POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge
|
||
pub fn gitea_api_merge_pull_request(project_id: &str, pr_number: i64) -> Result<String, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
let (instance_id, owner, repo_name): (String, String, String) = conn.query_row(
|
||
"SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1",
|
||
params![project_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
).map_err(|_| format!("项目 {} 未绑定 Gitea 仓库", project_id))?;
|
||
|
||
let (base_url, token) = instance_auth(&conn, &instance_id)?;
|
||
|
||
let url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}/merge");
|
||
ureq::post(&url)
|
||
.set("Authorization", &format!("token {token}"))
|
||
.set("Content-Type", "application/json")
|
||
.send_json(serde_json::json!({
|
||
"Do": "merge",
|
||
"merge_title_field": format!("Merge PR #{}", pr_number),
|
||
"merge_message_field": "",
|
||
}))
|
||
.map_err(|e| api_err("合并 PR 失败", e))?;
|
||
|
||
Ok(format!("PR #{pr_number} 已成功合并({owner}/{repo_name})"))
|
||
}
|
||
|
||
// ── MCP 工具:一键建仓库 + 配置 remote ───────────────────────────────────────
|
||
|
||
/// 为项目在默认 Gitea 实例上创建远程仓库,并配置本地 git remote origin。
|
||
/// 幂等:仓库已存在(409)则直接绑定;remote 已配置则 set-url 更新。
|
||
pub fn gitea_api_setup_repo(
|
||
project_id: &str,
|
||
repo_name: Option<&str>,
|
||
private: bool,
|
||
) -> Result<String, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
// 项目基本信息
|
||
let (project_name, win_path): (String, Option<String>) = conn
|
||
.query_row(
|
||
"SELECT pp.name, pw.win_path
|
||
FROM project_workspaces pw
|
||
JOIN project_profiles pp ON pp.id = pw.profile_id
|
||
WHERE pw.id = ?1",
|
||
params![project_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||
)
|
||
.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||
|
||
// 默认 Gitea 实例
|
||
let (instance_id, base_url, token): (String, String, String) = conn
|
||
.query_row(
|
||
"SELECT id, url, access_token FROM gitea_instances WHERE is_default = 1 LIMIT 1",
|
||
[],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
)
|
||
.map_err(|_| "未配置默认 Gitea 实例,请先在炼境 UI 中添加并设为默认".to_string())?;
|
||
|
||
// 仓库名 slug 化(保留 ASCII 字母数字 + - _ .);空字符串等同未传
|
||
let final_repo_name = repo_name
|
||
.filter(|s| !s.trim().is_empty())
|
||
.map(|s| s.to_string())
|
||
.unwrap_or_else(|| {
|
||
let slug: String = project_name
|
||
.to_ascii_lowercase()
|
||
.replace(' ', "-")
|
||
.chars()
|
||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
|
||
.collect();
|
||
if slug.is_empty() {
|
||
format!("project-{}", &project_id[..8.min(project_id.len())])
|
||
} else {
|
||
slug
|
||
}
|
||
});
|
||
|
||
// 仓库拥有者(从 token 获取登录名)
|
||
let owner = gitea_api_get_user(&base_url, &token)?.login;
|
||
|
||
// 创建仓库,409=已存在时直接绑定
|
||
let (clone_url, already_existed) = match ureq::post(&format!("{base_url}/api/v1/user/repos"))
|
||
.set("Authorization", &format!("token {token}"))
|
||
.send_json(serde_json::json!({
|
||
"name": final_repo_name,
|
||
"description": format!("{} — 由炼境自动创建", project_name),
|
||
"private": private,
|
||
"auto_init": false,
|
||
})) {
|
||
Ok(resp) => {
|
||
let json: serde_json::Value =
|
||
resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
|
||
let url = json["clone_url"]
|
||
.as_str()
|
||
.ok_or("响应缺少 clone_url")?
|
||
.to_string();
|
||
(url, false)
|
||
}
|
||
Err(ureq::Error::Status(409, _)) => {
|
||
let json = gitea_api_get_repo(&base_url, &token, &owner, &final_repo_name)?;
|
||
let url = json["clone_url"]
|
||
.as_str()
|
||
.ok_or("响应缺少 clone_url")?
|
||
.to_string();
|
||
(url, true)
|
||
}
|
||
Err(e) => return Err(api_err("创建仓库失败", e)),
|
||
};
|
||
|
||
// 写入炼境 DB 绑定关系
|
||
upsert_repo_link(
|
||
&conn,
|
||
&instance_id,
|
||
project_id,
|
||
&owner,
|
||
&final_repo_name,
|
||
Some(&clone_url),
|
||
)?;
|
||
|
||
// 配置本地 git remote origin
|
||
let remote_status = configure_git_remote(win_path.as_deref(), &clone_url);
|
||
|
||
let existed_tag = if already_existed {
|
||
"已存在,直接绑定"
|
||
} else {
|
||
"新建"
|
||
};
|
||
let remote_tag = match &remote_status {
|
||
Ok(action) => format!("✅ {action}"),
|
||
Err(e) => format!("⚠️ {e}"),
|
||
};
|
||
let mut out = format!(
|
||
"Gitea 仓库已就绪:{base_url}/{owner}/{final_repo_name}\n\n\
|
||
- 仓库:{owner}/{final_repo_name}({existed_tag})\n\
|
||
- 炼境绑定:✅ project_id 已关联\n\
|
||
- git remote origin:{remote_tag}\n"
|
||
);
|
||
if remote_status.is_ok() {
|
||
out.push_str(
|
||
"\n建议下一步:git push -u origin main,然后调用 setup_webhook 注册 webhook。",
|
||
);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// 配置本地仓库的 git remote origin(add 或 set-url,幂等)
|
||
fn configure_git_remote(win_path: Option<&str>, clone_url: &str) -> Result<String, String> {
|
||
let path = win_path.ok_or_else(|| "项目无本地路径".to_string())?;
|
||
if !std::path::Path::new(path).join(".git").exists() {
|
||
return Err("本地目录无 .git,跳过 remote 配置".to_string());
|
||
}
|
||
|
||
let has_origin = std::process::Command::new("git")
|
||
.args(["-C", path, "remote", "get-url", "origin"])
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false);
|
||
|
||
let out = if has_origin {
|
||
std::process::Command::new("git")
|
||
.args(["-C", path, "remote", "set-url", "origin", clone_url])
|
||
.output()
|
||
} else {
|
||
std::process::Command::new("git")
|
||
.args(["-C", path, "remote", "add", "origin", clone_url])
|
||
.output()
|
||
}
|
||
.map_err(|e| format!("执行 git remote 失败: {e}"))?;
|
||
|
||
if out.status.success() {
|
||
Ok(if has_origin {
|
||
"origin set-url 更新成功".to_string()
|
||
} else {
|
||
"origin 已添加".to_string()
|
||
})
|
||
} else {
|
||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn normalize_url_strips_trailing_slash() {
|
||
assert_eq!(
|
||
normalize_url("https://gitea.example.com/"),
|
||
"https://gitea.example.com"
|
||
);
|
||
assert_eq!(
|
||
normalize_url("https://gitea.example.com"),
|
||
"https://gitea.example.com"
|
||
);
|
||
assert_eq!(
|
||
normalize_url("https://gitea.example.com///"),
|
||
"https://gitea.example.com"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gitea_tables_exist_after_migrate() {
|
||
let conn = crate::db::conn_with_schema();
|
||
let count: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gitea_instances','gitea_repos')",
|
||
[],
|
||
|r| r.get(0),
|
||
).unwrap();
|
||
assert_eq!(count, 2, "gitea_instances 或 gitea_repos 表未建");
|
||
}
|
||
|
||
#[test]
|
||
fn save_and_list_instance() {
|
||
let conn = crate::db::conn_with_schema();
|
||
conn.execute(
|
||
"INSERT INTO gitea_instances (id, name, url, access_token, is_default)
|
||
VALUES ('inst-1', '测试实例', 'https://gitea.test', 'token123', 0)",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
|
||
let count: i64 = conn
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM gitea_instances WHERE id = 'inst-1'",
|
||
[],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn 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();
|
||
conn.execute(
|
||
"INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1', 'a', 'u', 't')",
|
||
[],
|
||
).unwrap();
|
||
conn.execute(
|
||
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name)
|
||
VALUES ('r1', 'i1', 'proj-uuid-1', 'owner', 'repo')",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
// 同一 project_id 第二次应失败
|
||
let result = conn.execute(
|
||
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name)
|
||
VALUES ('r2', 'i1', 'proj-uuid-1', 'owner', 'repo2')",
|
||
[],
|
||
);
|
||
assert!(result.is_err(), "project_id 唯一索引未生效");
|
||
}
|
||
|
||
// ── G6:轮询解析 ──────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn parse_gitea_commits_extracts_fields() {
|
||
let json = serde_json::json!([
|
||
{"sha": "abc", "commit": {"message": "feat(x): a", "committer": {"date": "2026-07-02T10:00:00+08:00"}}},
|
||
{"sha": "def", "commit": {"message": "fix: b", "committer": {}}, "created": "2026-07-01T09:00:00+08:00"}
|
||
]);
|
||
let commits = parse_gitea_commits(&json);
|
||
assert_eq!(commits.len(), 2);
|
||
assert_eq!(commits[0].0, "abc");
|
||
assert_eq!(commits[0].2.as_deref(), Some("2026-07-02T10:00:00+08:00"));
|
||
assert_eq!(
|
||
commits[1].2.as_deref(),
|
||
Some("2026-07-01T09:00:00+08:00"),
|
||
"committer.date 缺失时回退 created"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_gitea_commits_tolerates_garbage() {
|
||
assert!(parse_gitea_commits(&serde_json::json!({"not": "array"})).is_empty());
|
||
assert!(parse_gitea_commits(&serde_json::json!([{"no_sha": true}])).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn delivery_ci_summary_uses_latest_context_and_preserves_failure() {
|
||
let statuses = serde_json::json!([
|
||
{"context": "test", "status": "success", "description": "green"},
|
||
{"context": "test", "status": "failure", "description": "old failure"},
|
||
{"context": "lint", "state": "failure", "description": "bad"}
|
||
]);
|
||
let summary = summarize_ci_statuses(&statuses);
|
||
assert_eq!(summary.state, "failed");
|
||
assert_eq!(summary.data["contexts"].as_array().unwrap().len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn delivery_review_summary_prefers_latest_reviewer_decision() {
|
||
let reviews = serde_json::json!([
|
||
{"user": {"login": "alice"}, "state": "REQUEST_CHANGES", "submitted_at": "2026-09-21T10:00:00Z"},
|
||
{"user": {"login": "alice"}, "state": "APPROVED", "submitted_at": "2026-09-21T09:00:00Z"},
|
||
{"user": {"login": "bob"}, "state": "APPROVED", "submitted_at": "2026-09-21T10:01:00Z"}
|
||
]);
|
||
let summary = summarize_reviews(&reviews);
|
||
assert_eq!(summary.state, "changes_requested");
|
||
assert_eq!(summary.data["reviewers"].as_array().unwrap().len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn delivery_pr_selection_matches_current_branch_only() {
|
||
let pulls = serde_json::json!([
|
||
{"number": 2, "head": {"ref": "feat/other"}},
|
||
{"number": 3, "head": {"ref": "feat/delivery"}}
|
||
]);
|
||
assert_eq!(
|
||
select_open_pull_request(&pulls, "feat/delivery").expect("应匹配当前分支")["number"]
|
||
.as_i64(),
|
||
Some(3)
|
||
);
|
||
assert!(select_open_pull_request(&pulls, "main").is_none());
|
||
}
|
||
}
|