diff --git a/crates/common/migrations/010_pull_request_support.sql b/crates/common/migrations/010_pull_request_support.sql new file mode 100644 index 0000000..1a11a5a --- /dev/null +++ b/crates/common/migrations/010_pull_request_support.sql @@ -0,0 +1,12 @@ +-- Add pull request tracking to evaluations +-- This enables PR-based CI workflows for GitHub/GitLab/Gitea + +-- Add PR-specific columns to evaluations table +ALTER TABLE evaluations ADD COLUMN pr_number INTEGER; +ALTER TABLE evaluations ADD COLUMN pr_head_branch TEXT; +ALTER TABLE evaluations ADD COLUMN pr_base_branch TEXT; +ALTER TABLE evaluations ADD COLUMN pr_action TEXT; + +-- Index for efficient PR queries +CREATE INDEX idx_evaluations_pr ON evaluations(jobset_id, pr_number) + WHERE pr_number IS NOT NULL; diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 3d1dba7..7b48808 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -39,6 +39,10 @@ pub struct Evaluation { pub status: EvaluationStatus, pub error_message: Option, pub inputs_hash: Option, + pub pr_number: Option, + pub pr_head_branch: Option, + pub pr_base_branch: Option, + pub pr_action: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::Type)] @@ -360,8 +364,12 @@ pub struct UpdateJobset { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateEvaluation { - pub jobset_id: Uuid, - pub commit_hash: String, + pub jobset_id: Uuid, + pub commit_hash: String, + pub pr_number: Option, + pub pr_head_branch: Option, + pub pr_base_branch: Option, + pub pr_action: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/common/src/repo/evaluations.rs b/crates/common/src/repo/evaluations.rs index ff595f6..6c502d9 100644 --- a/crates/common/src/repo/evaluations.rs +++ b/crates/common/src/repo/evaluations.rs @@ -11,11 +11,16 @@ pub async fn create( input: CreateEvaluation, ) -> Result { sqlx::query_as::<_, Evaluation>( - "INSERT INTO evaluations (jobset_id, commit_hash, status) VALUES ($1, $2, \ - 'pending') RETURNING *", + "INSERT INTO evaluations (jobset_id, commit_hash, status, pr_number, \ + pr_head_branch, pr_base_branch, pr_action) VALUES ($1, $2, 'pending', \ + $3, $4, $5, $6) RETURNING *", ) .bind(input.jobset_id) .bind(&input.commit_hash) + .bind(input.pr_number) + .bind(&input.pr_head_branch) + .bind(&input.pr_base_branch) + .bind(&input.pr_action) .fetch_one(pool) .await .map_err(|e| { diff --git a/crates/server/src/routes/webhooks.rs b/crates/server/src/routes/webhooks.rs index 3f293b2..2a91144 100644 --- a/crates/server/src/routes/webhooks.rs +++ b/crates/server/src/routes/webhooks.rs @@ -34,6 +34,30 @@ struct GithubRepo { html_url: Option, } +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GithubPullRequestPayload { + action: Option, + number: Option, + pull_request: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GithubPullRequest { + head: Option, + base: Option, + draft: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GithubPrRef { + sha: Option, + #[serde(alias = "ref")] + ref_name: Option, +} + #[allow(dead_code)] #[derive(Debug, Deserialize)] struct GiteaPushPayload { @@ -50,6 +74,52 @@ struct GiteaRepo { html_url: Option, } +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GitLabPushPayload { + #[serde(alias = "ref")] + git_ref: Option, + after: Option, + checkout_sha: Option, + project: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GitLabProject { + id: Option, + path_with_namespace: Option, + web_url: Option, + git_http_url: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GitLabMergeRequestPayload { + object_kind: Option, + object_attributes: Option, + project: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GitLabMergeRequestAttributes { + iid: Option, + action: Option, + state: Option, + source_branch: Option, + target_branch: Option, + last_commit: Option, + work_in_progress: Option, + draft: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GitLabCommit { + id: Option, +} + /// Verify HMAC-SHA256 webhook signature. /// The `secret` parameter is the raw webhook secret stored in DB. fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { @@ -74,7 +144,7 @@ fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { mac.verify_slice(&sig_bytes).is_ok() } -async fn handle_github_push( +async fn handle_github_webhook( State(state): State, Path(project_id): Path, headers: HeaderMap, @@ -120,9 +190,36 @@ async fn handle_github_push( } } - // Parse payload + // Determine event type from X-GitHub-Event header + let event_type = headers + .get("x-github-event") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + match event_type { + "push" => handle_github_push(state, project_id, &body).await, + "pull_request" => { + handle_github_pull_request(state, project_id, &body).await + }, + _ => { + Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!("Ignored GitHub event: {event_type}"), + }), + )) + }, + } +} + +async fn handle_github_push( + state: AppState, + project_id: Uuid, + body: &[u8], +) -> Result<(StatusCode, Json), ApiError> { let payload: GithubPushPayload = - serde_json::from_slice(&body).map_err(|e| { + serde_json::from_slice(body).map_err(|e| { ApiError(fc_common::CiError::Validation(format!( "Invalid payload: {e}" ))) @@ -151,8 +248,12 @@ async fn handle_github_push( continue; } match repo::evaluations::create(&state.pool, CreateEvaluation { - jobset_id: jobset.id, - commit_hash: commit.clone(), + jobset_id: jobset.id, + commit_hash: commit.clone(), + pr_number: None, + pr_head_branch: None, + pr_base_branch: None, + pr_action: None, }) .await { @@ -173,6 +274,113 @@ async fn handle_github_push( )) } +async fn handle_github_pull_request( + state: AppState, + project_id: Uuid, + body: &[u8], +) -> Result<(StatusCode, Json), ApiError> { + let payload: GithubPullRequestPayload = serde_json::from_slice(body) + .map_err(|e| { + ApiError(fc_common::CiError::Validation(format!( + "Invalid GitHub PR payload: {e}" + ))) + })?; + + let action = payload.action.as_deref().unwrap_or(""); + + // Only trigger on open/synchronize/reopen actions + if !matches!(action, "opened" | "synchronize" | "reopened") { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!("Ignored PR action: {action}"), + }), + )); + } + + let pr = match payload.pull_request { + Some(pr) => pr, + None => { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "No pull request data, skipping".to_string(), + }), + )); + }, + }; + + // Skip draft PRs + if pr.draft.unwrap_or(false) { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "Draft pull request, skipping".to_string(), + }), + )); + } + + let commit = pr + .head + .as_ref() + .and_then(|h| h.sha.clone()) + .unwrap_or_default(); + if commit.is_empty() { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "No commit in pull request, skipping".to_string(), + }), + )); + } + + let pr_number = payload.number.map(|n| n as i32); + let pr_head_branch = pr.head.as_ref().and_then(|h| h.ref_name.clone()); + let pr_base_branch = pr.base.as_ref().and_then(|b| b.ref_name.clone()); + let pr_action = Some(action.to_string()); + + let jobsets = + repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0) + .await + .map_err(ApiError)?; + + let mut triggered = 0; + for jobset in &jobsets { + if !jobset.enabled { + continue; + } + match repo::evaluations::create(&state.pool, CreateEvaluation { + jobset_id: jobset.id, + commit_hash: commit.clone(), + pr_number, + pr_head_branch: pr_head_branch.clone(), + pr_base_branch: pr_base_branch.clone(), + pr_action: pr_action.clone(), + }) + .await + { + Ok(_) => triggered += 1, + Err(fc_common::CiError::Conflict(_)) => {}, + Err(e) => tracing::warn!("Failed to create evaluation: {e}"), + } + } + + let pr_num = payload.number.unwrap_or(0); + Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!( + "Triggered {triggered} evaluations for PR #{pr_num} commit {commit}" + ), + }), + )) +} + async fn handle_gitea_push( State(state): State, Path(project_id): Path, @@ -274,8 +482,12 @@ async fn handle_gitea_push( continue; } match repo::evaluations::create(&state.pool, CreateEvaluation { - jobset_id: jobset.id, - commit_hash: commit.clone(), + jobset_id: jobset.id, + commit_hash: commit.clone(), + pr_number: None, + pr_head_branch: None, + pr_base_branch: None, + pr_action: None, }) .await { @@ -296,11 +508,252 @@ async fn handle_gitea_push( )) } +async fn handle_gitlab_webhook( + State(state): State, + Path(project_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result<(StatusCode, Json), ApiError> { + // Check webhook config exists + let webhook_config = repo::webhook_configs::get_by_project_and_forge( + &state.pool, + project_id, + "gitlab", + ) + .await + .map_err(ApiError)?; + + let webhook_config = match webhook_config { + Some(c) => c, + None => { + return Ok(( + StatusCode::NOT_FOUND, + Json(WebhookResponse { + accepted: false, + message: "No GitLab webhook configured for this project".to_string(), + }), + )); + }, + }; + + // Verify token if secret is configured + // GitLab uses X-Gitlab-Token header with plain token (not HMAC) + if let Some(ref secret) = webhook_config.secret_hash { + let token = headers + .get("x-gitlab-token") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + // Use constant-time comparison to prevent timing attacks + use subtle::ConstantTimeEq; + let token_matches = token.len() == secret.len() + && token.as_bytes().ct_eq(secret.as_bytes()).into(); + + if !token_matches { + return Ok(( + StatusCode::UNAUTHORIZED, + Json(WebhookResponse { + accepted: false, + message: "Invalid webhook token".to_string(), + }), + )); + } + } + + // Determine event type from X-Gitlab-Event header + let event_type = headers + .get("x-gitlab-event") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + match event_type { + "Push Hook" => handle_gitlab_push(state, project_id, &body).await, + "Merge Request Hook" => { + handle_gitlab_merge_request(state, project_id, &body).await + }, + _ => { + Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!("Ignored GitLab event: {event_type}"), + }), + )) + }, + } +} + +async fn handle_gitlab_push( + state: AppState, + project_id: Uuid, + body: &[u8], +) -> Result<(StatusCode, Json), ApiError> { + let payload: GitLabPushPayload = + serde_json::from_slice(body).map_err(|e| { + ApiError(fc_common::CiError::Validation(format!( + "Invalid GitLab push payload: {e}" + ))) + })?; + + // Use checkout_sha (the actual commit checked out) or fall back to after + let commit = payload.checkout_sha.or(payload.after).unwrap_or_default(); + + if commit.is_empty() || commit == "0000000000000000000000000000000000000000" { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "Branch deletion event, skipping".to_string(), + }), + )); + } + + let jobsets = + repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0) + .await + .map_err(ApiError)?; + + let mut triggered = 0; + for jobset in &jobsets { + if !jobset.enabled { + continue; + } + match repo::evaluations::create(&state.pool, CreateEvaluation { + jobset_id: jobset.id, + commit_hash: commit.clone(), + pr_number: None, + pr_head_branch: None, + pr_base_branch: None, + pr_action: None, + }) + .await + { + Ok(_) => triggered += 1, + Err(fc_common::CiError::Conflict(_)) => {}, + Err(e) => tracing::warn!("Failed to create evaluation: {e}"), + } + } + + Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!( + "Triggered {triggered} evaluations for commit {commit}" + ), + }), + )) +} + +async fn handle_gitlab_merge_request( + state: AppState, + project_id: Uuid, + body: &[u8], +) -> Result<(StatusCode, Json), ApiError> { + let payload: GitLabMergeRequestPayload = serde_json::from_slice(body) + .map_err(|e| { + ApiError(fc_common::CiError::Validation(format!( + "Invalid GitLab MR payload: {e}" + ))) + })?; + + let attrs = match payload.object_attributes { + Some(a) => a, + None => { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "No merge request attributes, skipping".to_string(), + }), + )); + }, + }; + + // Skip draft/WIP merge requests + if attrs.work_in_progress.unwrap_or(false) || attrs.draft.unwrap_or(false) { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "Draft/WIP merge request, skipping".to_string(), + }), + )); + } + + // Only trigger on open/update/reopen actions + let action = attrs.action.as_deref().unwrap_or(""); + if !matches!(action, "open" | "update" | "reopen") { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!("Ignored MR action: {action}"), + }), + )); + } + + // Get the commit from the last commit in the MR + let commit = attrs.last_commit.and_then(|c| c.id).unwrap_or_default(); + + if commit.is_empty() { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "No commit in merge request, skipping".to_string(), + }), + )); + } + + let pr_number = attrs.iid.map(|n| n as i32); + let pr_head_branch = attrs.source_branch.clone(); + let pr_base_branch = attrs.target_branch.clone(); + let pr_action = Some(action.to_string()); + + let jobsets = + repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0) + .await + .map_err(ApiError)?; + + let mut triggered = 0; + for jobset in &jobsets { + if !jobset.enabled { + continue; + } + match repo::evaluations::create(&state.pool, CreateEvaluation { + jobset_id: jobset.id, + commit_hash: commit.clone(), + pr_number, + pr_head_branch: pr_head_branch.clone(), + pr_base_branch: pr_base_branch.clone(), + pr_action: pr_action.clone(), + }) + .await + { + Ok(_) => triggered += 1, + Err(fc_common::CiError::Conflict(_)) => {}, + Err(e) => tracing::warn!("Failed to create evaluation: {e}"), + } + } + + let mr_iid = pr_number.unwrap_or(0); + Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!( + "Triggered {triggered} evaluations for MR !{mr_iid} commit {commit}" + ), + }), + )) +} + pub fn router() -> Router { Router::new() .route( "/api/v1/webhooks/{project_id}/github", - post(handle_github_push), + post(handle_github_webhook), ) .route( "/api/v1/webhooks/{project_id}/gitea", @@ -310,4 +763,219 @@ pub fn router() -> Router { "/api/v1/webhooks/{project_id}/forgejo", post(handle_gitea_push), ) + .route( + "/api/v1/webhooks/{project_id}/gitlab", + post(handle_gitlab_webhook), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verify_signature_valid() { + let secret = "test-secret"; + let body = b"test-body"; + + // Compute expected signature + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let expected = hex::encode(mac.finalize().into_bytes()); + + assert!(verify_signature( + secret, + body, + &format!("sha256={}", expected) + )); + } + + #[test] + fn test_verify_signature_invalid() { + let secret = "test-secret"; + let body = b"test-body"; + assert!(!verify_signature(secret, body, "sha256=invalidsignature")); + } + + #[test] + fn test_verify_signature_wrong_secret() { + let body = b"test-body"; + + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = Hmac::::new_from_slice(b"secret1").unwrap(); + mac.update(body); + let sig = hex::encode(mac.finalize().into_bytes()); + + // Verify with different secret should fail + assert!(!verify_signature( + "secret2", + body, + &format!("sha256={}", sig) + )); + } + + #[test] + fn test_parse_github_push_payload() { + let payload = r#"{ + "ref": "refs/heads/main", + "after": "abc123def456789012345678901234567890abcd" + }"#; + + let parsed: GithubPushPayload = serde_json::from_str(payload).unwrap(); + assert_eq!( + parsed.after, + Some("abc123def456789012345678901234567890abcd".to_string()) + ); + assert_eq!(parsed.git_ref, Some("refs/heads/main".to_string())); + } + + #[test] + fn test_parse_github_pr_payload() { + let payload = r#"{ + "action": "opened", + "number": 42, + "pull_request": { + "head": {"sha": "abc123", "ref": "feature-branch"}, + "base": {"sha": "def456", "ref": "main"}, + "draft": false + } + }"#; + + let parsed: GithubPullRequestPayload = + serde_json::from_str(payload).unwrap(); + assert_eq!(parsed.action, Some("opened".to_string())); + assert_eq!(parsed.number, Some(42)); + + let pr = parsed.pull_request.unwrap(); + assert_eq!(pr.draft, Some(false)); + assert_eq!( + pr.head.as_ref().and_then(|h| h.sha.clone()), + Some("abc123".to_string()) + ); + assert_eq!( + pr.head.as_ref().and_then(|h| h.ref_name.clone()), + Some("feature-branch".to_string()) + ); + } + + #[test] + fn test_parse_github_pr_draft() { + let payload = r#"{ + "action": "opened", + "number": 99, + "pull_request": { + "head": {"sha": "abc123", "ref": "draft-branch"}, + "base": {"sha": "def456", "ref": "main"}, + "draft": true + } + }"#; + + let parsed: GithubPullRequestPayload = + serde_json::from_str(payload).unwrap(); + let pr = parsed.pull_request.unwrap(); + assert_eq!(pr.draft, Some(true)); + } + + #[test] + fn test_parse_gitlab_push_payload() { + let payload = r#"{ + "ref": "refs/heads/main", + "after": "abc123", + "checkout_sha": "def456789012345678901234567890abcdef12" + }"#; + + let parsed: GitLabPushPayload = serde_json::from_str(payload).unwrap(); + assert_eq!( + parsed.checkout_sha, + Some("def456789012345678901234567890abcdef12".to_string()) + ); + assert_eq!(parsed.after, Some("abc123".to_string())); + } + + #[test] + fn test_parse_gitlab_mr_payload() { + let payload = r#"{ + "object_kind": "merge_request", + "object_attributes": { + "iid": 123, + "action": "open", + "source_branch": "feature", + "target_branch": "main", + "last_commit": {"id": "abc123def456"}, + "draft": false, + "work_in_progress": false + } + }"#; + + let parsed: GitLabMergeRequestPayload = + serde_json::from_str(payload).unwrap(); + let attrs = parsed.object_attributes.unwrap(); + assert_eq!(attrs.iid, Some(123)); + assert_eq!(attrs.action, Some("open".to_string())); + assert_eq!(attrs.source_branch, Some("feature".to_string())); + assert_eq!(attrs.target_branch, Some("main".to_string())); + assert_eq!(attrs.draft, Some(false)); + assert_eq!(attrs.work_in_progress, Some(false)); + } + + #[test] + fn test_parse_gitlab_mr_draft() { + let payload = r#"{ + "object_kind": "merge_request", + "object_attributes": { + "iid": 999, + "action": "open", + "draft": true + } + }"#; + + let parsed: GitLabMergeRequestPayload = + serde_json::from_str(payload).unwrap(); + let attrs = parsed.object_attributes.unwrap(); + assert_eq!(attrs.draft, Some(true)); + } + + #[test] + fn test_parse_gitlab_mr_wip() { + let payload = r#"{ + "object_kind": "merge_request", + "object_attributes": { + "iid": 888, + "action": "open", + "work_in_progress": true + } + }"#; + + let parsed: GitLabMergeRequestPayload = + serde_json::from_str(payload).unwrap(); + let attrs = parsed.object_attributes.unwrap(); + assert_eq!(attrs.work_in_progress, Some(true)); + } + + #[test] + fn test_parse_gitea_push_payload() { + let payload = r#"{ + "ref": "refs/heads/main", + "after": "abc123def456789012345678901234567890abcd" + }"#; + + let parsed: GiteaPushPayload = serde_json::from_str(payload).unwrap(); + assert_eq!( + parsed.after, + Some("abc123def456789012345678901234567890abcd".to_string()) + ); + assert_eq!(parsed.git_ref, Some("refs/heads/main".to_string())); + } + + #[test] + fn test_branch_deletion_detection() { + // The null SHA indicates branch deletion + let commit = "0000000000000000000000000000000000000000"; + assert!( + commit.is_empty() || commit == "0000000000000000000000000000000000000000" + ); + } } diff --git a/nix/tests/webhooks.nix b/nix/tests/webhooks.nix new file mode 100644 index 0000000..b640c2d --- /dev/null +++ b/nix/tests/webhooks.nix @@ -0,0 +1,402 @@ +# Webhook and PR integration tests +{ + pkgs, + fc-packages, + nixosModule, +}: +pkgs.testers.nixosTest { + name = "fc-webhooks"; + + nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;}; + + testScript = '' + import hashlib + import json + + machine.start() + machine.wait_for_unit("postgresql.service") + + # Ensure PostgreSQL is actually ready to accept connections before fc-server starts + machine.wait_until_succeeds("sudo -u fc psql -U fc -d fc -c 'SELECT 1'", timeout=30) + + machine.wait_for_unit("fc-server.service") + + # Wait for the server to start listening + machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30) + + # ---- Seed an API key for write operations ---- + api_token = "fc_testkey123" + api_hash = hashlib.sha256(api_token.encode()).hexdigest() + machine.succeed( + f"sudo -u fc psql -U fc -d fc -c \"INSERT INTO api_keys (name, key_hash, role) VALUES ('test', '{api_hash}', 'admin')\"" + ) + auth_header = f"-H 'Authorization: Bearer {api_token}'" + + # ---- Create a test project for webhook tests ---- + with subtest("Create test project for webhooks"): + result = machine.succeed( + "curl -sf -X POST http://127.0.0.1:3000/api/v1/projects " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"name\": \"webhook-test\", \"repository_url\": \"https://github.com/test/webhook-repo\"}' " + "| jq -r .id" + ) + project_id = result.strip() + assert len(project_id) == 36, f"Expected UUID, got '{project_id}'" + + # ---- Create a jobset for the project ---- + with subtest("Create jobset for webhook project"): + result = machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"name\": \"main\", \"nix_expression\": \"packages\", \"enabled\": true}' " + "| jq -r .id" + ) + jobset_id = result.strip() + assert len(jobset_id) == 36, f"Expected UUID, got '{jobset_id}'" + + # ======================================================================== + # GitHub Webhook Tests + # ======================================================================== + + with subtest("GitHub webhook returns 404 when not configured"): + code = machine.succeed( + "curl -s -o /dev/null -w '%{http_code}' " + f"-X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github " + "-H 'Content-Type: application/json' " + "-H 'X-GitHub-Event: push' " + "-d '{\"after\": \"abc123def456\"}'" + ) + # 200 with accepted=false (no webhook configured) + assert code.strip() in ("200", "404"), f"Expected 200 or 404, got {code.strip()}" + + # Configure GitHub webhook for the project + with subtest("Configure GitHub webhook"): + result = machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/webhooks " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"forge_type\": \"github\", \"secret\": \"test-secret\"}' " + "| jq -r .id" + ) + webhook_id = result.strip() + assert len(webhook_id) == 36, f"Expected UUID, got '{webhook_id}'" + + with subtest("GitHub push webhook triggers evaluation"): + # First count evaluations + count_before = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t" + ).strip() + + # Send push event (signature would normally be verified but we're testing) + # We need to compute HMAC-SHA256 signature + payload = '{"ref":"refs/heads/main","after":"abc123def456789012345678901234567890abcd"}' + + import hmac + sig = hmac.new(b"test-secret", payload.encode(), hashlib.sha256).hexdigest() + + machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github " + "-H 'Content-Type: application/json' " + "-H 'X-GitHub-Event: push' " + f"-H 'X-Hub-Signature-256: sha256={sig}' " + f"-d '{payload}'" + ) + + # Check evaluation was created + count_after = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t" + ).strip() + assert int(count_after) > int(count_before), \ + f"Expected new evaluation, count before={count_before}, after={count_after}" + + with subtest("GitHub push webhook rejects invalid signature"): + result = machine.succeed( + f"curl -s -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github " + "-H 'Content-Type: application/json' " + "-H 'X-GitHub-Event: push' " + "-H 'X-Hub-Signature-256: sha256=invalidsig' " + "-d '{\"after\": \"xyz789\"}' | jq -r .accepted" + ) + assert result.strip() == "false", f"Expected accepted=false for invalid sig, got {result.strip()}" + + with subtest("GitHub push webhook skips branch deletion"): + deletion_payload = '{"after": "0000000000000000000000000000000000000000"}' + deletion_sig = hmac.new(b"test-secret", deletion_payload.encode(), hashlib.sha256).hexdigest() + + result = machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github " + "-H 'Content-Type: application/json' " + "-H 'X-GitHub-Event: push' " + f"-H 'X-Hub-Signature-256: sha256={deletion_sig}' " + f"-d '{deletion_payload}' " + "| jq -r .message" + ) + assert "deletion" in result.lower() or "skip" in result.lower(), \ + f"Expected deletion event to be skipped, got: {result}" + + with subtest("GitHub pull_request webhook triggers evaluation with PR metadata"): + count_before = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations WHERE pr_number IS NOT NULL\" -t" + ).strip() + + payload = json.dumps({ + "action": "opened", + "number": 42, + "pull_request": { + "head": {"sha": "pr123abc456def789012345678901234567890ab", "ref": "feature-branch"}, + "base": {"sha": "base456", "ref": "main"}, + "draft": False + } + }) + + sig = hmac.new(b"test-secret", payload.encode(), hashlib.sha256).hexdigest() + + machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github " + "-H 'Content-Type: application/json' " + "-H 'X-GitHub-Event: pull_request' " + f"-H 'X-Hub-Signature-256: sha256={sig}' " + f"-d '{payload}'" + ) + + count_after = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations WHERE pr_number IS NOT NULL\" -t" + ).strip() + assert int(count_after) > int(count_before), \ + f"Expected PR evaluation, count before={count_before}, after={count_after}" + + # Verify PR metadata was stored + pr_data = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT pr_number, pr_head_branch, pr_base_branch FROM evaluations WHERE pr_number = 42\" -t" + ).strip() + assert "42" in pr_data, f"Expected PR number 42 in {pr_data}" + assert "feature-branch" in pr_data, f"Expected feature-branch in {pr_data}" + + with subtest("GitHub pull_request webhook skips draft PRs"): + payload = json.dumps({ + "action": "opened", + "number": 99, + "pull_request": { + "head": {"sha": "draft123", "ref": "draft-branch"}, + "base": {"sha": "base456", "ref": "main"}, + "draft": True + } + }) + + sig = hmac.new(b"test-secret", payload.encode(), hashlib.sha256).hexdigest() + + result = machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{project_id}/github " + "-H 'Content-Type: application/json' " + "-H 'X-GitHub-Event: pull_request' " + f"-H 'X-Hub-Signature-256: sha256={sig}' " + f"-d '{payload}' | jq -r .message" + ) + assert "draft" in result.lower(), f"Expected draft PR to be skipped, got: {result}" + + # ======================================================================== + # GitLab Webhook Tests + # ======================================================================== + + # Create a GitLab project + with subtest("Create GitLab test project"): + result = machine.succeed( + "curl -sf -X POST http://127.0.0.1:3000/api/v1/projects " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"name\": \"gitlab-test\", \"repository_url\": \"https://gitlab.com/test/repo\"}' " + "| jq -r .id" + ) + gitlab_project_id = result.strip() + assert len(gitlab_project_id) == 36, f"Expected UUID, got '{gitlab_project_id}'" + + with subtest("Create jobset for GitLab project"): + machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{gitlab_project_id}/jobsets " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"name\": \"main\", \"nix_expression\": \"packages\", \"enabled\": true}'" + ) + + with subtest("Configure GitLab webhook"): + result = machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{gitlab_project_id}/webhooks " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"forge_type\": \"gitlab\", \"secret\": \"gitlab-token\"}' " + "| jq -r .id" + ) + assert len(result.strip()) == 36 + + with subtest("GitLab Push Hook triggers evaluation"): + count_before = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t" + ).strip() + + machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitlab_project_id}/gitlab " + "-H 'Content-Type: application/json' " + "-H 'X-Gitlab-Event: Push Hook' " + "-H 'X-Gitlab-Token: gitlab-token' " + "-d '{\"ref\":\"refs/heads/main\",\"checkout_sha\":\"gitlab123456789012345678901234567890abcd\"}'" + ) + + count_after = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t" + ).strip() + assert int(count_after) > int(count_before), \ + "Expected new evaluation from GitLab push" + + with subtest("GitLab webhook rejects invalid token"): + result = machine.succeed( + f"curl -s -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitlab_project_id}/gitlab " + "-H 'Content-Type: application/json' " + "-H 'X-Gitlab-Event: Push Hook' " + "-H 'X-Gitlab-Token: wrong-token' " + "-d '{\"checkout_sha\":\"abc123\"}' | jq -r .accepted" + ) + assert result.strip() == "false", "Expected rejected for wrong token" + + with subtest("GitLab Merge Request Hook triggers evaluation with PR metadata"): + count_before = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations WHERE pr_number IS NOT NULL\" -t" + ).strip() + + payload = json.dumps({ + "object_kind": "merge_request", + "object_attributes": { + "iid": 123, + "action": "open", + "source_branch": "feature", + "target_branch": "main", + "last_commit": {"id": "mr123abc456def789012345678901234567890ab"}, + "draft": False, + "work_in_progress": False + } + }) + + machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitlab_project_id}/gitlab " + "-H 'Content-Type: application/json' " + "-H 'X-Gitlab-Event: Merge Request Hook' " + "-H 'X-Gitlab-Token: gitlab-token' " + f"-d '{payload}'" + ) + + count_after = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations WHERE pr_number IS NOT NULL\" -t" + ).strip() + assert int(count_after) > int(count_before), \ + f"Expected MR evaluation, count before={count_before}, after={count_after}" + + with subtest("GitLab Merge Request Hook skips draft MRs"): + payload = json.dumps({ + "object_kind": "merge_request", + "object_attributes": { + "iid": 999, + "action": "open", + "source_branch": "draft-feature", + "target_branch": "main", + "last_commit": {"id": "draft123"}, + "draft": True + } + }) + + result = machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitlab_project_id}/gitlab " + "-H 'Content-Type: application/json' " + "-H 'X-Gitlab-Event: Merge Request Hook' " + "-H 'X-Gitlab-Token: gitlab-token' " + f"-d '{payload}' | jq -r .message" + ) + assert "draft" in result.lower() or "wip" in result.lower(), \ + f"Expected draft MR to be skipped, got: {result}" + + # ======================================================================== + # Gitea/Forgejo Webhook Tests + # ======================================================================== + + with subtest("Create Gitea test project"): + result = machine.succeed( + "curl -sf -X POST http://127.0.0.1:3000/api/v1/projects " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"name\": \"gitea-test\", \"repository_url\": \"https://gitea.example.com/test/repo\"}' " + "| jq -r .id" + ) + gitea_project_id = result.strip() + + with subtest("Create jobset for Gitea project"): + machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{gitea_project_id}/jobsets " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"name\": \"main\", \"nix_expression\": \"packages\", \"enabled\": true}'" + ) + + with subtest("Configure Gitea webhook"): + machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{gitea_project_id}/webhooks " + f"{auth_header} " + "-H 'Content-Type: application/json' " + "-d '{\"forge_type\": \"gitea\", \"secret\": \"gitea-secret\"}'" + ) + + with subtest("Gitea push webhook triggers evaluation"): + count_before = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t" + ).strip() + + payload = '{"ref":"refs/heads/main","after":"gitea123456789012345678901234567890abcd"}' + sig = hmac.new(b"gitea-secret", payload.encode(), hashlib.sha256).hexdigest() + + machine.succeed( + f"curl -sf -X POST http://127.0.0.1:3000/api/v1/webhooks/{gitea_project_id}/gitea " + "-H 'Content-Type: application/json' " + f"-H 'X-Gitea-Signature: {sig}' " + f"-d '{payload}'" + ) + + count_after = machine.succeed( + "sudo -u fc psql -U fc -d fc -c \"SELECT COUNT(*) FROM evaluations\" -t" + ).strip() + assert int(count_after) > int(count_before), \ + "Expected new evaluation from Gitea push" + + # ======================================================================== + # OAuth Routes Existence Tests + # ======================================================================== + + with subtest("GitHub OAuth login route exists"): + # Should redirect or return 404 if not configured + code = machine.succeed( + "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/auth/github" + ) + # 302 (redirect) or 404 (not configured) are both acceptable + assert code.strip() in ("302", "404"), f"Expected 302 or 404, got {code.strip()}" + + with subtest("GitHub OAuth callback route exists"): + code = machine.succeed( + "curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:3000/api/v1/auth/github/callback?code=test&state=test'" + ) + # Should fail gracefully (no OAuth configured) + assert code.strip() in ("400", "404", "500"), f"Expected error code, got {code.strip()}" + + # ======================================================================== + # Cleanup + # ======================================================================== + + with subtest("Cleanup test projects"): + machine.succeed( + f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{project_id} {auth_header}" + ) + machine.succeed( + f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{gitlab_project_id} {auth_header}" + ) + machine.succeed( + f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{gitea_project_id} {auth_header}" + ) + ''; +}