use http::StatusCode; use reqwest::Method; use sqlx::PgConnection; use std::collections::HashMap; use windmill_common::{ error::{Error, Result}, BASE_URL, DB, }; use crate::{ generate_webhook_service_url, github::{ routes, CreateWebhookResponse, GitHub, GithubOAuthData, GithubServiceConfig, GithubTriggerData, GithubWebhookApiResponse, GITHUB_API_BASE, }, http_error_status, External, NativeTrigger, NativeTriggerData, PushArgsOwned, ServiceName, }; #[async_trait::async_trait] impl External for GitHub { type ServiceConfig = GithubServiceConfig; type TriggerData = GithubTriggerData; type OAuthData = GithubOAuthData; type CreateResponse = CreateWebhookResponse; const SERVICE_NAME: ServiceName = ServiceName::Github; const DISPLAY_NAME: &'static str = "GitHub"; const SUPPORT_WEBHOOK: bool = true; const TOKEN_ENDPOINT: &'static str = "https://github.com/login/oauth/access_token"; const REFRESH_ENDPOINT: &'static str = "https://github.com/login/oauth/access_token"; const AUTH_ENDPOINT: &'static str = "https://github.com/login/oauth/authorize"; async fn create( &self, w_id: &str, oauth_data: &Self::OAuthData, webhook_token: &str, data: &NativeTriggerData, db: &DB, _tx: &mut PgConnection, ) -> Result { let base_url = &**BASE_URL.load(); // During create, we don't have external_id yet (GitHub assigns the hook ID) let webhook_url = generate_webhook_service_url( base_url, w_id, &data.script_path, data.is_flow, None, ServiceName::Github, webhook_token, ); let url = format!( "{}/repos/{}/{}/hooks", GITHUB_API_BASE, data.service_config.owner, data.service_config.repo ); let payload = serde_json::json!({ "name": "web", "active": true, "events": data.service_config.events, "config": { "url": webhook_url, "content_type": "json", "insecure_ssl": "0" } }); let response: CreateWebhookResponse = self .http_client_request( &url, Method::POST, w_id, &oauth_data.connection_path, db, None, Some(&payload), ) .await?; Ok(response) } async fn update( &self, w_id: &str, oauth_data: &Self::OAuthData, external_id: &str, webhook_token: &str, data: &NativeTriggerData, db: &DB, _tx: &mut PgConnection, ) -> Result { let base_url = &**BASE_URL.load(); // Now we have external_id, include it in the webhook URL let webhook_url = generate_webhook_service_url( base_url, w_id, &data.script_path, data.is_flow, Some(external_id), ServiceName::Github, webhook_token, ); let url = format!( "{}/repos/{}/{}/hooks/{}", GITHUB_API_BASE, data.service_config.owner, data.service_config.repo, external_id ); let payload = serde_json::json!({ "active": true, "events": data.service_config.events, "config": { "url": webhook_url, "content_type": "json", "insecure_ssl": "0" } }); let _: serde_json::Value = self .http_client_request( &url, Method::PATCH, w_id, &oauth_data.connection_path, db, None, Some(&payload), ) .await?; // Return the resolved service_config serde_json::to_value(&data.service_config) .map_err(|e| Error::internal_err(format!("Failed to serialize service config: {}", e))) } async fn get( &self, w_id: &str, oauth_data: &Self::OAuthData, external_id: &str, db: &DB, _tx: &mut PgConnection, ) -> Result> { // We need owner/repo to construct the API URL — fetch from DB let (owner, repo) = match self.get_owner_repo_from_db(db, w_id, external_id).await? { Some(pair) => pair, None => return Ok(None), }; let data = self .get_webhook( w_id, &oauth_data.connection_path, &owner, &repo, external_id, db, ) .await?; Ok(Some(data)) } async fn delete( &self, w_id: &str, oauth_data: &Self::OAuthData, external_id: &str, db: &DB, _tx: &mut PgConnection, ) -> Result<()> { // We need owner/repo to construct the API URL — fetch from DB. // Missing row means the trigger was already removed; other DB errors propagate. let (owner, repo) = match self.get_owner_repo_from_db(db, w_id, external_id).await? { Some(pair) => pair, None => return Ok(()), }; let url = format!( "{}/repos/{}/{}/hooks/{}", GITHUB_API_BASE, owner, repo, external_id ); // Swallow 404 only (webhook may already be deleted on GitHub); propagate // other errors so callers don't think cleanup succeeded when it didn't. let result: std::result::Result = self .http_client_request::<_, ()>( &url, Method::DELETE, w_id, &oauth_data.connection_path, db, None, None, ) .await; match result { Ok(_) => Ok(()), Err(e) if http_error_status(&e) == Some(StatusCode::NOT_FOUND) => Ok(()), Err(e) => Err(e), } } async fn maintain_triggers( &self, db: &DB, workspace_id: &str, triggers: &[NativeTrigger], oauth_data: &Self::OAuthData, synced: &mut Vec, errors: &mut Vec, ) { // GitHub webhooks don't expire, but we verify they still exist for trigger in triggers { let config: GithubServiceConfig = match trigger .service_config .as_ref() .and_then(|v| serde_json::from_value(v.clone()).ok()) { Some(c) => c, None => { errors.push(crate::sync::SyncError { resource_path: trigger.script_path.clone(), error_message: "Invalid service config".to_string(), error_type: "config_error".to_string(), }); continue; } }; let url = format!( "{}/repos/{}/{}/hooks/{}", GITHUB_API_BASE, config.owner, config.repo, trigger.external_id ); let result: std::result::Result = self .http_client_request::<_, ()>( &url, Method::GET, workspace_id, &oauth_data.connection_path, db, None, None, ) .await; match result { Ok(_response) => { // Webhook still exists — clear any previous error if trigger.error.is_some() { synced.push(crate::sync::TriggerSyncInfo { external_id: trigger.external_id.clone(), script_path: trigger.script_path.clone(), action: crate::sync::SyncAction::ErrorCleared, }); } } Err(e) if http_error_status(&e) == Some(StatusCode::NOT_FOUND) => { errors.push(crate::sync::SyncError { resource_path: trigger.script_path.clone(), error_message: "Webhook no longer exists on GitHub".to_string(), error_type: "not_found".to_string(), }); } Err(e) => { errors.push(crate::sync::SyncError { resource_path: trigger.script_path.clone(), error_message: format!( "Failed to verify GitHub webhook: {}", crate::external_error_message(&e) ), error_type: "api_error".to_string(), }); } } } } async fn prepare_webhook( &self, _db: &DB, _w_id: &str, headers: HashMap, body: String, _script_path: &str, _is_flow: bool, ) -> Result { use serde_json::value::to_raw_value; let mut args = HashMap::new(); let event = headers.get("x-github-event").cloned().unwrap_or_default(); let delivery_id = headers .get("x-github-delivery") .cloned() .unwrap_or_default(); let payload: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); args.insert( "event".to_string(), to_raw_value(&event).map_err(|e| Error::internal_err(e.to_string()))?, ); args.insert( "delivery_id".to_string(), to_raw_value(&delivery_id).map_err(|e| Error::internal_err(e.to_string()))?, ); args.insert( "payload".to_string(), to_raw_value(&payload).map_err(|e| Error::internal_err(e.to_string()))?, ); Ok(PushArgsOwned { extra: None, args }) } fn external_id_and_metadata_from_response( &self, resp: &Self::CreateResponse, ) -> (String, Option) { (resp.id.to_string(), None) } fn additional_routes(&self) -> axum::Router { routes::github_routes(self.clone()) } fn error_hint(&self, status: StatusCode) -> Option<&'static str> { match status { StatusCode::UNAUTHORIZED => { Some("reconnect the GitHub account this uses with Reconnect next to it, or ask its owner or an admin to.") } StatusCode::FORBIDDEN => Some( "check that the OAuth app is authorized for the organization and that the \ connected GitHub account has the access this needs — managing webhooks requires \ admin rights on the repository.", ), _ => None, } } /// GitHub answers 403 to both throttling and a permission failure, and words the throttle /// two ways: the primary and secondary limits say "rate limit", the older secondary /// response says "abuse detection mechanism". fn is_transient_response(&self, status: StatusCode, body: &str) -> bool { if status != StatusCode::FORBIDDEN { return false; } let body = body.to_ascii_lowercase(); body.contains("rate limit") || body.contains("abuse detection") } } impl GitHub { /// Fetch owner/repo from the stored service_config in the DB. /// Returns `Ok(None)` if the trigger row is missing; propagates other DB errors. async fn get_owner_repo_from_db( &self, db: &DB, w_id: &str, external_id: &str, ) -> Result> { let config = sqlx::query_scalar!( r#" SELECT service_config FROM native_trigger WHERE external_id = $1 AND service_name = $2 AND workspace_id = $3 "#, external_id, ServiceName::Github as ServiceName, w_id ) .fetch_optional(db) .await? .flatten(); let Some(config) = config else { return Ok(None); }; let owner = config .get("owner") .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); let repo = config .get("repo") .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); Ok(Some((owner, repo))) } /// Fetch a webhook from GitHub API. async fn get_webhook( &self, w_id: &str, connection_path: &str, owner: &str, repo: &str, external_id: &str, db: &DB, ) -> Result { let url = format!( "{}/repos/{}/{}/hooks/{}", GITHUB_API_BASE, owner, repo, external_id ); let response: GithubWebhookApiResponse = self .http_client_request::<_, ()>(&url, Method::GET, w_id, connection_path, db, None, None) .await?; Ok(GithubTriggerData { id: response.id, active: response.active, events: response.events, owner: owner.to_string(), repo: repo.to_string(), }) } }