新增: DockerNode+CI状态读取+模板CRUD+CIStatus面板
- DockerNode: 环境检测(docker --version)+授权+容器内执行(12个测试) - ci_status.rs: Gitea commit status API 读取(7个测试,失败返回空不阻塞) - CIStatus.vue: CI检查面板(通过/失败/pending 汇总+可点击跳转) - 模板CRUD IPC: list/save/delete templates(内置只读+自定义KV持久化) - TemplateInfo 结构体(IPC传输用) - 零编译警告,vue-tsc+vite build 通过
This commit is contained in:
219
src-tauri/src/commands/ci_status.rs
Normal file
219
src-tauri/src/commands/ci_status.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! CI 检查状态查询命令 — 读取 Gitea commit 检查状态(对标 GitHub commit status API)
|
||||
//!
|
||||
//! 调用 Gitea `{api_base}/api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` 拉取该
|
||||
//! commit 的 CI 检查项列表,转成 CheckStatus 返回前端(供 commit 详情面板展示 ✓/✗/⏳)。
|
||||
//!
|
||||
//! 设计取舍:
|
||||
//! - **容错优先**:网络错误 / URL 解析失败 / Gitea 异常 → 返回空 vec,**不阻断工作流**
|
||||
//! (CI 状态属于辅助信息,失败不应阻塞 Git 浏览等主功能)。
|
||||
//! - URL 解析:从 repo_url(如 `https://gitea.1216.top/lxy/DevFlow`)拆出
|
||||
//! `{scheme}://{host}` 作 api_base,路径段尾两段作 owner/repo。
|
||||
//! - 字段映射:Gitea status.state 取值 `pending/success/failure/error/...`,
|
||||
//! error/warning 归一为 failure 展示;`context` 作检查名,`target_url` 作详情链接。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
|
||||
|
||||
/// 前端展示用的 CI 检查状态(归一化后)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CheckStatus {
|
||||
/// 检查名(如 "ci/cargo-test"、"continuous-integration/drone/push")
|
||||
pub name: String,
|
||||
/// 归一状态:"pending" | "success" | "failure"
|
||||
pub status: String,
|
||||
/// 详情链接(Gitea target_url,可能为空)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// Gitea API 原始返回项(仅取关心的字段,其余忽略)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaStatus {
|
||||
/// 检查上下文名(Gitea 字段名 context)
|
||||
context: Option<String>,
|
||||
/// 原始状态:pending / success / failure / error / warning / ...
|
||||
state: Option<String>,
|
||||
/// 详情链接
|
||||
target_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 repo_url 解析 (api_base, owner, repo)。
|
||||
///
|
||||
/// 例:`https://gitea.1216.top/lxy/DevFlow` →
|
||||
/// (`https://gitea.1216.top`, `lxy`, `DevFlow`)
|
||||
///
|
||||
/// `.git` 后缀容忍;路径段不足两段返回 None。
|
||||
fn parse_repo_url(repo_url: &str) -> Option<(String, String, String)> {
|
||||
let url = reqwest::Url::parse(repo_url.trim()).ok()?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
let host = url.host_str()?;
|
||||
let port = url.port_or_known_default();
|
||||
let authority = match port {
|
||||
Some(p) => format!("{}:{}", host, p),
|
||||
None => host.to_string(),
|
||||
};
|
||||
let api_base = format!("{}://{}", url.scheme(), authority);
|
||||
|
||||
// 路径段:去空 / 去 .git 后缀 → 取末两段 owner / repo
|
||||
// (url::Url::path_segments() 返 Option<Split>,Split 不实现 Default,需手动兜底)
|
||||
let path_iter = url.path_segments().unwrap_or("".split('/'));
|
||||
let mut segments: Vec<String> = path_iter
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.trim_end_matches(".git").to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if segments.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let repo = segments.pop()?;
|
||||
let owner = segments.pop()?;
|
||||
Some((api_base, owner, repo))
|
||||
}
|
||||
|
||||
/// 将 Gitea 原始 state 归一为前端三态:"pending" | "success" | "failure"
|
||||
fn normalize_state(raw: &str) -> &'static str {
|
||||
match raw.to_lowercase().as_str() {
|
||||
"success" => "success",
|
||||
"pending" | "" => "pending",
|
||||
// failure / error / warning / blocked / 其他均视为失败展示
|
||||
_ => "failure",
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询指定 commit 的 CI 检查状态列表。
|
||||
///
|
||||
/// 任一环节失败(网络/解析/Gitea 异常)均返回空 vec,日志告警但不返回错误,
|
||||
/// 避免阻断前端 Git 浏览等主流程。
|
||||
#[tauri::command]
|
||||
pub async fn get_commit_status(
|
||||
_state: State<'_, AppState>,
|
||||
repo_url: String,
|
||||
commit_sha: String,
|
||||
) -> Result<Vec<CheckStatus>, String> {
|
||||
let (api_base, owner, repo) = match parse_repo_url(&repo_url) {
|
||||
Some(parsed) => parsed,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
repo_url = %repo_url,
|
||||
"[ci_status] repo_url 解析失败,返回空列表"
|
||||
);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"{}/api/v1/repos/{}/{}/commits/{}/statuses",
|
||||
api_base, owner, repo, commit_sha
|
||||
);
|
||||
|
||||
tracing::info!(url = %url, "[ci_status] 查询 Gitea commit statuses");
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[ci_status] 构建 reqwest client 失败");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[ci_status] 请求 Gitea 失败(网络不可达或未鉴权)");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!(
|
||||
status = %resp.status(),
|
||||
"[ci_status] Gitea 响应非 2xx,返回空列表"
|
||||
);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let raw: Vec<GiteaStatus> = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[ci_status] 解析 Gitea 响应 JSON 失败");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let checks = raw
|
||||
.into_iter()
|
||||
.map(|s| CheckStatus {
|
||||
name: s.context.unwrap_or_else(|| "unknown".to_string()),
|
||||
status: normalize_state(s.state.as_deref().unwrap_or("")).to_string(),
|
||||
url: s.target_url.filter(|u| !u.is_empty()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(checks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_repo_url ──
|
||||
|
||||
#[test]
|
||||
fn parse_standard_gitea_url() {
|
||||
let (base, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow").unwrap();
|
||||
assert_eq!(base, "https://gitea.1216.top");
|
||||
assert_eq!(owner, "lxy");
|
||||
assert_eq!(repo, "DevFlow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_strips_git_suffix() {
|
||||
let (_, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow.git").unwrap();
|
||||
assert_eq!(owner, "lxy");
|
||||
assert_eq!(repo, "DevFlow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_port() {
|
||||
let (base, _, _) = parse_repo_url("http://gitea.local:3000/team/proj").unwrap();
|
||||
assert_eq!(base, "http://gitea.local:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_non_http() {
|
||||
assert!(parse_repo_url("ftp://gitea/x/y").is_none());
|
||||
assert!(parse_repo_url("file:///x/y").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_short_path() {
|
||||
assert!(parse_repo_url("https://gitea.1216.top/").is_none());
|
||||
assert!(parse_repo_url("https://gitea.1216.top/onlyone").is_none());
|
||||
}
|
||||
|
||||
// ── normalize_state ──
|
||||
|
||||
#[test]
|
||||
fn normalize_success_and_pending() {
|
||||
assert_eq!(normalize_state("success"), "success");
|
||||
assert_eq!(normalize_state("pending"), "pending");
|
||||
assert_eq!(normalize_state(""), "pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_failure_variants() {
|
||||
assert_eq!(normalize_state("failure"), "failure");
|
||||
assert_eq!(normalize_state("error"), "failure");
|
||||
assert_eq!(normalize_state("warning"), "failure");
|
||||
assert_eq!(normalize_state("blocked"), "failure");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
||||
|
||||
pub mod ai;
|
||||
pub mod ci_status;
|
||||
pub mod events;
|
||||
pub mod idea;
|
||||
pub mod knowledge;
|
||||
|
||||
@@ -141,6 +141,95 @@ pub async fn get_plan_execution() -> Result<bool, String> {
|
||||
Ok(df_ai::plan_executor::plan_execution_enabled())
|
||||
}
|
||||
|
||||
/// 列出可用模板(内置 + 自定义)。
|
||||
///
|
||||
/// 内置模板从 assets/templates/ 加载;自定义模板从 app_settings KV(df-custom-templates)读取。
|
||||
#[tauri::command]
|
||||
pub async fn list_templates(state: State<'_, AppState>) -> Result<Vec<TemplateInfo>, String> {
|
||||
let mut templates = Vec::new();
|
||||
|
||||
// 内置模板
|
||||
let builtin = [
|
||||
("code-review", "代码审查", "AI 驱动的代码审查流程"),
|
||||
("bug-fix", "Bug 修复", "AI 驱动的 bug 修复流程"),
|
||||
("feature-dev", "功能开发", "AI 驱动的功能开发全流程"),
|
||||
];
|
||||
for (id, name, desc) in &builtin {
|
||||
templates.push(TemplateInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: desc.to_string(),
|
||||
builtin: true,
|
||||
yaml: String::new(), // 内置模板不返回全文(前端需要时单独加载)
|
||||
});
|
||||
}
|
||||
|
||||
// 自定义模板
|
||||
if let Ok(Some(custom_json)) = state.settings.get("df-custom-templates").await {
|
||||
if let Ok(custom) = serde_json::from_str::<Vec<TemplateInfo>>(&custom_json) {
|
||||
templates.extend(custom);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// 保存自定义模板(新建/覆盖)。
|
||||
#[tauri::command]
|
||||
pub async fn save_template(
|
||||
state: State<'_, AppState>,
|
||||
template: TemplateInfo,
|
||||
) -> Result<(), String> {
|
||||
let mut customs: Vec<TemplateInfo> = state
|
||||
.settings
|
||||
.get("df-custom-templates")
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 按 id 去重(覆盖同名)
|
||||
customs.retain(|t| t.id != template.id);
|
||||
customs.push(template);
|
||||
|
||||
let json = serde_json::to_string(&customs).map_err(|e| e.to_string())?;
|
||||
state.settings.set("df-custom-templates", &json).await.map_err(err_str)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除自定义模板。
|
||||
#[tauri::command]
|
||||
pub async fn delete_template(state: State<'_, AppState>, template_id: String) -> Result<(), String> {
|
||||
let mut customs: Vec<TemplateInfo> = state
|
||||
.settings
|
||||
.get("df-custom-templates")
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let before = customs.len();
|
||||
customs.retain(|t| t.id != template_id);
|
||||
|
||||
if customs.len() < before {
|
||||
let json = serde_json::to_string(&customs).map_err(|e| e.to_string())?;
|
||||
state.settings.set("df-custom-templates", &json).await.map_err(err_str)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 模板信息(IPC 传输用)
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TemplateInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// true = 内置模板(只读);false = 自定义模板
|
||||
pub builtin: bool,
|
||||
/// 自定义模板的 YAML 内容(内置模板为空)
|
||||
pub yaml: String,
|
||||
}
|
||||
|
||||
/// 解决合并冲突(用户/reviewer 选择解决方案后调用)。
|
||||
///
|
||||
/// 更新 ai_conflicts 表的 resolution + resolved_by + resolved_at,
|
||||
|
||||
@@ -438,6 +438,14 @@ pub fn run() {
|
||||
commands::settings::get_plan_execution,
|
||||
// 冲突解决
|
||||
commands::settings::resolve_conflict,
|
||||
// 模板管理
|
||||
commands::settings::list_templates,
|
||||
commands::settings::save_template,
|
||||
commands::settings::delete_template,
|
||||
// CI 状态
|
||||
commands::ci_status::get_commit_status,
|
||||
// CI 检查状态(Gitea commit statuses,失败返回空列表不阻断工作流)
|
||||
commands::ci_status::get_commit_status,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
Reference in New Issue
Block a user