From 8c1968c8638cde9abed5fd5942403ade842ccc12 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 7 Feb 2026 20:04:39 +0300 Subject: [PATCH] common: improve notifications system Signed-off-by: NotAShelf Change-Id: I084bb95a4eb79d5a15f7c062c112124c6a6a6964 --- crates/common/src/notifications.rs | 151 ++++++++++++++++++++++++++++- crates/server/tests/api_tests.rs | 3 + 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/crates/common/src/notifications.rs b/crates/common/src/notifications.rs index 6c54660..febc218 100644 --- a/crates/common/src/notifications.rs +++ b/crates/common/src/notifications.rs @@ -32,7 +32,13 @@ pub async fn dispatch_build_finished( .await; } - // 4. Email notification + // 4. GitLab commit status + if let (Some(url), Some(token)) = (&config.gitlab_url, &config.gitlab_token) { + set_gitlab_status(url, token, &project.repository_url, commit_hash, build) + .await; + } + + // 5. Email notification if let Some(ref email_config) = config.email && (!email_config.on_failure_only || build.status == BuildStatus::Failed) { @@ -183,6 +189,67 @@ async fn set_gitea_status( } } +async fn set_gitlab_status( + base_url: &str, + token: &str, + repo_url: &str, + commit: &str, + build: &Build, +) { + // Parse project path from URL + let project_path = match parse_gitlab_project(repo_url, base_url) { + Some(p) => p, + None => { + warn!("Cannot parse GitLab project from {repo_url}"); + return; + }, + }; + + // GitLab uses different state names + let (state, description) = match build.status { + BuildStatus::Completed => ("success", "Build succeeded"), + BuildStatus::Failed => ("failed", "Build failed"), + BuildStatus::Running => ("running", "Build in progress"), + BuildStatus::Pending => ("pending", "Build queued"), + BuildStatus::Cancelled => ("canceled", "Build cancelled"), + }; + + // URL-encode the project path for the API + let encoded_project = urlencoding::encode(&project_path); + let url = format!( + "{}/api/v4/projects/{}/statuses/{}", + base_url.trim_end_matches('/'), + encoded_project, + commit + ); + + let body = serde_json::json!({ + "state": state, + "description": description, + "name": format!("fc/{}", build.job_name), + }); + + let client = reqwest::Client::new(); + match client + .post(&url) + .header("PRIVATE-TOKEN", token) + .json(&body) + .send() + .await + { + Ok(resp) => { + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + warn!("GitLab status API returned {status}: {text}"); + } else { + info!(build_id = %build.id, "Set GitLab commit status: {state}"); + } + }, + Err(e) => error!("GitLab status API request failed: {e}"), + } +} + fn parse_github_repo(url: &str) -> Option<(String, String)> { // Handle https://github.com/owner/repo.git or git@github.com:owner/repo.git let url = url.trim_end_matches(".git"); @@ -216,6 +283,23 @@ fn parse_gitea_repo( None } +fn parse_gitlab_project(repo_url: &str, base_url: &str) -> Option { + let url = repo_url.trim_end_matches(".git"); + let base = base_url.trim_end_matches('/'); + if let Some(rest) = url.strip_prefix(&format!("{base}/")) { + return Some(rest.to_string()); + } + // Also try without scheme match (e.g., https vs git@) + if let (Some(at_pos), Some(colon_pos)) = ( + url.find('@'), + url.find('@').and_then(|p| url[p..].find(':')), + ) { + let path = &url[at_pos + colon_pos + 1..]; + return Some(path.to_string()); + } + None +} + async fn send_email_notification( config: &EmailConfig, build: &Build, @@ -311,3 +395,68 @@ async fn send_email_notification( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_github_repo_https() { + let result = parse_github_repo("https://github.com/owner/repo.git"); + assert_eq!(result, Some(("owner".to_string(), "repo".to_string()))); + + let result = parse_github_repo("https://github.com/owner/repo"); + assert_eq!(result, Some(("owner".to_string(), "repo".to_string()))); + } + + #[test] + fn test_parse_github_repo_ssh() { + let result = parse_github_repo("git@github.com:owner/repo.git"); + assert_eq!(result, Some(("owner".to_string(), "repo".to_string()))); + } + + #[test] + fn test_parse_github_repo_invalid() { + assert_eq!(parse_github_repo("https://gitlab.com/owner/repo"), None); + assert_eq!(parse_github_repo("invalid-url"), None); + } + + #[test] + fn test_parse_gitea_repo() { + let result = parse_gitea_repo( + "https://gitea.example.com/owner/repo.git", + "https://gitea.example.com", + ); + assert_eq!(result, Some(("owner".to_string(), "repo".to_string()))); + + let result = parse_gitea_repo( + "https://gitea.example.com/owner/repo", + "https://gitea.example.com/", + ); + assert_eq!(result, Some(("owner".to_string(), "repo".to_string()))); + } + + #[test] + fn test_parse_gitlab_project() { + let result = parse_gitlab_project( + "https://gitlab.com/group/subgroup/repo.git", + "https://gitlab.com", + ); + assert_eq!(result, Some("group/subgroup/repo".to_string())); + + let result = parse_gitlab_project( + "https://gitlab.com/owner/repo", + "https://gitlab.com/", + ); + assert_eq!(result, Some("owner/repo".to_string())); + } + + #[test] + fn test_parse_gitlab_project_ssh() { + let result = parse_gitlab_project( + "git@gitlab.com:group/repo.git", + "https://gitlab.com", + ); + assert_eq!(result, Some("group/repo".to_string())); + } +} diff --git a/crates/server/tests/api_tests.rs b/crates/server/tests/api_tests.rs index ca28b63..bebb71f 100644 --- a/crates/server/tests/api_tests.rs +++ b/crates/server/tests/api_tests.rs @@ -37,6 +37,7 @@ fn build_app(pool: sqlx::PgPool) -> axum::Router { pool, config, sessions: std::sync::Arc::new(dashmap::DashMap::new()), + http_client: reqwest::Client::new(), }; fc_server::routes::router(state, &server_config) } @@ -54,6 +55,7 @@ async fn test_router_no_duplicate_routes() { pool, config, sessions: std::sync::Arc::new(dashmap::DashMap::new()), + http_client: reqwest::Client::new(), }; let _app = fc_server::routes::router(state, &server_config); @@ -68,6 +70,7 @@ fn build_app_with_config( pool, config, sessions: std::sync::Arc::new(dashmap::DashMap::new()), + http_client: reqwest::Client::new(), }; fc_server::routes::router(state, &server_config) }