tirbofish/kitgit · diff
merge: feat/webhooks into integrate/all-features
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -0,0 +1,27 @@ +-- Repository webhooks + delivery log + +CREATE TABLE webhooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + url TEXT NOT NULL, + secret TEXT NOT NULL DEFAULT '', + events TEXT[] NOT NULL DEFAULT '{}', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT webhooks_url_http CHECK (url ~* '^https?://') +); +CREATE INDEX webhooks_repo_id_idx ON webhooks(repo_id); + +CREATE TABLE webhook_deliveries ( + id BIGSERIAL PRIMARY KEY, + webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, + event TEXT NOT NULL, + action TEXT NOT NULL DEFAULT '', + success BOOLEAN NOT NULL DEFAULT FALSE, + status_code INT, + error TEXT, + duration_ms INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX webhook_deliveries_webhook_id_idx ON webhook_deliveries(webhook_id); +CREATE INDEX webhook_deliveries_created_at_idx ON webhook_deliveries(created_at DESC); @@ -271,6 +271,30 @@ pub struct CommitDay { pub count: i32, } +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Webhook { + pub id: Uuid, + pub repo_id: Uuid, + pub url: String, + pub secret: String, + pub events: Vec<String>, + pub active: bool, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct WebhookDelivery { + pub id: i64, + pub webhook_id: Uuid, + pub event: String, + pub action: String, + pub success: bool, + pub status_code: Option<i32>, + pub error: Option<String>, + pub duration_ms: Option<i32>, + pub created_at: DateTime<Utc>, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Access { @@ -2484,3 +2484,171 @@ pub async fn list_audit_log_for_user( .fetch_all(pool) .await?) } + +// ── webhooks ───────────────────────────────────────────────────────────────── + +pub async fn list_webhooks(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Webhook>> { + Ok(sqlx::query_as::<_, Webhook>( + "SELECT * FROM webhooks WHERE repo_id = $1 ORDER BY created_at DESC", + ) + .bind(repo_id) + .fetch_all(pool) + .await?) +} + +pub async fn list_active_webhooks_for_event( + pool: &PgPool, + repo_id: Uuid, + event: &str, +) -> Result<Vec<Webhook>> { + Ok(sqlx::query_as::<_, Webhook>( + r#" + SELECT * FROM webhooks + WHERE repo_id = $1 AND active = TRUE AND $2 = ANY(events) + ORDER BY created_at + "#, + ) + .bind(repo_id) + .bind(event) + .fetch_all(pool) + .await?) +} + +pub async fn get_webhook(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Result<Option<Webhook>> { + Ok(sqlx::query_as::<_, Webhook>( + "SELECT * FROM webhooks WHERE id = $1 AND repo_id = $2", + ) + .bind(id) + .bind(repo_id) + .fetch_optional(pool) + .await?) +} + +pub async fn create_webhook( + pool: &PgPool, + repo_id: Uuid, + url: &str, + secret: &str, + events: &[String], +) -> Result<Webhook> { + Ok(sqlx::query_as::<_, Webhook>( + r#" + INSERT INTO webhooks (repo_id, url, secret, events) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(repo_id) + .bind(url) + .bind(secret) + .bind(events) + .fetch_one(pool) + .await?) +} + +pub async fn set_webhook_active( + pool: &PgPool, + id: Uuid, + repo_id: Uuid, + active: bool, +) -> Result<()> { + sqlx::query("UPDATE webhooks SET active = $3 WHERE id = $1 AND repo_id = $2") + .bind(id) + .bind(repo_id) + .bind(active) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn delete_webhook(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM webhooks WHERE id = $1 AND repo_id = $2") + .bind(id) + .bind(repo_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn record_webhook_delivery( + pool: &PgPool, + webhook_id: Uuid, + event: &str, + action: &str, + success: bool, + status_code: Option<i32>, + error: Option<&str>, + duration_ms: i32, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO webhook_deliveries + (webhook_id, event, action, success, status_code, error, duration_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + ) + .bind(webhook_id) + .bind(event) + .bind(action) + .bind(success) + .bind(status_code) + .bind(error) + .bind(duration_ms) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_recent_webhook_deliveries( + pool: &PgPool, + repo_id: Uuid, + limit: i64, +) -> Result<Vec<(WebhookDelivery, String)>> { + #[derive(sqlx::FromRow)] + struct Row { + id: i64, + webhook_id: Uuid, + event: String, + action: String, + success: bool, + status_code: Option<i32>, + error: Option<String>, + duration_ms: Option<i32>, + created_at: DateTime<Utc>, + webhook_url: String, + } + let rows = sqlx::query_as::<_, Row>( + r#" + SELECT d.id, d.webhook_id, d.event, d.action, d.success, d.status_code, + d.error, d.duration_ms, d.created_at, w.url AS webhook_url + FROM webhook_deliveries d + JOIN webhooks w ON w.id = d.webhook_id + WHERE w.repo_id = $1 + ORDER BY d.created_at DESC + LIMIT $2 + "#, + ) + .bind(repo_id) + .bind(limit) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| { + ( + WebhookDelivery { + id: r.id, + webhook_id: r.webhook_id, + event: r.event, + action: r.action, + success: r.success, + status_code: r.status_code, + error: r.error, + duration_ms: r.duration_ms, + created_at: r.created_at, + }, + r.webhook_url, + ) + }) + .collect()) +} @@ -166,6 +166,18 @@ async fn post_push_hooks( .bind(repo.id) .execute(&state.pool) .await; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_PUSH, + "push".into(), + repo.clone(), + owner.to_string(), + Some(user.clone()), + serde_json::json!({ + "ref": format!("refs/heads/{}", repo.default_branch), + "default_branch": repo.default_branch, + }), + ); Ok(()) } @@ -367,6 +367,7 @@ impl Handler for SshHandler { let actor_id = self .user_id .or_else(|| self.deploy_key.as_ref().and_then(|d| d.created_by)); + let mut sender = None; if let Some(uid) = actor_id { if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await { let _ = queries::record_activity( @@ -385,6 +386,7 @@ impl Handler for SshHandler { 1, ) .await; + sender = Some(user); } } if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name) { @@ -393,6 +395,18 @@ impl Handler for SshHandler { let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await; } } + crate::webhooks::spawn_dispatch( + self.state.pool.clone(), + crate::webhooks::EVENT_PUSH, + "push".into(), + repo.clone(), + owner, + sender, + serde_json::json!({ + "ref": format!("refs/heads/{}", repo.default_branch), + "default_branch": repo.default_branch, + }), + ); Ok(()) } } @@ -8,6 +8,7 @@ mod mfa; mod og; mod state; mod web; +mod webhooks; use crate::auth::AuthState; use crate::config::Config; @@ -275,6 +275,18 @@ pub fn app_router(state: AppState) -> Router { post(routes::deploy_key_delete), ) .route( + "/{owner}/{repo}/settings/webhooks", + post(routes::webhook_add), + ) + .route( + "/{owner}/{repo}/settings/webhooks/{id}/delete", + post(routes::webhook_delete), + ) + .route( + "/{owner}/{repo}/settings/webhooks/{id}/toggle", + post(routes::webhook_toggle), + ) + .route( "/{owner}/{repo}/settings/branch-rules", post(routes_extra::branch_rule_add), ) @@ -2830,6 +2830,20 @@ pub async fn issue_create( serde_json::json!({ "number": number }), ) .await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_ISSUES, + "opened".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "number": issue.number, + "title": issue.title, + "body": issue.body, + "state": issue.state, + }), + ); Ok(redirect_see_other(&format!( "/{owner}/{repo}/issues/{}", issue.number @@ -2933,6 +2947,20 @@ pub async fn issue_close( return Err(AppError::forbidden()); } queries::set_issue_state(&state.pool, issue.id, "closed").await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_ISSUES, + "closed".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "number": issue.number, + "title": issue.title, + "body": issue.body, + "state": "closed", + }), + ); Ok(redirect_see_other(&format!( "/{owner}/{repo}/issues/{number}" ))) @@ -2953,6 +2981,20 @@ pub async fn issue_reopen( return Err(AppError::forbidden()); } queries::set_issue_state(&state.pool, issue.id, "open").await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_ISSUES, + "reopened".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "number": issue.number, + "title": issue.title, + "body": issue.body, + "state": "open", + }), + ); Ok(redirect_see_other(&format!( "/{owner}/{repo}/issues/{number}" ))) @@ -3085,6 +3127,22 @@ pub async fn pull_create( serde_json::json!({ "number": number }), ) .await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_PULL_REQUEST, + "opened".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "number": pull.number, + "title": pull.title, + "body": pull.body, + "state": pull.state, + "source_branch": pull.source_branch, + "target_branch": pull.target_branch, + }), + ); Ok(redirect_see_other(&format!( "/{owner}/{repo}/pulls/{}", pull.number @@ -3285,6 +3343,23 @@ pub async fn pull_merge( serde_json::json!({ "number": number, "commit": merge_commit }), ) .await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_PULL_REQUEST, + "merged".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "number": pull.number, + "title": pull.title, + "body": pull.body, + "state": "merged", + "source_branch": pull.source_branch, + "target_branch": pull.target_branch, + "merge_commit": merge_commit, + }), + ); Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}"))) } @@ -3306,6 +3381,22 @@ pub async fn pull_close( return Err(AppError::bad("pull is not open")); } queries::set_pull_state(&state.pool, pull.id, "closed", None).await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_PULL_REQUEST, + "closed".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "number": pull.number, + "title": pull.title, + "body": pull.body, + "state": "closed", + "source_branch": pull.source_branch, + "target_branch": pull.target_branch, + }), + ); Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}"))) } @@ -3497,6 +3588,21 @@ pub async fn release_create( serde_json::json!({ "tag": tag }), ) .await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_RELEASE, + "created".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "tag_name": release.tag_name, + "title": release.title, + "body": release.body, + "is_prerelease": release.is_prerelease, + "is_draft": release.is_draft, + }), + ); Ok(redirect_see_other(&format!( "/{owner}/{repo}/releases/{}", release.tag_name @@ -3716,6 +3822,21 @@ pub async fn release_update( serde_json::json!({ "tag": updated.tag_name }), ) .await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_RELEASE, + "edited".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "tag_name": updated.tag_name, + "title": updated.title, + "body": updated.body, + "is_prerelease": updated.is_prerelease, + "is_draft": updated.is_draft, + }), + ); Ok(redirect_see_other(&format!( "/{owner}/{repo}/releases/{}", updated.tag_name @@ -3760,6 +3881,21 @@ pub async fn release_delete( serde_json::json!({ "tag": tag }), ) .await?; + crate::webhooks::spawn_dispatch( + state.pool.clone(), + crate::webhooks::EVENT_RELEASE, + "deleted".into(), + repository.clone(), + owner.clone(), + Some(user.clone()), + serde_json::json!({ + "tag_name": tag, + "title": release.title, + "body": release.body, + "is_prerelease": release.is_prerelease, + "is_draft": release.is_draft, + }), + ); Ok(redirect_see_other(&format!("/{owner}/{repo}/releases"))) } @@ -3925,8 +4061,46 @@ pub async fn repo_settings( } let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); let branch_rules = queries::list_branch_rules(&state.pool, repository.id).await?; -let mirror = queries::get_repo_mirror(&state.pool, repository.id).await?; + let mirror = queries::get_repo_mirror(&state.pool, repository.id).await?; let deploy_keys = crate::db::deploy_keys::list_deploy_keys(&state.pool, repository.id).await?; + let webhooks: Vec<WebhookView> = queries::list_webhooks(&state.pool, repository.id) + .await? + .into_iter() + .map(|h| WebhookView { + id: h.id, + url: h.url, + has_secret: !h.secret.is_empty(), + events_label: if h.events.is_empty() { + "(none)".into() + } else { + h.events.join(", ") + }, + active: h.active, + created_at: h.created_at.to_rfc3339(), + }) + .collect(); + let webhook_deliveries: Vec<WebhookDeliveryView> = + queries::list_recent_webhook_deliveries(&state.pool, repository.id, 20) + .await? + .into_iter() + .map(|(d, url)| { + let status_label = match (d.success, d.status_code, d.error.as_deref()) { + (true, Some(code), _) => format!("{code}"), + (false, Some(code), Some(err)) => format!("{code}: {err}"), + (false, Some(code), None) => format!("{code}"), + (false, None, Some(err)) => err.to_string(), + _ => "unknown".into(), + }; + WebhookDeliveryView { + event: d.event, + action: d.action, + success: d.success, + status_label, + webhook_url: url, + created_at: d.created_at.to_rfc3339(), + } + }) + .collect(); Ok(RepoSettingsTemplate { viewer, owner: owner_user, @@ -3939,6 +4113,8 @@ let mirror = queries::get_repo_mirror(&state.pool, repository.id).await?; branch_rules, mirror, deploy_keys, + webhooks, + webhook_deliveries, is_site_admin, error: None, }) @@ -4124,6 +4300,84 @@ pub async fn deploy_key_delete( } #[derive(Deserialize)] +pub struct WebhookAddForm { + pub url: String, + pub secret: Option<String>, + pub event_push: Option<String>, + pub event_issues: Option<String>, + pub event_pull_request: Option<String>, + pub event_release: Option<String>, +} + +pub async fn webhook_add( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<WebhookAddForm>, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let url = form.url.trim(); + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err(AppError::bad("url must start with http:// or https://")); + } + let mut events = Vec::new(); + if checkbox(&form.event_push) { + events.push(crate::webhooks::EVENT_PUSH.to_string()); + } + if checkbox(&form.event_issues) { + events.push(crate::webhooks::EVENT_ISSUES.to_string()); + } + if checkbox(&form.event_pull_request) { + events.push(crate::webhooks::EVENT_PULL_REQUEST.to_string()); + } + if checkbox(&form.event_release) { + events.push(crate::webhooks::EVENT_RELEASE.to_string()); + } + let events = crate::webhooks::normalize_events(&events); + if events.is_empty() { + return Err(AppError::bad("select at least one event")); + } + let secret = form.secret.as_deref().unwrap_or("").trim(); + queries::create_webhook(&state.pool, repository.id, url, secret, &events).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +pub async fn webhook_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, Uuid)>, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + queries::delete_webhook(&state.pool, id, repository.id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +pub async fn webhook_toggle( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, Uuid)>, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let hook = queries::get_webhook(&state.pool, id, repository.id) + .await? + .ok_or_else(AppError::not_found)?; + queries::set_webhook_active(&state.pool, id, repository.id, !hook.active).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +#[derive(Deserialize)] pub struct ArchiveForm { pub archived: Option<String>, } @@ -79,6 +79,24 @@ pub struct CollaboratorView { pub avatar_url: String, } +pub struct WebhookView { + pub id: Uuid, + pub url: String, + pub has_secret: bool, + pub events_label: String, + pub active: bool, + pub created_at: String, +} + +pub struct WebhookDeliveryView { + pub event: String, + pub action: String, + pub success: bool, + pub status_label: String, + pub webhook_url: String, + pub created_at: String, +} + pub struct LanguageStatView { pub name: String, pub percent: f64, @@ -532,8 +550,10 @@ pub struct RepoSettingsTemplate { pub collaborators: Vec<CollaboratorView>, pub branches: Vec<String>, pub branch_rules: Vec<BranchRule>, -pub mirror: Option<RepoMirror>, + pub mirror: Option<RepoMirror>, pub deploy_keys: Vec<DeployKey>, + pub webhooks: Vec<WebhookView>, + pub webhook_deliveries: Vec<WebhookDeliveryView>, pub clone_http: String, pub clone_ssh: String, pub is_site_admin: bool, @@ -0,0 +1,189 @@ +//! Repository webhook delivery (JSON POST + optional HMAC-SHA256). + +use crate::db::models::{Repository, User, Webhook}; +use crate::db::queries; +use anyhow::Result; +use hmac::{Hmac, Mac}; +use serde_json::{json, Value}; +use sha2::Sha256; +use sqlx::PgPool; +use std::time::Instant; +use uuid::Uuid; + +type HmacSha256 = Hmac<Sha256>; + +pub const EVENT_PUSH: &str = "push"; +pub const EVENT_ISSUES: &str = "issues"; +pub const EVENT_PULL_REQUEST: &str = "pull_request"; +pub const EVENT_RELEASE: &str = "release"; + +pub const ALL_EVENTS: &[&str] = &[EVENT_PUSH, EVENT_ISSUES, EVENT_PULL_REQUEST, EVENT_RELEASE]; + +pub fn normalize_events(raw: &[String]) -> Vec<String> { + let mut out = Vec::new(); + for e in raw { + let e = e.trim(); + if ALL_EVENTS.contains(&e) && !out.iter().any(|x| x == e) { + out.push(e.to_string()); + } + } + out +} + +fn sign_body(secret: &str, body: &[u8]) -> Option<String> { + if secret.is_empty() { + return None; + } + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?; + mac.update(body); + Some(format!( + "sha256={}", + hex::encode(mac.finalize().into_bytes()) + )) +} + +fn build_envelope( + event: &str, + action: &str, + delivery_id: Uuid, + repo: &Repository, + owner: &str, + sender: Option<&User>, + payload: Value, +) -> Value { + json!({ + "event": event, + "action": action, + "delivery_id": delivery_id.to_string(), + "repository": { + "id": repo.id, + "name": repo.name, + "owner": owner, + "full_name": format!("{owner}/{}", repo.name), + "default_branch": repo.default_branch, + "visibility": repo.visibility, + "private": repo.visibility == "private", + }, + "sender": sender.map(|u| json!({ + "id": u.id, + "username": u.username, + "display_name": u.display_name, + })), + "payload": payload, + }) +} + +/// Fire matching active webhooks for a repo. Failures are logged; callers should ignore errors. +pub async fn dispatch( + pool: &PgPool, + event: &str, + action: &str, + repo: &Repository, + owner: &str, + sender: Option<&User>, + payload: Value, +) -> Result<()> { + let hooks = queries::list_active_webhooks_for_event(pool, repo.id, event).await?; + if hooks.is_empty() { + return Ok(()); + } + let delivery_id = Uuid::new_v4(); + let body_val = build_envelope(event, action, delivery_id, repo, owner, sender, payload); + let body = serde_json::to_vec(&body_val)?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("kitgit-webhooks/0.1") + .build()?; + + for hook in hooks { + deliver_one(pool, &client, &hook, event, action, delivery_id, &body).await; + } + Ok(()) +} + +/// Spawn a background dispatch so request handlers stay fast. +pub fn spawn_dispatch( + pool: PgPool, + event: &'static str, + action: String, + repo: Repository, + owner: String, + sender: Option<User>, + payload: Value, +) { + tokio::spawn(async move { + if let Err(e) = dispatch( + &pool, + event, + &action, + &repo, + &owner, + sender.as_ref(), + payload, + ) + .await + { + tracing::warn!("webhook dispatch failed: {e:#}"); + } + }); +} + +async fn deliver_one( + pool: &PgPool, + client: &reqwest::Client, + hook: &Webhook, + event: &str, + action: &str, + delivery_id: Uuid, + body: &[u8], +) { + let started = Instant::now(); + let mut req = client + .post(&hook.url) + .header("Content-Type", "application/json") + .header("X-Kitgit-Event", event) + .header("X-Kitgit-Delivery", delivery_id.to_string()) + .header("X-Kitgit-Action", action) + .body(body.to_vec()); + + if let Some(sig) = sign_body(&hook.secret, body) { + req = req.header("X-Hub-Signature-256", sig); + } + + let (success, status_code, error) = match req.send().await { + Ok(resp) => { + let code = resp.status().as_u16() as i32; + let ok = resp.status().is_success(); + let err = if ok { + None + } else { + let text = resp.text().await.unwrap_or_default(); + let truncated: String = text.chars().take(500).collect(); + Some(if truncated.is_empty() { + format!("HTTP {code}") + } else { + format!("HTTP {code}: {truncated}") + }) + }; + (ok, Some(code), err) + } + Err(e) => (false, None, Some(e.to_string())), + }; + + let duration_ms = started.elapsed().as_millis().min(i32::MAX as u128) as i32; + if let Err(e) = queries::record_webhook_delivery( + pool, + hook.id, + event, + action, + success, + status_code, + error.as_deref(), + duration_ms, + ) + .await + { + tracing::warn!("failed to record webhook delivery: {e:#}"); + } +} @@ -284,6 +284,72 @@ </form> </div> + <div class="kg-settings-section"> + <span class="kg-kicker">webhooks</span> + <p class="kg-muted">HTTP callbacks for repository events. Optional secret signs payloads as <code>X-Hub-Signature-256</code>.</p> + {% if webhooks.is_empty() %} + <p class="kg-muted">No webhooks yet.</p> + {% else %} + <ul class="kg-list"> + {% for hook in webhooks %} + <li> + <span> + <code>{{ hook.url }}</code> + <span class="kg-meta">{{ hook.events_label }}</span> + {% if hook.has_secret %}<span class="kg-badge">signed</span>{% endif %} + {% if hook.active %}<span class="kg-badge">active</span>{% else %}<span class="kg-badge">inactive</span>{% endif %} + </span> + <span class="kg-actions" style="gap:0.35rem"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/webhooks/{{ hook.id }}/toggle"> + <button class="kg-btn kg-btn--ghost" type="submit">{% if hook.active %}disable{% else %}enable{% endif %}</button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/webhooks/{{ hook.id }}/delete"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button> + </form> + </span> + </li> + {% endfor %} + </ul> + {% endif %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/webhooks" style="margin-top:1rem"> + <label> + payload URL + <input type="url" name="url" required placeholder="https://example.com/hooks/kitgit" /> + </label> + <label> + secret <span class="kg-muted">(optional)</span> + <input type="text" name="secret" placeholder="HMAC secret" autocomplete="off" /> + </label> + <fieldset style="border:0;padding:0;margin:0"> + <legend class="kg-muted" style="margin-bottom:0.35rem">events</legend> + <label class="row"><input type="checkbox" name="event_push" checked /> push</label> + <label class="row"><input type="checkbox" name="event_issues" checked /> issues</label> + <label class="row"><input type="checkbox" name="event_pull_request" checked /> pull_request</label> + <label class="row"><input type="checkbox" name="event_release" checked /> release</label> + </fieldset> + <div class="kg-actions"> + <button class="kg-btn" type="submit">add webhook</button> + </div> + </form> + {% if !webhook_deliveries.is_empty() %} + <div style="margin-top:1.25rem"> + <span class="kg-kicker">recent deliveries</span> + <ul class="kg-list"> + {% for d in webhook_deliveries %} + <li> + <span> + <strong>{{ d.event }}</strong>{% if !d.action.is_empty() %}.{{ d.action }}{% endif %} + <span class="kg-badge">{% if d.success %}ok{% else %}fail{% endif %}</span> + <span class="kg-meta">{{ d.status_label }} · {{ d.created_at }}</span> + <span class="kg-meta">{{ d.webhook_url }}</span> + </span> + </li> + {% endfor %} + </ul> + </div> + {% endif %} + </div> + {% if access.can_owner() || is_site_admin %} <div class="kg-danger-zone"> <span class="kg-kicker">danger zone</span>