//! 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, } /// Gitea API 原始返回项(仅取关心的字段,其余忽略) #[derive(Debug, Deserialize)] struct GiteaStatus { /// 检查上下文名(Gitea 字段名 context) context: Option, /// 原始状态:pending / success / failure / error / warning / ... state: Option, /// 详情链接 target_url: Option, } /// 从 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()?; // port_or_known_default() 对 https 返 Some(443)、http 返 Some(80), // 致 authority 总是带默认端口(:443/:80),base URL 形如 "https://host:443" 而非规范 // "https://host"。下游 Gitea API 调用虽大多容忍,但测试断言与可读性受影响。 // 修复:仅当端口非 scheme 默认端口时才拼入 authority(显式非默认端口才保留)。 let port = url.port(); // 仅取显式端口(未在 URL 写出的不返) 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 不实现 Default,需手动兜底) let path_iter = url.path_segments().unwrap_or("".split('/')); let mut segments: Vec = 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, 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 = 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"); } }