dev-manager-tauri/src-tauri/src/commands/gitea.rs
lanrtop 28e65b3d1c feat(gitea): 增加 Agent PR 安全合并门禁
合并前重新校验 PR、head SHA、CI 与 review,并用 Gitea head_commit_id 在服务端绑定用户确认的提交。同步 MCP schema、接入模板与 Codex skill 契约。

Feature-Confirmed: true

Rules-Applied: R06,R07,R10,R12
2026-09-22 21:07:55 +09:00

2117 lines
76 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! Gitea 集成——控制面:实例管理 + 仓库操作 + webhook 注册。
//!
//! 观测面(webhook 接收端)在 mcp_server.rs 实现。
use rusqlite::params;
use serde::{Deserialize, Serialize};
// ── 数据结构 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GiteaInstance {
pub id: String,
pub name: String,
pub url: String,
pub access_token: String,
pub is_default: bool,
pub created_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SaveGiteaInstanceInput {
pub name: String,
pub url: String,
pub access_token: String,
pub is_default: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GiteaRepo {
pub id: String,
pub instance_id: String,
pub project_id: String,
pub gitea_owner: String,
pub gitea_repo_name: String,
pub clone_url: Option<String>,
pub webhook_secret: Option<String>,
pub webhook_id: Option<i64>,
pub linked_at: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GiteaUser {
pub login: String,
pub full_name: String,
pub email: String,
}
// ── 辅助函数 ──────────────────────────────────────────────────────────────────
fn row_to_instance(row: &rusqlite::Row<'_>) -> rusqlite::Result<GiteaInstance> {
Ok(GiteaInstance {
id: row.get(0)?,
name: row.get(1)?,
url: row.get(2)?,
access_token: row.get(3)?,
is_default: row.get::<_, i64>(4)? != 0,
created_at: row.get(5)?,
})
}
fn row_to_repo(row: &rusqlite::Row<'_>) -> rusqlite::Result<GiteaRepo> {
Ok(GiteaRepo {
id: row.get(0)?,
instance_id: row.get(1)?,
project_id: row.get(2)?,
gitea_owner: row.get(3)?,
gitea_repo_name: row.get(4)?,
clone_url: row.get(5)?,
webhook_secret: row.get(6)?,
webhook_id: row.get(7)?,
linked_at: row.get(8)?,
})
}
/// 规范化 Gitea URL:去掉末尾 /
fn normalize_url(url: &str) -> String {
url.trim_end_matches('/').to_string()
}
// ── 实例管理命令 ──────────────────────────────────────────────────────────────
/// 保存 Gitea 实例(新增或覆盖同名实例)
#[tauri::command]
pub fn gitea_save_instance(input: SaveGiteaInstanceInput) -> Result<GiteaInstance, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
let url = normalize_url(&input.url);
let is_default = input.is_default.unwrap_or(false) as i64;
// 若设为默认,先取消其他默认
if is_default != 0 {
let _ = conn.execute("UPDATE gitea_instances SET is_default = 0", []);
}
conn.execute(
"INSERT INTO gitea_instances (id, name, url, access_token, is_default)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, input.name, url, input.access_token, is_default],
)
.map_err(|e| e.to_string())?;
conn.query_row(
"SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances WHERE id = ?1",
params![id],
row_to_instance,
).map_err(|e| e.to_string())
}
/// 列出所有 Gitea 实例
#[tauri::command]
pub fn gitea_list_instances() -> Result<Vec<GiteaInstance>, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances ORDER BY is_default DESC, created_at ASC"
).map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], row_to_instance)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
/// 删除 Gitea 实例(级联删除 gitea_repos)
#[tauri::command]
pub fn gitea_delete_instance(id: String) -> Result<(), String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM gitea_instances WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// 更新 Gitea 实例(仅更新非空字段)
#[tauri::command]
pub fn gitea_update_instance(
id: String,
name: Option<String>,
url: Option<String>,
access_token: Option<String>,
is_default: Option<bool>,
) -> Result<GiteaInstance, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
if let Some(ref _n) = name {
conn.execute(
"UPDATE gitea_instances SET name = ?1 WHERE id = ?2",
params![_n, id],
)
.map_err(|e| e.to_string())?;
}
if let Some(ref u) = url {
let u = normalize_url(u);
conn.execute(
"UPDATE gitea_instances SET url = ?1 WHERE id = ?2",
params![u, id],
)
.map_err(|e| e.to_string())?;
}
if let Some(ref t) = access_token {
conn.execute(
"UPDATE gitea_instances SET access_token = ?1 WHERE id = ?2",
params![t, id],
)
.map_err(|e| e.to_string())?;
}
if let Some(def) = is_default {
if def {
let _ = conn.execute("UPDATE gitea_instances SET is_default = 0", []);
}
conn.execute(
"UPDATE gitea_instances SET is_default = ?1 WHERE id = ?2",
params![def as i64, id],
)
.map_err(|e| e.to_string())?;
}
conn.query_row(
"SELECT id, name, url, access_token, is_default, created_at FROM gitea_instances WHERE id = ?1",
params![id],
row_to_instance,
).map_err(|e| e.to_string())
}
/// 测试 Gitea 连接:调 GET /api/v1/user,返回用户信息验证 token 有效性
#[tauri::command]
pub fn gitea_test_connection(instance_id: String) -> Result<GiteaUser, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let (url, token): (String, String) = conn
.query_row(
"SELECT url, access_token FROM gitea_instances WHERE id = ?1",
params![instance_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.map_err(|_| "实例不存在".to_string())?;
gitea_api_get_user(&url, &token)
}
// ── Gitea API 辅助 ────────────────────────────────────────────────────────────
/// 读取实例的 (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}?sort=recentupdate&limit=50");
match gitea_get_json(&base_url, &token, &path, "读取 Gitea CI 状态失败") {
Ok(statuses) => summarize_ci_statuses(&statuses, Some(sha)),
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"],
})
}
fn status_timestamp(status: &serde_json::Value) -> Option<chrono::DateTime<chrono::FixedOffset>> {
status["updated_at"]
.as_str()
.or_else(|| status["created_at"].as_str())
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
}
fn normalized_status_state(status: &serde_json::Value) -> String {
let raw_state = status["status"]
.as_str()
.or_else(|| status["state"].as_str())
.unwrap_or("unknown")
.to_ascii_lowercase();
let description = status["description"]
.as_str()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
// Gitea 1.22 Actions 会把条件未命中的 job 记录为 status=success,
// 仅在 description 中标明 "Has been skipped"。部署信号必须还原成 skipped,
// 否则会把“未部署”误报为“部署成功”。
if raw_state == "skipped" || matches!(description.as_str(), "has been skipped" | "skipped") {
"skipped".to_string()
} else {
raw_state
}
}
fn latest_statuses_by_context<'a>(
items: &'a [serde_json::Value],
) -> std::collections::BTreeMap<String, &'a serde_json::Value> {
let mut latest: std::collections::BTreeMap<String, &'a serde_json::Value> =
std::collections::BTreeMap::new();
for item in items {
let context = item["context"].as_str().unwrap_or("unknown").to_string();
let should_replace = latest
.get(&context)
.map(
|existing| match (status_timestamp(item), status_timestamp(existing)) {
(Some(candidate), Some(current)) => candidate > current,
(Some(_), None) => true,
_ => false,
},
)
.unwrap_or(true);
if should_replace {
latest.insert(context, item);
}
}
latest
}
fn status_context_summary(context: &str, item: &serde_json::Value) -> serde_json::Value {
let state = normalized_status_state(item);
serde_json::json!({
"context": context,
"state": state,
"description": item["description"],
"target_url": item["target_url"],
"created_at": item["created_at"],
"updated_at": item["updated_at"],
})
}
/// 把 Gitea commit status 归并成稳定枚举;保留每个 context 的可追溯字段,供 Agent 自行判断门禁。
pub fn summarize_ci_statuses(
statuses: &serde_json::Value,
target_sha: Option<&str>,
) -> 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!({ "target_sha": target_sha, "contexts": [] }),
);
}
let latest = latest_statuses_by_context(items);
let contexts: Vec<serde_json::Value> = latest
.iter()
.map(|(context, item)| status_context_summary(context, item))
.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 == "skipped") {
"not_applicable"
} else if states
.iter()
.all(|state| state == "success" || state == "skipped")
{
"passed"
} else {
"pending"
};
let detail = match state {
"passed" => "所有最新 CI context 均成功",
"failed" => "至少一个最新 CI context 失败",
"not_applicable" => "所有最新 CI context 均未执行",
_ => "存在尚未成功的 CI context",
};
DeliverySignal::new(
state,
"gitea",
detail,
serde_json::json!({ "target_sha": target_sha, "contexts": contexts }),
)
}
/// 从同一批 commit statuses 中按项目声明的精确 context 提取部署过程信号。
///
/// 本函数不猜测 job 名,也不把普通 CI 成功当作部署成功;context 的所有权属于项目 profile。
/// P3-D 接入双证据聚合前先由 P3-A 固化并验证纯函数契约。
#[allow(dead_code)]
pub fn summarize_status_context(
statuses: &serde_json::Value,
target_sha: &str,
expected_context: &str,
) -> DeliverySignal {
let Some(items) = statuses.as_array() else {
return DeliverySignal::new(
"unknown",
"gitea",
"Gitea status 响应不是数组",
serde_json::json!({ "target_sha": target_sha, "expected_context": expected_context }),
);
};
let latest = latest_statuses_by_context(items);
let available_contexts: Vec<&str> = latest.keys().map(String::as_str).collect();
let Some(status) = latest.get(expected_context) else {
return DeliverySignal::new(
"unknown",
"gitea",
"目标 deployment context 尚无状态",
serde_json::json!({
"target_sha": target_sha,
"expected_context": expected_context,
"available_contexts": available_contexts,
}),
);
};
let raw_state = normalized_status_state(status);
let state = match raw_state.as_str() {
"success" => "success",
"failure" | "error" => "failure",
"pending" => "pending",
"warning" => "warning",
"skipped" => "skipped",
_ => "unknown",
};
let detail = match state {
"success" => "目标 deployment context 已成功",
"failure" => "目标 deployment context 已失败",
"pending" => "目标 deployment context 尚未结束",
"warning" => "目标 deployment context 带警告结束",
"skipped" => "目标 deployment context 未执行",
_ => "目标 deployment context 返回未知状态",
};
DeliverySignal::new(
state,
"gitea",
detail,
serde_json::json!({
"target_sha": target_sha,
"status": status_context_summary(expected_context, status),
"available_contexts": available_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"))
}
fn is_full_git_sha(value: &str) -> bool {
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
/// 合并门禁的纯函数部分。网络层先抓取同一时刻的 PR / CI / review,再一次性校验。
/// 返回已确认的实际 head SHA,供执行结果与审计信息复用。
fn validate_merge_preconditions(
confirmed: bool,
expected_head_sha: &str,
pull_request: &serde_json::Value,
ci: &DeliverySignal,
review: &DeliverySignal,
) -> Result<String, String> {
if !confirmed {
return Err("合并需要当前用户明确确认(confirmed=true)".to_string());
}
if !is_full_git_sha(expected_head_sha) {
return Err("expected_head_sha 必须是完整 40 位 Git SHA".to_string());
}
let state = pull_request["state"].as_str().unwrap_or("unknown");
if state != "open" || pull_request["merged"].as_bool() == Some(true) {
return Err(format!("PR 当前不可合并:state={state}"));
}
if pull_request["mergeable"].as_bool() == Some(false) {
return Err("PR 存在冲突或被 Gitea 判定为不可合并".to_string());
}
let actual_head_sha = pull_request["head"]["sha"]
.as_str()
.ok_or_else(|| "Gitea PR 响应缺少 head.sha,拒绝合并".to_string())?;
if actual_head_sha != expected_head_sha {
return Err(format!(
"PR head SHA 已变化:期望 {expected_head_sha},实际 {actual_head_sha};请重新检查后确认"
));
}
if ci.state != "passed" {
return Err(format!("CI 门禁未通过:{}({})", ci.state, ci.detail));
}
if ci.data["target_sha"].as_str() != Some(actual_head_sha) {
return Err("CI 证据未绑定当前 PR head SHA,拒绝合并".to_string());
}
match review.state.as_str() {
"approved" | "pending" => {}
"changes_requested" => return Err("review 门禁阻塞:存在 reviewer 请求修改".to_string()),
other => {
return Err(format!(
"review 状态不可用或未知:{other};为避免误合并已拒绝"
))
}
}
Ok(actual_head_sha.to_string())
}
fn merge_request_body(pr_number: i64, head_sha: &str) -> serde_json::Value {
serde_json::json!({
"Do": "merge",
"merge_title_field": format!("Merge PR #{pr_number}"),
"merge_message_field": "",
"head_commit_id": head_sha,
})
}
/// 合并指定 PR。所有高影响边界在能力本体内强制校验,MCP/UI 不得绕过。
/// 调用 POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge。
pub fn gitea_api_merge_pull_request(
conn: &rusqlite::Connection,
project_id: &str,
pr_number: i64,
expected_head_sha: &str,
confirmed: bool,
) -> Result<String, String> {
if !confirmed {
return Err("合并需要当前用户明确确认(confirmed=true)".to_string());
}
if !is_full_git_sha(expected_head_sha) {
return Err("expected_head_sha 必须是完整 40 位 Git SHA".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 pull_path = format!("/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}");
let pull_request = gitea_get_json(&base_url, &token, &pull_path, "读取待合并 PR 失败")?;
let actual_head_sha = pull_request["head"]["sha"]
.as_str()
.ok_or_else(|| "Gitea PR 响应缺少 head.sha,拒绝合并".to_string())?;
let status_path = format!(
"/api/v1/repos/{owner}/{repo_name}/statuses/{actual_head_sha}?sort=recentupdate&limit=50"
);
let statuses = gitea_get_json(
&base_url,
&token,
&status_path,
"读取待合并 PR 的 CI 状态失败",
)?;
let ci = summarize_ci_statuses(&statuses, Some(actual_head_sha));
let reviews_path = format!("/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}/reviews");
let reviews = gitea_get_json(
&base_url,
&token,
&reviews_path,
"读取待合并 PR 的 review 状态失败",
)?;
let review = summarize_reviews(&reviews);
let validated_sha =
validate_merge_preconditions(confirmed, expected_head_sha, &pull_request, &ci, &review)?;
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(merge_request_body(pr_number, &validated_sha))
.map_err(|e| api_err("合并 PR 失败", e))?;
serde_json::to_string_pretty(&serde_json::json!({
"merged": true,
"project_id": project_id,
"repository": format!("{owner}/{repo_name}"),
"pr_number": pr_number,
"head_sha": validated_sha,
"ci_state": ci.state,
"review_state": review.state,
}))
.map_err(|error| error.to_string())
}
// ── 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": "failure",
"description": "old failure",
"target_url": "https://gitea.test/actions/runs/524/jobs/0",
"created_at": "2026-09-22T01:00:00Z",
"updated_at": "2026-09-22T01:01:00Z"
},
{
"context": "test",
"status": "success",
"description": "green",
"target_url": "https://gitea.test/actions/runs/525/jobs/0",
"created_at": "2026-09-22T02:00:00Z",
"updated_at": "2026-09-22T02:01:00Z"
},
{"context": "lint", "state": "failure", "description": "bad"}
]);
let summary = summarize_ci_statuses(&statuses, Some("530888eb"));
assert_eq!(summary.state, "failed");
assert_eq!(summary.data["target_sha"], "530888eb");
assert_eq!(summary.data["contexts"].as_array().unwrap().len(), 2);
let test = summary.data["contexts"]
.as_array()
.unwrap()
.iter()
.find(|item| item["context"] == "test")
.unwrap();
assert_eq!(test["state"], "success");
assert_eq!(
test["target_url"],
"https://gitea.test/actions/runs/525/jobs/0"
);
assert_eq!(test["updated_at"], "2026-09-22T02:01:00Z");
}
#[test]
fn delivery_context_summary_uses_exact_context_and_preserves_evidence() {
// 字段形状来自 Gitea 1.22 CommitStatus schema;context/job 名与 target URL
// 来自 enterprise-system 的真实 run #525 页面,敏感 creator 信息不进入 fixture。
let statuses = serde_json::json!([
{
"context": "CI — DeepSeek Agent / agent (push)",
"status": "success",
"description": "success",
"target_url": "https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/0",
"created_at": "2026-09-22T03:35:00Z",
"updated_at": "2026-09-22T03:36:36Z"
},
{
"context": "CI — DeepSeek Agent / deploy-api (push)",
"status": "success",
"description": "success",
"target_url": "https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/2",
"created_at": "2026-09-22T03:35:00Z",
"updated_at": "2026-09-22T03:38:55Z"
}
]);
let signal = summarize_status_context(
&statuses,
"530888eb90983f21be4efa8191aae5711e03e2dc",
"CI — DeepSeek Agent / deploy-api (push)",
);
assert_eq!(signal.state, "success");
assert_eq!(
signal.data["target_sha"],
"530888eb90983f21be4efa8191aae5711e03e2dc"
);
assert_eq!(
signal.data["status"]["context"],
"CI — DeepSeek Agent / deploy-api (push)"
);
assert_eq!(
signal.data["status"]["target_url"],
"https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/2"
);
assert_eq!(signal.data["status"]["updated_at"], "2026-09-22T03:38:55Z");
}
#[test]
fn delivery_context_summary_normalizes_terminal_and_missing_states() {
for (raw_state, expected) in [
("pending", "pending"),
("failure", "failure"),
("error", "failure"),
("warning", "warning"),
("skipped", "skipped"),
] {
let statuses = serde_json::json!([
{"context": "deploy-api", "state": raw_state, "description": raw_state},
{"context": "agent", "status": "success"}
]);
let signal = summarize_status_context(&statuses, "abc", "deploy-api");
assert_eq!(signal.state, expected, "raw state={raw_state}");
}
let statuses = serde_json::json!([
{"context": "agent", "status": "success"}
]);
let missing = summarize_status_context(&statuses, "abc", "deploy-api");
assert_eq!(missing.state, "unknown");
assert_eq!(
missing.data["available_contexts"],
serde_json::json!(["agent"])
);
}
#[test]
fn gitea_1_22_skipped_job_is_not_deployment_success() {
// 脱敏自 enterprise-system 真实 run #529:Gitea 1.22.6 把 skipped job
// 编码为 status=success,仅通过 description 表达未执行,target_url 也是相对路径。
let statuses = serde_json::json!([
{
"context": "CI — DeepSeek Agent / deploy-api (push)",
"created_at": "2026-09-22T15:12:03+08:00",
"description": "Has been skipped",
"status": "success",
"target_url": "/lanrtop/enterprise-system/actions/runs/529/jobs/2",
"updated_at": "2026-09-22T15:12:03+08:00"
}
]);
let signal = summarize_status_context(
&statuses,
"a2e85f50b093cbd6af58a09eaa9a927849723724",
"CI — DeepSeek Agent / deploy-api (push)",
);
assert_eq!(signal.state, "skipped");
assert_eq!(signal.data["status"]["state"], "skipped");
assert_eq!(
signal.data["status"]["target_url"],
"/lanrtop/enterprise-system/actions/runs/529/jobs/2"
);
let ci = summarize_ci_statuses(&statuses, Some("a2e85f50b093cbd6af58a09eaa9a927849723724"));
assert_eq!(ci.state, "not_applicable");
assert_eq!(ci.data["contexts"][0]["state"], "skipped");
}
#[test]
fn delivery_ci_summary_compares_rfc3339_timestamps_across_offsets() {
let statuses = serde_json::json!([
{
"context": "test",
"status": "failure",
"description": "older despite later local calendar date",
"updated_at": "2026-09-22T02:00:00+09:00"
},
{
"context": "test",
"status": "success",
"description": "newer in UTC",
"updated_at": "2026-09-21T18:00:00Z"
}
]);
let summary = summarize_ci_statuses(&statuses, Some("abc"));
assert_eq!(summary.state, "passed");
assert_eq!(summary.data["contexts"][0]["description"], "newer in UTC");
}
#[test]
fn delivery_ci_summary_does_not_infer_skipped_from_unrelated_description() {
let statuses = serde_json::json!([{
"context": "test",
"status": "success",
"description": "success; no checks skipped",
"updated_at": "2026-09-22T10:00:00Z"
}]);
let summary = summarize_ci_statuses(&statuses, Some("abc"));
assert_eq!(summary.state, "passed");
assert_eq!(summary.data["contexts"][0]["state"], "success");
}
#[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());
}
fn merge_gate_fixture() -> (serde_json::Value, DeliverySignal, DeliverySignal) {
let sha = "0123456789abcdef0123456789abcdef01234567";
let pull_request = serde_json::json!({
"state": "open",
"merged": false,
"mergeable": true,
"head": { "sha": sha }
});
let ci = DeliverySignal::new(
"passed",
"gitea",
"所有最新 CI context 均成功",
serde_json::json!({ "target_sha": sha, "contexts": [] }),
);
let review = DeliverySignal::new(
"pending",
"gitea",
"尚无有效 approve/reject review",
serde_json::json!({ "reviewers": [] }),
);
(pull_request, ci, review)
}
#[test]
fn merge_gate_requires_explicit_confirmation_and_full_sha() {
let (pull_request, ci, review) = merge_gate_fixture();
let sha = pull_request["head"]["sha"].as_str().unwrap();
assert!(
validate_merge_preconditions(false, sha, &pull_request, &ci, &review)
.unwrap_err()
.contains("明确确认")
);
assert!(
validate_merge_preconditions(true, "01234567", &pull_request, &ci, &review)
.unwrap_err()
.contains("完整 40 位")
);
}
#[test]
fn merge_gate_rejects_sha_drift_and_non_open_pr() {
let (mut pull_request, ci, review) = merge_gate_fixture();
let different_sha = "fedcba9876543210fedcba9876543210fedcba98";
assert!(
validate_merge_preconditions(true, different_sha, &pull_request, &ci, &review,)
.unwrap_err()
.contains("SHA 已变化")
);
pull_request["state"] = serde_json::json!("closed");
let original_sha = "0123456789abcdef0123456789abcdef01234567";
assert!(
validate_merge_preconditions(true, original_sha, &pull_request, &ci, &review,)
.unwrap_err()
.contains("不可合并")
);
}
#[test]
fn merge_gate_rejects_unready_ci_and_blocking_review() {
let (pull_request, mut ci, mut review) = merge_gate_fixture();
let sha = pull_request["head"]["sha"].as_str().unwrap();
for state in ["pending", "failed", "unavailable"] {
ci.state = state.to_string();
assert!(
validate_merge_preconditions(true, sha, &pull_request, &ci, &review)
.unwrap_err()
.contains("CI 门禁未通过")
);
}
ci.state = "passed".to_string();
review.state = "changes_requested".to_string();
assert!(
validate_merge_preconditions(true, sha, &pull_request, &ci, &review)
.unwrap_err()
.contains("请求修改")
);
review.state = "unavailable".to_string();
assert!(
validate_merge_preconditions(true, sha, &pull_request, &ci, &review)
.unwrap_err()
.contains("不可用或未知")
);
}
#[test]
fn merge_gate_allows_current_sha_with_passed_ci_and_no_blocking_review() {
let (pull_request, ci, mut review) = merge_gate_fixture();
let sha = pull_request["head"]["sha"].as_str().unwrap();
assert_eq!(
validate_merge_preconditions(true, sha, &pull_request, &ci, &review).unwrap(),
sha
);
review.state = "approved".to_string();
assert_eq!(
validate_merge_preconditions(true, sha, &pull_request, &ci, &review).unwrap(),
sha
);
}
#[test]
fn merge_request_body_binds_the_confirmed_head_sha() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let body = merge_request_body(13, sha);
assert_eq!(body["Do"], "merge");
assert_eq!(body["head_commit_id"], sha);
}
}