kitgit

tirbofish/kitgit · diff

a0dfd68 · Thribhu K

feat: add repository webhooks with delivery status

Verified

diff --git a/migrations/014_webhooks.sql b/migrations/014_webhooks.sql
new file mode 100644
index 0000000..880aed9
--- /dev/null
+++ b/migrations/014_webhooks.sql
@@ -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);
\ No newline at end of file
diff --git a/src/db/models.rs b/src/db/models.rs
index 874e00c..e790600 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -234,6 +234,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 {
diff --git a/src/db/queries.rs b/src/db/queries.rs
index a236a11..ee2240d 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -2349,3 +2349,171 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> {
         .await?;
     Ok(row.map(|r| r.0))
 }
+
+// ── 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())
+}
diff --git a/src/git/http.rs b/src/git/http.rs
index 05a5e73..a816ab9 100644
--- a/src/git/http.rs
+++ b/src/git/http.rs
@@ -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(())
 }
 
diff --git a/src/git/ssh.rs b/src/git/ssh.rs
index 95d2ba2..35c86fc 100644
--- a/src/git/ssh.rs
+++ b/src/git/ssh.rs
@@ -337,6 +337,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,
+                        Some(user),
+                        serde_json::json!({
+                            "ref": format!("refs/heads/{}", repo.default_branch),
+                            "default_branch": repo.default_branch,
+                        }),
+                    );
                 }
             }
         }
diff --git a/src/main.rs b/src/main.rs
index 2a913a5..9be6dec 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,6 +8,7 @@ mod mfa;
 mod og;
 mod state;
 mod web;
+mod webhooks;
 
 use crate::auth::AuthState;
 use crate::config::Config;
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c4b4cbb..67af9cd 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -262,6 +262,18 @@ pub fn app_router(state: AppState) -> Router {
             post(routes::collab_remove),
         )
         .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),
         )
diff --git a/src/web/routes.rs b/src/web/routes.rs
index c080f6f..ada5271 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -2649,6 +2649,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
@@ -2752,6 +2766,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}"
     )))
@@ -2772,6 +2800,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}"
     )))
@@ -2904,6 +2946,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
@@ -3104,6 +3162,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}")))
 }
 
@@ -3125,6 +3200,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}")))
 }
 
@@ -3316,6 +3407,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
@@ -3535,6 +3641,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
@@ -3579,6 +3700,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")))
 }
 
@@ -3743,6 +3879,44 @@ 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 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();
     let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false);
     Ok(RepoSettingsTemplate {
         viewer,
@@ -3754,6 +3928,8 @@ pub async fn repo_settings(
         clone_http,
         clone_ssh,
         branch_rules,
+        webhooks,
+        webhook_deliveries,
         is_site_admin,
         error: None,
     })
@@ -3884,6 +4060,84 @@ pub async fn collab_remove(
 }
 
 #[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>,
 }
diff --git a/src/web/templates.rs b/src/web/templates.rs
index a2932d4..b1b61c8 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -78,6 +78,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,
@@ -521,6 +539,8 @@ pub struct RepoSettingsTemplate {
     pub collaborators: Vec<CollaboratorView>,
     pub branches: Vec<String>,
     pub branch_rules: Vec<BranchRule>,
+    pub webhooks: Vec<WebhookView>,
+    pub webhook_deliveries: Vec<WebhookDeliveryView>,
     pub clone_http: String,
     pub clone_ssh: String,
     pub is_site_admin: bool,
diff --git a/src/webhooks.rs b/src/webhooks.rs
new file mode 100644
index 0000000..f2af0e6
--- /dev/null
+++ b/src/webhooks.rs
@@ -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:#}");
+    }
+}
\ No newline at end of file
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
index effe71d..b3ffe41 100644
--- a/templates/repo_settings.html
+++ b/templates/repo_settings.html
@@ -185,6 +185,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>