mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add GitHub webhook listener via Tailscale funnel
Adds a webhook subsystem that receives GitHub App webhooks when configured in server.toml with [git.webhooks] strategy = "tailscale_funnel". On startup, it binds a local HTTP listener on a random port, exposes it via `tailscale funnel`, and patches the GitHub App webhook URL. Incoming webhooks are verified with HMAC-SHA256 before processing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
46d170468e
commit
d838fda004
8 changed files with 544 additions and 2 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -142,10 +142,13 @@ dependencies = [
|
|||
"arc-workflows",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
|
|
@ -158,6 +161,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ indicatif = "0.18"
|
|||
termimad = "0.34"
|
||||
toml = "0.8"
|
||||
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
openssh = "0.11"
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ toml.workspace = true
|
|||
tracing.workspace = true
|
||||
ulid.workspace = true
|
||||
uuid.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
reqwest.workspace = true
|
||||
bytes = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
|
|
@ -50,4 +55,3 @@ http-body-util = "0.1"
|
|||
tempfile = "3"
|
||||
openapiv3 = "2"
|
||||
serde_yaml = "0.9"
|
||||
reqwest = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -3062,6 +3062,7 @@ mod settings {
|
|||
client_id: Some("Iv1.abc123".into()),
|
||||
slug: Some("arc-dev".into()),
|
||||
author: Default::default(),
|
||||
webhooks: None,
|
||||
},
|
||||
feature_flags: FeatureFlags {
|
||||
session_sandboxes: false,
|
||||
|
|
|
|||
439
crates/arc-api/src/github_webhooks.rs
Normal file
439
crates/arc-api/src/github_webhooks.rs
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
use axum::body::Bytes;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Verify a GitHub webhook HMAC-SHA256 signature.
|
||||
///
|
||||
/// `signature_header` is the value of the `X-Hub-Signature-256` header,
|
||||
/// expected in the form `sha256=<hex-digest>`.
|
||||
pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool {
|
||||
let hex_digest = match signature_header.strip_prefix("sha256=") {
|
||||
Some(h) => h,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let expected = match hex::decode(hex_digest) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let mut mac = match HmacSha256::new_from_slice(secret) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(body);
|
||||
mac.verify_slice(&expected).is_ok()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WebhookState {
|
||||
secret: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn webhook_handler(
|
||||
State(state): State<WebhookState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
let signature = match headers
|
||||
.get("x-hub-signature-256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let delivery_id = headers
|
||||
.get("x-github-delivery")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
warn!(delivery = %delivery_id, "Webhook signature verification failed");
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
};
|
||||
|
||||
if !verify_signature(&state.secret, &body, signature) {
|
||||
let delivery_id = headers
|
||||
.get("x-github-delivery")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
warn!(delivery = %delivery_id, "Webhook signature verification failed");
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
let event_type = headers
|
||||
.get("x-github-event")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
let delivery_id = headers
|
||||
.get("x-github-delivery")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let (repo, action) = parse_event_metadata(&body);
|
||||
|
||||
debug!(
|
||||
event = %event_type,
|
||||
delivery = %delivery_id,
|
||||
repo = %repo,
|
||||
action = %action,
|
||||
"Webhook received"
|
||||
);
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
fn parse_event_metadata(body: &[u8]) -> (String, String) {
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap_or_default();
|
||||
let repo = parsed
|
||||
.get("repository")
|
||||
.and_then(|r| r.get("full_name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let action = parsed
|
||||
.get("action")
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("none")
|
||||
.to_string();
|
||||
(repo, action)
|
||||
}
|
||||
|
||||
/// A running webhook listener that can be shut down.
|
||||
pub struct WebhookListener {
|
||||
port: u16,
|
||||
shutdown_tx: tokio::sync::oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
impl WebhookListener {
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
let _ = self.shutdown_tx.send(());
|
||||
info!("Webhook listener stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the webhook HTTP listener on a random port (127.0.0.1 only).
|
||||
pub async fn spawn_webhook_listener(secret: Vec<u8>) -> anyhow::Result<WebhookListener> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let state = WebhookState { secret };
|
||||
let router = Router::new()
|
||||
.route("/webhooks/github", post(webhook_handler))
|
||||
.with_state(state);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
});
|
||||
|
||||
info!(port = port, "Webhook listener started");
|
||||
|
||||
Ok(WebhookListener { port, shutdown_tx })
|
||||
}
|
||||
|
||||
/// Manage the full webhook lifecycle: listener + tailscale funnel + GitHub API.
|
||||
pub struct WebhookManager {
|
||||
listener: WebhookListener,
|
||||
funnel_port: u16,
|
||||
}
|
||||
|
||||
impl WebhookManager {
|
||||
/// Start the webhook system: spawn listener, enable Tailscale funnel,
|
||||
/// and update the GitHub App webhook URL.
|
||||
pub async fn start(
|
||||
secret: Vec<u8>,
|
||||
app_id: &str,
|
||||
private_key_pem: &str,
|
||||
) -> anyhow::Result<Self> {
|
||||
let listener = spawn_webhook_listener(secret).await?;
|
||||
let port = listener.port();
|
||||
|
||||
// Enable Tailscale funnel
|
||||
let funnel_url = match enable_tailscale_funnel(port).await {
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
error!(error = %err, "Failed to enable Tailscale funnel");
|
||||
listener.shutdown();
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
info!(url = %funnel_url, "Tailscale funnel enabled");
|
||||
|
||||
// Update GitHub App webhook URL
|
||||
let webhook_url = format!("{funnel_url}/webhooks/github");
|
||||
if let Err(err) = update_github_app_webhook(app_id, private_key_pem, &webhook_url).await {
|
||||
error!(error = %err, "Failed to update GitHub App webhook URL");
|
||||
disable_tailscale_funnel(port).await;
|
||||
listener.shutdown();
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
info!(url = %webhook_url, "GitHub App webhook URL updated");
|
||||
|
||||
Ok(Self {
|
||||
listener,
|
||||
funnel_port: port,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shut down: disable funnel, stop listener.
|
||||
pub async fn shutdown(self) {
|
||||
disable_tailscale_funnel(self.funnel_port).await;
|
||||
self.listener.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
async fn enable_tailscale_funnel(port: u16) -> anyhow::Result<String> {
|
||||
let output = tokio::process::Command::new("tailscale")
|
||||
.args(["funnel", &port.to_string()])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("tailscale funnel failed: {stderr}");
|
||||
}
|
||||
|
||||
// Get the funnel URL from `tailscale funnel status`
|
||||
let status_output = tokio::process::Command::new("tailscale")
|
||||
.args(["funnel", "status"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&status_output.stdout);
|
||||
// Parse the HTTPS URL from status output — first line typically contains it
|
||||
let url = stdout
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("https://") {
|
||||
// Strip trailing path/colon info
|
||||
Some(trimmed.trim_end_matches('/').to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}"))?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn disable_tailscale_funnel(port: u16) {
|
||||
match tokio::process::Command::new("tailscale")
|
||||
.args(["funnel", "off", &port.to_string()])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
info!("Tailscale funnel disabled");
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!(error = %stderr, "Failed to disable Tailscale funnel");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to disable Tailscale funnel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_github_app_webhook(
|
||||
app_id: &str,
|
||||
private_key_pem: &str,
|
||||
webhook_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let jwt = arc_workflows::github_app::sign_app_jwt(app_id, private_key_pem)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"url": webhook_url,
|
||||
"content_type": "json",
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.patch("https://api.github.com/app/hook/config")
|
||||
.header("Authorization", format!("Bearer {jwt}"))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "arc")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("GitHub API returned {status}: {text}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use tower::ServiceExt;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// verify_signature
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn compute_signature(secret: &[u8], body: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||
mac.update(body);
|
||||
let result = mac.finalize();
|
||||
format!("sha256={}", hex::encode(result.into_bytes()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_signature() {
|
||||
let secret = b"test-secret";
|
||||
let body = b"hello world";
|
||||
let sig = compute_signature(secret, body);
|
||||
assert!(verify_signature(secret, body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_signature() {
|
||||
let secret = b"test-secret";
|
||||
let body = b"hello world";
|
||||
let sig = compute_signature(b"wrong-secret", body);
|
||||
assert!(!verify_signature(secret, body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_sha256_prefix() {
|
||||
let secret = b"test-secret";
|
||||
let body = b"hello world";
|
||||
let mut sig = compute_signature(secret, body);
|
||||
sig = sig.replace("sha256=", "");
|
||||
assert!(!verify_signature(secret, body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_valid_signature() {
|
||||
let secret = b"test-secret";
|
||||
let body = b"";
|
||||
let sig = compute_signature(secret, body);
|
||||
assert!(verify_signature(secret, body, &sig));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// webhook_handler
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn build_test_router(secret: &[u8]) -> Router {
|
||||
let state = WebhookState {
|
||||
secret: secret.to_vec(),
|
||||
};
|
||||
Router::new()
|
||||
.route("/webhooks/github", post(webhook_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_missing_signature() {
|
||||
let app = build_test_router(b"secret");
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhooks/github")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_bad_signature() {
|
||||
let app = build_test_router(b"secret");
|
||||
let body = b"{}";
|
||||
let bad_sig = compute_signature(b"wrong", body);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhooks/github")
|
||||
.header("x-hub-signature-256", bad_sig)
|
||||
.body(Body::from(body.to_vec()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_valid_webhook() {
|
||||
let secret = b"my-secret";
|
||||
let app = build_test_router(secret);
|
||||
let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#;
|
||||
let sig = compute_signature(secret, body);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhooks/github")
|
||||
.header("x-hub-signature-256", sig)
|
||||
.header("x-github-event", "pull_request")
|
||||
.header("x-github-delivery", "abc-123")
|
||||
.body(Body::from(body.to_vec()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// spawn_webhook_listener
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_listener_serves_route() {
|
||||
let secret = b"integration-secret";
|
||||
let listener = spawn_webhook_listener(secret.to_vec()).await.unwrap();
|
||||
let port = listener.port();
|
||||
|
||||
// Valid request should return 200
|
||||
let body = b"{}";
|
||||
let sig = compute_signature(secret, body);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("http://127.0.0.1:{port}/webhooks/github"))
|
||||
.header("x-hub-signature-256", sig)
|
||||
.body(body.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Missing signature should return 401
|
||||
let resp = client
|
||||
.post(format!("http://127.0.0.1:{port}/webhooks/github"))
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 401);
|
||||
|
||||
listener.shutdown();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
mod demo;
|
||||
pub mod error;
|
||||
pub mod github_webhooks;
|
||||
pub mod jwt_auth;
|
||||
pub mod serve;
|
||||
pub mod server;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::time::Duration;
|
|||
use arc_llm::provider::Provider;
|
||||
use arc_util::terminal::Styles;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use clap::Args;
|
||||
|
||||
|
|
@ -167,6 +167,41 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
|
|||
eprintln!("{}", styles.dim.apply_to("(dry-run mode)"));
|
||||
}
|
||||
|
||||
// Optionally start webhook listener
|
||||
let webhook_manager = {
|
||||
let cfg = shared_config.read().expect("config lock poisoned");
|
||||
match (&cfg.git.webhooks, &cfg.git.app_id) {
|
||||
(Some(_webhook_config), Some(app_id)) => {
|
||||
let secret = std::env::var("GITHUB_APP_WEBHOOK_SECRET").ok();
|
||||
let private_key_pem = read_github_private_key();
|
||||
match (secret, private_key_pem) {
|
||||
(Some(secret), Some(pem)) => {
|
||||
let app_id = app_id.clone();
|
||||
drop(cfg);
|
||||
match crate::github_webhooks::WebhookManager::start(
|
||||
secret.into_bytes(),
|
||||
&app_id,
|
||||
&pem,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(manager) => Some(manager),
|
||||
Err(err) => {
|
||||
error!(error = %err, "Failed to start webhook listener");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("Webhook config present but GITHUB_APP_WEBHOOK_SECRET or GITHUB_APP_PRIVATE_KEY not set; skipping webhook listener");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn config polling task
|
||||
let config_for_poll = Arc::clone(&shared_config);
|
||||
let config_path_for_poll = config_path.clone();
|
||||
|
|
@ -214,6 +249,11 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
|
|||
axum::serve(listener, router).await?;
|
||||
}
|
||||
|
||||
// Clean up webhook listener on shutdown
|
||||
if let Some(manager) = webhook_manager {
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +304,18 @@ fn resolve_model_provider(
|
|||
(model, provider_enum)
|
||||
}
|
||||
|
||||
/// Read the GitHub App private key from the environment, decoding base64 if needed.
|
||||
fn read_github_private_key() -> Option<String> {
|
||||
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
|
||||
if raw.starts_with("-----") {
|
||||
Some(raw)
|
||||
} else {
|
||||
let pem_bytes =
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &raw).ok()?;
|
||||
String::from_utf8(pem_bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive client certificate verification mode from the resolved auth strategies.
|
||||
fn client_auth_from_mode(auth_mode: &AuthMode) -> ClientAuth {
|
||||
let strategies = match auth_mode {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,17 @@ pub struct GitAuthorConfig {
|
|||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebhookStrategy {
|
||||
TailscaleFunnel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
pub struct WebhookConfig {
|
||||
pub strategy: WebhookStrategy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct GitConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -80,6 +91,7 @@ pub struct GitConfig {
|
|||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author: GitAuthorConfig,
|
||||
pub webhooks: Option<WebhookConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
||||
|
|
@ -510,6 +522,32 @@ exclude_globs = ["**/node_modules/**", "**/.cache/**"]
|
|||
assert!(config.run_defaults.checkpoint.exclude_globs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_git_webhooks_config() {
|
||||
let toml = r#"
|
||||
[git]
|
||||
provider = "github"
|
||||
app_id = "2993730"
|
||||
|
||||
[git.webhooks]
|
||||
strategy = "tailscale_funnel"
|
||||
"#;
|
||||
let config: ServerConfig = toml::from_str(toml).unwrap();
|
||||
let webhooks = config.git.webhooks.unwrap();
|
||||
assert_eq!(webhooks.strategy, WebhookStrategy::TailscaleFunnel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_git_webhooks_missing_is_none() {
|
||||
let toml = r#"
|
||||
[git]
|
||||
provider = "github"
|
||||
app_id = "123"
|
||||
"#;
|
||||
let config: ServerConfig = toml::from_str(toml).unwrap();
|
||||
assert!(config.git.webhooks.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_log_config() {
|
||||
let toml = "[log]\nlevel = \"trace\"";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue