mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 08:03:33 +00:00
429 lines
13 KiB
Rust
429 lines
13 KiB
Rust
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<Self::ServiceConfig>,
|
|
db: &DB,
|
|
_tx: &mut PgConnection,
|
|
) -> Result<Self::CreateResponse> {
|
|
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<Self::ServiceConfig>,
|
|
db: &DB,
|
|
_tx: &mut PgConnection,
|
|
) -> Result<serde_json::Value> {
|
|
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<Option<Self::TriggerData>> {
|
|
// 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<serde_json::Value, _> = 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<crate::sync::TriggerSyncInfo>,
|
|
errors: &mut Vec<crate::sync::SyncError>,
|
|
) {
|
|
// 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<GithubWebhookApiResponse, _> = 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<String, String>,
|
|
body: String,
|
|
_script_path: &str,
|
|
_is_flow: bool,
|
|
) -> Result<PushArgsOwned> {
|
|
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<serde_json::Value>) {
|
|
(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<Option<(String, String)>> {
|
|
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<GithubTriggerData> {
|
|
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(),
|
|
})
|
|
}
|
|
}
|