From d2223833f5dc53eebf872319eaadc5a22b63fc48 Mon Sep 17 00:00:00 2001 From: bobdivx <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:41:41 +0200 Subject: [PATCH] feat(github-app): P3 app-level webhook (HMAC, anti-replay, event routing, shared handle_push) --- apps/server/src/auto_deploy.rs | 1 + apps/server/src/db.rs | 8 + apps/server/src/github_app_events.rs | 535 ++++++++++++++++++ apps/server/src/github_app_events_tests.rs | 351 ++++++++++++ apps/server/src/github_app_routes.rs | 11 +- apps/server/src/infra_routes.rs | 116 +++- apps/server/src/main.rs | 2 + .../github/check_run_rerequested.json | 1 + .../github/check_suite_rerequested.json | 1 + .../fixtures/github/installation_created.json | 4 + .../fixtures/github/installation_deleted.json | 1 + .../installation_repositories_added.json | 1 + .../installation_repositories_removed.json | 1 + .../fixtures/github/installation_suspend.json | 1 + .../github/installation_unsuspend.json | 1 + apps/server/tests/fixtures/github/ping.json | 1 + .../fixtures/github/pull_request_opened.json | 1 + apps/server/tests/fixtures/github/push.json | 6 + .../tests/fixtures/github/push_deleted.json | 3 + 19 files changed, 1023 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/github_app_events.rs create mode 100644 apps/server/src/github_app_events_tests.rs create mode 100644 apps/server/tests/fixtures/github/check_run_rerequested.json create mode 100644 apps/server/tests/fixtures/github/check_suite_rerequested.json create mode 100644 apps/server/tests/fixtures/github/installation_created.json create mode 100644 apps/server/tests/fixtures/github/installation_deleted.json create mode 100644 apps/server/tests/fixtures/github/installation_repositories_added.json create mode 100644 apps/server/tests/fixtures/github/installation_repositories_removed.json create mode 100644 apps/server/tests/fixtures/github/installation_suspend.json create mode 100644 apps/server/tests/fixtures/github/installation_unsuspend.json create mode 100644 apps/server/tests/fixtures/github/ping.json create mode 100644 apps/server/tests/fixtures/github/pull_request_opened.json create mode 100644 apps/server/tests/fixtures/github/push.json create mode 100644 apps/server/tests/fixtures/github/push_deleted.json diff --git a/apps/server/src/auto_deploy.rs b/apps/server/src/auto_deploy.rs index 1f6829f91..7a777a0e2 100644 --- a/apps/server/src/auto_deploy.rs +++ b/apps/server/src/auto_deploy.rs @@ -277,6 +277,7 @@ pub(crate) async fn deploy_project( } async fn tick(state: &AppState, ensure_webhooks: bool) { + crate::github_app_events::purge_old_deliveries(&state.pool).await; let projects = sqlx::query_as::<_, Project>( r#"SELECT * FROM projects WHERE auto_deploy != 0 diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index f6480a11d..8d7f42ff3 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -1057,6 +1057,14 @@ pub async fn migrate_github_app(pool: &PgPool) -> Result<(), sqlx::Error> { ] { sqlx::query(sql).execute(pool).await?; } + // P3 : observabilité des livraisons webhook app-level. + for sql in [ + "ALTER TABLE github_webhook_deliveries ADD COLUMN duration_ms BIGINT", + "ALTER TABLE github_webhook_deliveries ADD COLUMN installation_id BIGINT", + "ALTER TABLE github_webhook_deliveries ADD COLUMN action TEXT", + ] { + let _ = sqlx::query(sql).execute(pool).await; + } // P1 : type du propriétaire (User/Organization) pour le lien de gestion GitHub. let _ = sqlx::query("ALTER TABLE github_app ADD COLUMN owner_type TEXT NOT NULL DEFAULT 'User'") diff --git a/apps/server/src/github_app_events.rs b/apps/server/src/github_app_events.rs new file mode 100644 index 000000000..606d123f2 --- /dev/null +++ b/apps/server/src/github_app_events.rs @@ -0,0 +1,535 @@ +//! GitHub App — P3 : webhook app-level `POST /api/v1/webhooks/github-app`. +//! +//! Sécurité : HMAC-SHA256 du corps brut avec le `webhook_secret` scellé (comparaison +//! temps constant), aucun bypass « insecure », corps ≤ 25 Mo, anti-rejeu par +//! `X-GitHub-Delivery`, installation connue obligatoire. Réponse immédiate ; le +//! traitement tourne dans une tâche détachée. + +use std::time::Instant; + +use axum::{ + extract::{DefaultBodyLimit, State}, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Json, Router, +}; +use hmac::{Hmac, Mac}; +use serde_json::{json, Value}; +use sha2::Sha256; +use sqlx::PgPool; + +use crate::infra_routes::PushScope; +use crate::state::AppState; + +/// Limite GitHub pour un payload webhook. +pub const MAX_BODY: usize = 25 * 1024 * 1024; +pub const RETENTION_DAYS: i32 = 7; + +pub fn router() -> Router { + Router::new() + .route( + "/api/v1/webhooks/github-app", + post(webhook).layer(DefaultBodyLimit::max(MAX_BODY)), + ) + .route("/api/v1/github-app/deliveries", get(deliveries)) +} + +/// `sha256=` — comparaison temps constant (`verify_slice`). +pub fn verify_signature(secret: &[u8], body: &[u8], header: &str) -> bool { + let Some(hex) = header.trim().strip_prefix("sha256=") else { + return false; + }; + let Some(sig) = decode_hex(hex) else { + return false; + }; + let Ok(mut mac) = Hmac::::new_from_slice(secret) else { + return false; + }; + mac.update(body); + mac.verify_slice(&sig).is_ok() +} + +fn decode_hex(s: &str) -> Option> { + if s.len() != 64 { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()) + .collect() +} + +#[cfg(test)] +pub fn sign(secret: &[u8], body: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(secret).unwrap(); + mac.update(body); + let out = mac.finalize().into_bytes(); + format!( + "sha256={}", + out.iter().map(|b| format!("{b:02x}")).collect::() + ) +} + +async fn load_webhook_secret(pool: &PgPool) -> Option>> { + let (enc,): (String,) = + sqlx::query_as("SELECT webhook_secret_enc FROM github_app WHERE id = 1") + .fetch_optional(pool) + .await + .ok() + .flatten()?; + match crate::secret_box::open(&enc) { + Ok(s) => Some(s), + Err(e) => { + tracing::error!("webhook GitHub App : secret illisible : {e}"); + None + } + } +} + +fn header<'a>(h: &'a HeaderMap, name: &str) -> &'a str { + h.get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .trim() +} + +/// Événement accepté, à traiter hors requête. +#[derive(Debug)] +pub struct Accepted { + pub delivery_id: String, + pub event: String, + pub action: String, + pub installation_id: Option, + pub payload: Value, +} + +#[derive(Debug)] +pub enum Outcome { + Reply(StatusCode, Value), + Process(Box), +} + +fn reply(code: StatusCode, v: Value) -> Outcome { + Outcome::Reply(code, v) +} + +async fn set_status(pool: &PgPool, delivery_id: &str, status: &str, duration_ms: Option) { + let _ = sqlx::query( + "UPDATE github_webhook_deliveries SET status = $2, duration_ms = COALESCE($3, duration_ms) WHERE delivery_id = $1", + ) + .bind(delivery_id) + .bind(status) + .bind(duration_ms) + .execute(pool) + .await; +} + +/// Vérifications synchrones (signature, anti-rejeu, installation). Aucun traitement lourd. +pub async fn receive(pool: &PgPool, headers: &HeaderMap, body: &[u8]) -> Outcome { + let sig = header(headers, "x-hub-signature-256"); + if sig.is_empty() { + return reply( + StatusCode::UNAUTHORIZED, + json!({"error": "signature absente"}), + ); + } + let Some(secret) = load_webhook_secret(pool).await else { + return reply( + StatusCode::SERVICE_UNAVAILABLE, + json!({"error": "GitHub App non configurée"}), + ); + }; + if !verify_signature(&secret, body, sig) { + return reply( + StatusCode::UNAUTHORIZED, + json!({"error": "signature invalide"}), + ); + } + let event = header(headers, "x-github-event").to_string(); + let delivery_id = header(headers, "x-github-delivery").to_string(); + if event.is_empty() || delivery_id.is_empty() || delivery_id.len() > 128 { + return reply( + StatusCode::BAD_REQUEST, + json!({"error": "en-têtes GitHub manquants"}), + ); + } + let Ok(payload) = serde_json::from_slice::(body) else { + return reply(StatusCode::BAD_REQUEST, json!({"error": "JSON invalide"})); + }; + let action = payload + .get("action") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let installation_id = payload.pointer("/installation/id").and_then(Value::as_i64); + + let inserted = sqlx::query( + r#"INSERT INTO github_webhook_deliveries (delivery_id, event, status, installation_id, action) + VALUES ($1, $2, 'received', $3, $4) ON CONFLICT (delivery_id) DO NOTHING"#, + ) + .bind(&delivery_id) + .bind(&event) + .bind(installation_id) + .bind(&action) + .execute(pool) + .await; + match inserted { + Ok(r) if r.rows_affected() == 0 => { + return reply(StatusCode::OK, json!({"ok": true, "duplicate": true})); + } + Ok(_) => {} + Err(e) => { + tracing::error!("webhook GitHub App : enregistrement livraison : {e}"); + return reply( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error": "erreur base"}), + ); + } + } + + if event == "ping" { + set_status(pool, &delivery_id, "processed", Some(0)).await; + return reply(StatusCode::OK, json!({"ok": true, "pong": true})); + } + + // `installation.created` peut précéder le retour `/installed` : on l'accepte. + let creates = event == "installation" && action == "created"; + let known = match installation_id { + Some(id) => sqlx::query_as::<_, (i64,)>( + "SELECT installation_id FROM github_installations WHERE installation_id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await + .ok() + .flatten() + .is_some(), + None => false, + }; + if !known && !creates { + tracing::info!(event = %event, ?installation_id, "webhook GitHub App : installation inconnue, ignoré"); + set_status(pool, &delivery_id, "ignored_unknown_installation", Some(0)).await; + return reply( + StatusCode::ACCEPTED, + json!({"ok": true, "ignored": "installation inconnue"}), + ); + } + Outcome::Process(Box::new(Accepted { + delivery_id, + event, + action, + installation_id, + payload, + })) +} + +async fn webhook( + State(state): State, + headers: HeaderMap, + body: axum::body::Bytes, +) -> (StatusCode, Json) { + match receive(&state.pool, &headers, &body).await { + Outcome::Reply(code, v) => (code, Json(v)), + Outcome::Process(acc) => { + let delivery = acc.delivery_id.clone(); + let event = acc.event.clone(); + tokio::spawn(async move { process(&state, *acc).await }); + ( + StatusCode::OK, + Json(json!({"ok": true, "accepted": event, "delivery": delivery})), + ) + } + } +} + +/// Traitement détaché ; statut final de la livraison mis à jour. +pub async fn process(state: &AppState, acc: Accepted) { + let t0 = Instant::now(); + let res = route(state, &acc).await; + let ms = t0.elapsed().as_millis() as i64; + let status = match &res { + Ok(s) => s.to_string(), + Err(e) => { + tracing::warn!(delivery = %acc.delivery_id, event = %acc.event, "webhook GitHub App : échec : {e}"); + "failed".into() + } + }; + set_status(&state.pool, &acc.delivery_id, &status, Some(ms)).await; +} + +async fn route(state: &AppState, acc: &Accepted) -> Result<&'static str, String> { + match acc.event.as_str() { + "installation" => apply_installation_event(&state.pool, &acc.action, &acc.payload).await, + "installation_repositories" => { + apply_installation_repositories(&state.pool, &acc.action, &acc.payload).await + } + "push" => { + let id = acc.installation_id.ok_or("installation absente")?; + crate::infra_routes::handle_push(state, &acc.payload, PushScope::Installation(id)) + .await + .map(|_| "processed") + .map_err(|(_, j)| j.0.to_string()) + } + "check_run" | "check_suite" if acc.action == "rerequested" => { + let id = acc.installation_id.ok_or("installation absente")?; + let Some(push) = rerequest_as_push(&acc.event, &acc.payload) else { + return Ok("ignored"); + }; + crate::infra_routes::handle_push(state, &push, PushScope::Installation(id)) + .await + .map(|_| "processed") + .map_err(|(_, j)| j.0.to_string()) + } + // Previews de PR : phase P5. + "pull_request" => Ok("ignored_until_p5"), + _ => Ok("ignored"), + } +} + +/// `check_run|check_suite.rerequested` ⇒ redéploiement de la branche du `head_sha`. +pub fn rerequest_as_push(event: &str, payload: &Value) -> Option { + let suite = if event == "check_run" { + payload.pointer("/check_run/check_suite")? + } else { + payload.get("check_suite")? + }; + let branch = suite.get("head_branch").and_then(Value::as_str)?; + let sha = suite.get("head_sha").and_then(Value::as_str)?; + Some(json!({ + "ref": format!("refs/heads/{branch}"), + "repository": payload.get("repository")?, + "installation": payload.get("installation"), + "head_commit": { "id": sha, "message": "Relance demandée depuis GitHub" }, + })) +} + +async fn upsert_installation( + pool: &PgPool, + inst: &Value, + suspended: Option, +) -> Result { + let id = inst + .get("id") + .and_then(Value::as_i64) + .ok_or("installation.id absent")?; + let login = inst + .pointer("/account/login") + .and_then(Value::as_str) + .unwrap_or(""); + let kind = inst + .pointer("/account/type") + .and_then(Value::as_str) + .unwrap_or(""); + let sel = inst + .get("repository_selection") + .and_then(Value::as_str) + .unwrap_or("selected"); + let perms = inst + .get("permissions") + .cloned() + .unwrap_or_else(|| json!({})); + let susp = match suspended { + Some(true) => Some( + inst.get("suspended_at") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), + ), + Some(false) => None, + None => inst + .get("suspended_at") + .and_then(Value::as_str) + .map(str::to_string), + }; + sqlx::query( + r#" + INSERT INTO github_installations (installation_id, account_login, account_type, + repository_selection, permissions, suspended_at) + VALUES ($1, $2, $3, $4, $5, $6::timestamptz) + ON CONFLICT (installation_id) DO UPDATE SET + account_login = EXCLUDED.account_login, account_type = EXCLUDED.account_type, + repository_selection = EXCLUDED.repository_selection, + permissions = EXCLUDED.permissions, suspended_at = EXCLUDED.suspended_at, + updated_at = now() + "#, + ) + .bind(id) + .bind(login) + .bind(kind) + .bind(sel) + .bind(&perms) + .bind(susp) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + Ok(id) +} + +async fn add_repos(pool: &PgPool, id: i64, repos: Option<&Value>) -> Result<(), String> { + for r in repos.and_then(Value::as_array).into_iter().flatten() { + let (Some(rid), Some(full)) = ( + r.get("id").and_then(Value::as_i64), + r.get("full_name").and_then(Value::as_str), + ) else { + continue; + }; + sqlx::query( + r#"INSERT INTO github_installation_repos (installation_id, repo_id, full_name, private) + VALUES ($1, $2, $3, $4) + ON CONFLICT (installation_id, repo_id) DO UPDATE SET full_name = EXCLUDED.full_name, + private = EXCLUDED.private"#, + ) + .bind(id) + .bind(rid) + .bind(full) + .bind(r.get("private").and_then(Value::as_bool).unwrap_or(false)) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +async fn remove_repos(pool: &PgPool, id: i64, repos: Option<&Value>) -> Result<(), String> { + for rid in repos + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|r| r.get("id").and_then(Value::as_i64)) + { + sqlx::query( + "DELETE FROM github_installation_repos WHERE installation_id = $1 AND repo_id = $2", + ) + .bind(id) + .bind(rid) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// `installation` : created / deleted / suspend / unsuspend / new_permissions_accepted. +/// `deleted` ⇒ installation oubliée ; les projets liés restent (mode « déconnecté »). +pub async fn apply_installation_event( + pool: &PgPool, + action: &str, + p: &Value, +) -> Result<&'static str, String> { + let inst = p.get("installation").ok_or("installation absente")?; + match action { + "created" => { + let id = upsert_installation(pool, inst, None).await?; + add_repos(pool, id, p.get("repositories")).await?; + } + "new_permissions_accepted" => { + upsert_installation(pool, inst, None).await?; + } + "suspend" => { + upsert_installation(pool, inst, Some(true)).await?; + } + "unsuspend" => { + upsert_installation(pool, inst, Some(false)).await?; + } + "deleted" => { + let id = inst + .get("id") + .and_then(Value::as_i64) + .ok_or("installation.id absent")?; + sqlx::query("DELETE FROM github_installations WHERE installation_id = $1") + .bind(id) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + } + _ => return Ok("ignored"), + } + Ok("processed") +} + +pub async fn apply_installation_repositories( + pool: &PgPool, + action: &str, + p: &Value, +) -> Result<&'static str, String> { + let id = p + .pointer("/installation/id") + .and_then(Value::as_i64) + .ok_or("installation.id absent")?; + if let Some(sel) = p.get("repository_selection").and_then(Value::as_str) { + let _ = sqlx::query( + "UPDATE github_installations SET repository_selection = $2, updated_at = now() WHERE installation_id = $1", + ) + .bind(id) + .bind(sel) + .execute(pool) + .await; + } + match action { + "added" => add_repos(pool, id, p.get("repositories_added")).await?, + "removed" => remove_repos(pool, id, p.get("repositories_removed")).await?, + _ => return Ok("ignored"), + } + Ok("processed") +} + +/// Purge des livraisons de plus de 7 jours (appelée par le tick auto-deploy). +pub async fn purge_old_deliveries(pool: &PgPool) { + let _ = sqlx::query( + "DELETE FROM github_webhook_deliveries WHERE received_at < now() - make_interval(days => $1)", + ) + .bind(RETENTION_DAYS) + .execute(pool) + .await; +} + +pub async fn recent_deliveries(pool: &PgPool) -> Result, sqlx::Error> { + let rows: Vec<( + String, + String, + Option, + String, + Option, + Option, + String, + )> = sqlx::query_as( + r#"SELECT delivery_id, event, action, status, duration_ms, installation_id, + to_char(received_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + FROM github_webhook_deliveries ORDER BY received_at DESC LIMIT 20"#, + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(d, e, a, s, ms, i, at)| { + json!({"delivery_id": d, "event": e, "action": a, "status": s, + "duration_ms": ms, "installation_id": i, "received_at": at}) + }) + .collect()) +} + +/// `GET /api/v1/github-app/deliveries` — 20 dernières, sans payload (admin). +async fn deliveries( + State(state): State, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let (user, _) = crate::auth_routes::current_workspace(&state, &headers).await?; + if user.role != "instance_admin" { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({"error": "Réservé à l’admin instance"})), + )); + } + let items = recent_deliveries(&state.pool).await.map_err(|e| { + tracing::error!("livraisons webhook : {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "erreur base"})), + ) + })?; + Ok(Json(json!({"ok": true, "deliveries": items}))) +} + +#[cfg(test)] +#[path = "github_app_events_tests.rs"] +mod tests; diff --git a/apps/server/src/github_app_events_tests.rs b/apps/server/src/github_app_events_tests.rs new file mode 100644 index 000000000..f380f98a7 --- /dev/null +++ b/apps/server/src/github_app_events_tests.rs @@ -0,0 +1,351 @@ +use super::*; +use axum::http::HeaderValue; + +const SECRET: &[u8] = b"wh_test_secret"; + +macro_rules! fixture { + ($name:literal) => { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/github/", + $name + )) + }; +} + +fn fx(raw: &str) -> Value { + serde_json::from_str(raw).unwrap() +} + +async fn pool_with_app() -> PgPool { + if std::env::var(crate::secret_box::KEY_ENV).is_err() { + use base64::Engine; + std::env::set_var( + crate::secret_box::KEY_ENV, + base64::engine::general_purpose::STANDARD.encode([6u8; 32]), + ); + } + crate::secret_box::global().unwrap(); + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + let seal = |b: &[u8]| crate::secret_box::seal(b).unwrap(); + sqlx::query("INSERT INTO github_app (id, app_id, slug, client_id, client_secret_enc, webhook_secret_enc, private_key_enc, html_url, owner_login) VALUES (1, 4242, 'devforge-nas', 'Iv1', $1, $2, $3, 'h', 'bobdivx')") + .bind(seal(b"cs")).bind(seal(SECRET)).bind(seal(b"pem")) + .execute(&pool).await.unwrap(); + pool +} + +fn headers(event: &str, delivery: &str, sig: Option) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert("x-github-event", HeaderValue::from_str(event).unwrap()); + h.insert( + "x-github-delivery", + HeaderValue::from_str(delivery).unwrap(), + ); + if let Some(s) = sig { + h.insert("x-hub-signature-256", HeaderValue::from_str(&s).unwrap()); + } + h +} + +fn code(o: &Outcome) -> StatusCode { + match o { + Outcome::Reply(c, _) => *c, + Outcome::Process(_) => StatusCode::OK, + } +} + +async fn know_installation(pool: &PgPool) { + apply_installation_event(pool, "created", &fx(fixture!("installation_created.json"))) + .await + .unwrap(); +} + +#[test] +fn signature_verification_is_strict() { + let body = br#"{"a":1}"#; + let sig = sign(SECRET, body); + assert!(verify_signature(SECRET, body, &sig)); + assert!(!verify_signature(SECRET, br#"{"a":2}"#, &sig)); + assert!(!verify_signature(b"other", body, &sig)); + assert!(!verify_signature(SECRET, body, "")); + assert!(!verify_signature(SECRET, body, "sha1=abcd")); + assert!(!verify_signature(SECRET, body, "sha256=zz")); +} + +#[tokio::test] +async fn signature_valid_invalid_missing_modified() { + let pool = pool_with_app().await; + know_installation(&pool).await; + let body = fixture!("push.json").as_bytes(); + let good = sign(SECRET, body); + + let ok = receive(&pool, &headers("push", "d-valid", Some(good.clone())), body).await; + assert_eq!(code(&ok), StatusCode::OK); + assert!(matches!(ok, Outcome::Process(_))); + + let bad = receive( + &pool, + &headers("push", "d-invalid", Some(sign(b"wrong", body))), + body, + ) + .await; + assert_eq!(code(&bad), StatusCode::UNAUTHORIZED); + + let missing = receive(&pool, &headers("push", "d-missing", None), body).await; + assert_eq!(code(&missing), StatusCode::UNAUTHORIZED); + + let mut modified = body.to_vec(); + modified[10] ^= 1; + let m = receive(&pool, &headers("push", "d-mod", Some(good)), &modified).await; + assert_eq!(code(&m), StatusCode::UNAUTHORIZED); + + // Rejets de signature : aucune livraison enregistrée. + let (n,): (i64,) = sqlx::query_as("SELECT count(*) FROM github_webhook_deliveries") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 1); +} + +#[tokio::test] +async fn no_app_configured_is_503_and_no_insecure_bypass() { + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + std::env::set_var("DEVFORGE_ALLOW_INSECURE_WEBHOOK", "1"); + let body = fixture!("ping.json").as_bytes(); + let o = receive( + &pool, + &headers("ping", "d1", Some(sign(SECRET, body))), + body, + ) + .await; + assert_eq!(code(&o), StatusCode::SERVICE_UNAVAILABLE); + let o = receive(&pool, &headers("ping", "d1", None), body).await; + assert_eq!(code(&o), StatusCode::UNAUTHORIZED); + std::env::remove_var("DEVFORGE_ALLOW_INSECURE_WEBHOOK"); +} + +#[tokio::test] +async fn same_delivery_twice_is_processed_once() { + let pool = pool_with_app().await; + know_installation(&pool).await; + let body = fixture!("push.json").as_bytes(); + let h = headers("push", "dup-1", Some(sign(SECRET, body))); + let mut processed = 0; + for _ in 0..2 { + match receive(&pool, &h, body).await { + Outcome::Process(_) => processed += 1, + Outcome::Reply(c, v) => { + assert_eq!(c, StatusCode::OK); + assert_eq!(v["duplicate"], true); + } + } + } + assert_eq!(processed, 1, "un seul déploiement enfilé"); +} + +#[tokio::test] +async fn unknown_installation_is_ignored_but_created_is_accepted() { + let pool = pool_with_app().await; + let body = fixture!("push.json").as_bytes(); + let o = receive( + &pool, + &headers("push", "u1", Some(sign(SECRET, body))), + body, + ) + .await; + assert_eq!(code(&o), StatusCode::ACCEPTED); + let body = fixture!("installation_created.json").as_bytes(); + let o = receive( + &pool, + &headers("installation", "u2", Some(sign(SECRET, body))), + body, + ) + .await; + assert!(matches!(o, Outcome::Process(_))); + let body = fixture!("ping.json").as_bytes(); + let o = receive( + &pool, + &headers("ping", "u3", Some(sign(SECRET, body))), + body, + ) + .await; + assert_eq!(code(&o), StatusCode::OK); + + let list = recent_deliveries(&pool).await.unwrap(); + assert_eq!(list.len(), 3); + let s = serde_json::to_string(&list).unwrap(); + assert!(s.contains("ignored_unknown_installation")); + // Jamais de payload ni de secret. + assert!(!s.contains("head_commit") && !s.contains("wh_test_secret")); +} + +#[tokio::test] +async fn installation_fixtures_effects() { + let pool = pool_with_app().await; + know_installation(&pool).await; + let repos = |pool: PgPool| async move { + let r: Vec<(i64,)> = sqlx::query_as("SELECT repo_id FROM github_installation_repos WHERE installation_id = 77 ORDER BY repo_id") + .fetch_all(&pool).await.unwrap(); + r.into_iter().map(|x| x.0).collect::>() + }; + assert_eq!(repos(pool.clone()).await, vec![9001, 9002]); + + apply_installation_repositories( + &pool, + "added", + &fx(fixture!("installation_repositories_added.json")), + ) + .await + .unwrap(); + apply_installation_repositories( + &pool, + "removed", + &fx(fixture!("installation_repositories_removed.json")), + ) + .await + .unwrap(); + assert_eq!(repos(pool.clone()).await, vec![9001, 9003]); + + apply_installation_event(&pool, "suspend", &fx(fixture!("installation_suspend.json"))) + .await + .unwrap(); + let (s,): (bool,) = sqlx::query_as( + "SELECT suspended_at IS NOT NULL FROM github_installations WHERE installation_id = 77", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(s); + apply_installation_event( + &pool, + "unsuspend", + &fx(fixture!("installation_unsuspend.json")), + ) + .await + .unwrap(); + let (s,): (bool,) = sqlx::query_as( + "SELECT suspended_at IS NOT NULL FROM github_installations WHERE installation_id = 77", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!s); + + // deleted : installation + repos supprimés, projet conservé et signalé déconnecté. + sqlx::query("INSERT INTO projects (uuid, name, slug, created_at, updated_at, github_installation_id, github_repo_id) VALUES ('p1','A','a','x','x', 77, 9001)") + .execute(&pool).await.unwrap(); + apply_installation_event(&pool, "deleted", &fx(fixture!("installation_deleted.json"))) + .await + .unwrap(); + let (n,): (i64,) = sqlx::query_as("SELECT count(*) FROM github_installation_repos") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 0); + let (p,): (i64,) = sqlx::query_as("SELECT count(*) FROM projects WHERE uuid = 'p1'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(p, 1); + let st = crate::github_app_routes::status_json(&pool).await.unwrap(); + assert_eq!(st["disconnected_projects"], 1); +} + +#[test] +fn rerequested_maps_to_push_of_head_branch() { + for (ev, raw) in [ + ("check_suite", fixture!("check_suite_rerequested.json")), + ("check_run", fixture!("check_run_rerequested.json")), + ] { + let p = rerequest_as_push(ev, &fx(raw)).unwrap(); + assert_eq!(p["ref"], "refs/heads/main"); + assert_eq!(p["repository"]["id"], 9001); + assert_eq!( + p["head_commit"]["id"], + "6dcb09b5b57875f334f61aebed695e2e4193db5e" + ); + } +} + +#[test] +fn push_skip_deleted_and_tags() { + use crate::infra_routes::push_skip_reason; + assert_eq!( + push_skip_reason(&fx(fixture!("push_deleted.json"))), + Some("deleted") + ); + assert_eq!( + push_skip_reason(&json!({"ref": "refs/tags/v1"})), + Some("tag") + ); + assert_eq!(push_skip_reason(&fx(fixture!("push.json"))), None); +} + +/// `handle_push` : sélection des projets, commune aux deux routes (non-régression). +#[tokio::test] +async fn push_targets_legacy_vs_installation() { + use crate::infra_routes::{push_target_projects, PushScope}; + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + sqlx::query(r#"INSERT INTO projects (uuid, name, slug, created_at, updated_at, git_repository, github_installation_id, github_repo_id) VALUES + ('p-legacy','A','a','x','x','https://github.com/bobdivx/demo', NULL, NULL), + ('p-linked','B','b','x','x','https://github.com/bobdivx/demo', 77, 9001), + ('p-other-inst','C','c','x','x','bobdivx/demo', 78, 9001), + ('p-unrelated','D','d','x','x','https://github.com/bobdivx/autre', NULL, NULL)"#) + .execute(&pool).await.unwrap(); + let push = fx(fixture!("push.json")); + let names = |v: Vec| { + let mut n: Vec = v.into_iter().map(|p| p.uuid).collect(); + n.sort(); + n + }; + let flag = crate::github_auth::flag_from_env( + std::env::var(crate::github_auth::FLAG_ENV).ok().as_deref(), + ); + if !flag { + // Sans mode App : webhook historique strictement inchangé (tous les projets du dépôt). + assert_eq!( + names( + push_target_projects(&pool, &push, PushScope::Legacy) + .await + .unwrap() + ), + vec!["p-legacy", "p-linked", "p-other-inst"] + ); + } + assert_eq!( + names( + push_target_projects(&pool, &push, PushScope::Installation(77)) + .await + .unwrap() + ), + vec!["p-linked"] + ); + // Mode App actif : les projets migrés ne sont plus déployés par l'ancien webhook. + sqlx::query("INSERT INTO github_app (id, app_id, slug, client_id, client_secret_enc, webhook_secret_enc, private_key_enc, html_url, owner_login) VALUES (1,1,'s','c','v1:a:b','v1:a:b','v1:a:b','h','o')") + .execute(&pool).await.unwrap(); + assert_eq!( + names( + push_target_projects(&pool, &push, PushScope::Legacy) + .await + .unwrap() + ), + vec!["p-legacy"] + ); +} + +#[tokio::test] +async fn purge_keeps_recent_deliveries() { + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + sqlx::query("INSERT INTO github_webhook_deliveries (delivery_id, event, status, received_at) VALUES ('old','push','processed', now() - interval '8 days'), ('new','push','processed', now())") + .execute(&pool).await.unwrap(); + purge_old_deliveries(&pool).await; + let (n,): (i64,) = sqlx::query_as("SELECT count(*) FROM github_webhook_deliveries") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 1); +} diff --git a/apps/server/src/github_app_routes.rs b/apps/server/src/github_app_routes.rs index 30c99c52c..190edf2fe 100644 --- a/apps/server/src/github_app_routes.rs +++ b/apps/server/src/github_app_routes.rs @@ -684,6 +684,14 @@ pub async fn status_json(pool: &PgPool) -> Result { ) .fetch_all(pool) .await?; + // Projets liés à une installation supprimée côté GitHub (mode « déconnecté »). + let (disconnected,): (i64,) = sqlx::query_as( + r#"SELECT count(*) FROM projects p WHERE p.github_installation_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM github_installations i + WHERE i.installation_id = p.github_installation_id)"#, + ) + .fetch_one(pool) + .await?; let web = web_base(); let installations: Vec = insts .into_iter() @@ -712,7 +720,7 @@ pub async fn status_json(pool: &PgPool) -> Result { Ok(match app { None => json!({ "ok": true, "configured": false, "required_permissions": required, - "installations": installations, + "installations": installations, "disconnected_projects": disconnected, }), Some((app_id, slug, client_id, html_url, owner, owner_type)) => json!({ "ok": true, @@ -727,6 +735,7 @@ pub async fn status_json(pool: &PgPool) -> Result { "manage_url": manage_url(&web, &slug, &owner, &owner_type), "required_permissions": required, "installations": installations, + "disconnected_projects": disconnected, }), }) } diff --git a/apps/server/src/infra_routes.rs b/apps/server/src/infra_routes.rs index 12bd55c68..07fbf286e 100644 --- a/apps/server/src/infra_routes.rs +++ b/apps/server/src/infra_routes.rs @@ -2098,6 +2098,27 @@ async fn github_webhook( Json(json!({"error": e.to_string()})), ) })?; + handle_push(&state, &payload, PushScope::Legacy) + .await + .map(Json) +} + +/// Quels projets un push peut déployer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PushScope { + /// Webhook historique par dépôt : tous les projets du dépôt, sauf ceux migrés vers + /// l'installation GitHub App (traités par le webhook app-level, pas de double déploiement). + Legacy, + /// Webhook app-level : uniquement les projets liés à cette installation. + Installation(i64), +} + +/// Projets ciblés par un push (sans effet de bord). +pub async fn push_target_projects( + pool: &sqlx::PgPool, + payload: &Value, + scope: PushScope, +) -> Result, (axum::http::StatusCode, Json)> { let full_name = payload .pointer("/repository/full_name") .and_then(|v| v.as_str()) @@ -2106,24 +2127,6 @@ async fn github_webhook( .pointer("/repository/html_url") .and_then(|v| v.as_str()) .unwrap_or(""); - let branch_ref = payload - .get("ref") - .and_then(|v| v.as_str()) - .unwrap_or("refs/heads/main"); - let branch = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref); - let sha = payload - .pointer("/head_commit/id") - .and_then(|v| v.as_str()) - .map(|s| s.chars().take(12).collect::()); - let message = payload - .pointer("/head_commit/message") - .and_then(|v| v.as_str()) - .unwrap_or("webhook push") - .lines() - .next() - .unwrap_or("webhook push") - .to_string(); - if full_name.is_empty() && html_url.is_empty() { return Err(( axum::http::StatusCode::BAD_REQUEST, @@ -2139,7 +2142,7 @@ async fn github_webhook( .bind(format!("%{full_name}%")) .bind(format!("%{html_url}%")) .bind(full_name) - .fetch_all(&state.pool) + .fetch_all(pool) .await .map_err(|e| { ( @@ -2148,6 +2151,77 @@ async fn github_webhook( ) })?; + let app_mode = crate::github_auth::app_enabled(pool).await; + let repo_id = payload.pointer("/repository/id").and_then(Value::as_i64); + let links: Vec<(String, Option, Option)> = sqlx::query_as( + "SELECT uuid, github_installation_id, github_repo_id FROM projects WHERE github_installation_id IS NOT NULL", + ) + .fetch_all(pool) + .await + .unwrap_or_default(); + let link = |uuid: &str| links.iter().find(|l| l.0 == uuid).map(|l| (l.1, l.2)); + Ok(projects + .into_iter() + .filter(|p| match (scope, link(&p.uuid)) { + (PushScope::Legacy, Some(_)) => !app_mode, + (PushScope::Legacy, None) => true, + (PushScope::Installation(id), Some((Some(i), r))) => { + i == id && (r.is_none() || repo_id.is_none() || r == repo_id) + } + (PushScope::Installation(_), _) => false, + }) + .collect()) +} + +/// Suppression de branche ou push de tag : rien à déployer. +pub fn push_skip_reason(payload: &Value) -> Option<&'static str> { + if payload + .get("deleted") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Some("deleted"); + } + if payload + .get("ref") + .and_then(Value::as_str) + .is_some_and(|r| r.starts_with("refs/tags/")) + { + return Some("tag"); + } + None +} + +/// Traitement d'un événement `push` partagé par les deux webhooks. +pub async fn handle_push( + state: &AppState, + payload: &Value, + scope: PushScope, +) -> Result)> { + if let Some(reason) = push_skip_reason(payload) { + return Ok(json!({"ok": true, "ignored": reason})); + } + let state = state.clone(); + let branch_ref = payload + .get("ref") + .and_then(|v| v.as_str()) + .unwrap_or("refs/heads/main"); + let branch = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref); + let sha = payload + .pointer("/head_commit/id") + .and_then(|v| v.as_str()) + .map(|s| s.chars().take(12).collect::()); + let message = payload + .pointer("/head_commit/message") + .and_then(|v| v.as_str()) + .unwrap_or("webhook push") + .lines() + .next() + .unwrap_or("webhook push") + .to_string(); + + let projects = push_target_projects(&state.pool, payload, scope).await?; + // Environnement dev : branche `dev_branch` → conteneur dev séparé (tâche détachée). let dev_deployed = crate::dev_env::on_push(&state, &projects, branch, sha.as_deref(), &message).await; @@ -2269,9 +2343,7 @@ async fn github_webhook( })); } - Ok(Json( - json!({"ok": true, "deployed": deployed, "dev_deployed": dev_deployed}), - )) + Ok(json!({"ok": true, "deployed": deployed, "dev_deployed": dev_deployed})) } fn verify_github_sig(secret: &str, body: &[u8], signature_header: &str) -> bool { diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 35e14556c..e5dc750db 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -27,6 +27,7 @@ mod draft; mod draft_origin; mod draft_routes; mod git_routes; +mod github_app_events; mod github_app_routes; mod github_auth; mod group_routes; @@ -368,6 +369,7 @@ async fn main() -> Result<(), Box> { .merge(actions_routes::router()) .merge(git_routes::router()) .merge(github_app_routes::router()) + .merge(github_app_events::router()) .merge(draft_routes::router()) .merge(correction::router()) .merge(group_routes::router()) diff --git a/apps/server/tests/fixtures/github/check_run_rerequested.json b/apps/server/tests/fixtures/github/check_run_rerequested.json new file mode 100644 index 000000000..2cd7edf70 --- /dev/null +++ b/apps/server/tests/fixtures/github/check_run_rerequested.json @@ -0,0 +1 @@ +{"action":"rerequested","check_run":{"id":4,"name":"DevForge / deploy","head_sha":"6dcb09b5b57875f334f61aebed695e2e4193db5e","check_suite":{"id":5,"head_branch":"main","head_sha":"6dcb09b5b57875f334f61aebed695e2e4193db5e"}},"repository":{"id":9001,"full_name":"bobdivx/demo","html_url":"https://github.com/bobdivx/demo"},"installation":{"id":77},"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/check_suite_rerequested.json b/apps/server/tests/fixtures/github/check_suite_rerequested.json new file mode 100644 index 000000000..879c36433 --- /dev/null +++ b/apps/server/tests/fixtures/github/check_suite_rerequested.json @@ -0,0 +1 @@ +{"action":"rerequested","check_suite":{"id":5,"head_branch":"main","head_sha":"6dcb09b5b57875f334f61aebed695e2e4193db5e","status":"completed","conclusion":"failure","app":{"id":4242,"slug":"devforge-nas"}},"repository":{"id":9001,"full_name":"bobdivx/demo","html_url":"https://github.com/bobdivx/demo"},"installation":{"id":77},"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/installation_created.json b/apps/server/tests/fixtures/github/installation_created.json new file mode 100644 index 000000000..842e60f31 --- /dev/null +++ b/apps/server/tests/fixtures/github/installation_created.json @@ -0,0 +1,4 @@ +{"action":"created", + "installation":{"id":77,"account":{"login":"bobdivx","id":6737167,"type":"User"},"repository_selection":"selected","access_tokens_url":"https://api.github.com/app/installations/77/access_tokens","app_id":4242,"app_slug":"devforge-nas","target_type":"User","permissions":{"checks":"write","contents":"write","deployments":"write","metadata":"read","pull_requests":"write","statuses":"write"},"events":["push","pull_request","check_run","check_suite"],"created_at":"2026-10-14T01:00:00.000+02:00","updated_at":"2026-10-14T01:00:00.000+02:00","suspended_at":null}, + "repositories":[{"id":9001,"node_id":"R_1","name":"demo","full_name":"bobdivx/demo","private":true},{"id":9002,"node_id":"R_2","name":"site","full_name":"bobdivx/site","private":false}], + "sender":{"login":"bobdivx","id":6737167,"type":"User"}} diff --git a/apps/server/tests/fixtures/github/installation_deleted.json b/apps/server/tests/fixtures/github/installation_deleted.json new file mode 100644 index 000000000..c684de66a --- /dev/null +++ b/apps/server/tests/fixtures/github/installation_deleted.json @@ -0,0 +1 @@ +{"action":"deleted","installation":{"id":77,"account":{"login":"bobdivx","type":"User"},"repository_selection":"selected","permissions":{}},"repositories":[{"id":9001,"full_name":"bobdivx/demo","private":true}],"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/installation_repositories_added.json b/apps/server/tests/fixtures/github/installation_repositories_added.json new file mode 100644 index 000000000..7337ca57f --- /dev/null +++ b/apps/server/tests/fixtures/github/installation_repositories_added.json @@ -0,0 +1 @@ +{"action":"added","installation":{"id":77,"account":{"login":"bobdivx","type":"User"}},"repository_selection":"selected","repositories_added":[{"id":9003,"node_id":"R_3","name":"api","full_name":"bobdivx/api","private":true}],"repositories_removed":[],"requester":null,"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/installation_repositories_removed.json b/apps/server/tests/fixtures/github/installation_repositories_removed.json new file mode 100644 index 000000000..7d9179f36 --- /dev/null +++ b/apps/server/tests/fixtures/github/installation_repositories_removed.json @@ -0,0 +1 @@ +{"action":"removed","installation":{"id":77,"account":{"login":"bobdivx","type":"User"}},"repository_selection":"selected","repositories_added":[],"repositories_removed":[{"id":9002,"node_id":"R_2","name":"site","full_name":"bobdivx/site","private":false}],"requester":null,"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/installation_suspend.json b/apps/server/tests/fixtures/github/installation_suspend.json new file mode 100644 index 000000000..5542a6760 --- /dev/null +++ b/apps/server/tests/fixtures/github/installation_suspend.json @@ -0,0 +1 @@ +{"action":"suspend","installation":{"id":77,"account":{"login":"bobdivx","type":"User"},"repository_selection":"selected","permissions":{"contents":"write","metadata":"read"},"suspended_at":"2026-10-14T02:00:00Z","suspended_by":{"login":"bobdivx"}},"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/installation_unsuspend.json b/apps/server/tests/fixtures/github/installation_unsuspend.json new file mode 100644 index 000000000..86d80db49 --- /dev/null +++ b/apps/server/tests/fixtures/github/installation_unsuspend.json @@ -0,0 +1 @@ +{"action":"unsuspend","installation":{"id":77,"account":{"login":"bobdivx","type":"User"},"repository_selection":"selected","permissions":{"contents":"write","metadata":"read"},"suspended_at":null},"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/ping.json b/apps/server/tests/fixtures/github/ping.json new file mode 100644 index 000000000..1bde7b80b --- /dev/null +++ b/apps/server/tests/fixtures/github/ping.json @@ -0,0 +1 @@ +{"zen":"Keep it logically awesome.","hook_id":1,"hook":{"type":"App","id":1,"active":true,"events":["push","pull_request","check_run","check_suite"],"app_id":4242},"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/pull_request_opened.json b/apps/server/tests/fixtures/github/pull_request_opened.json new file mode 100644 index 000000000..a6d7113a7 --- /dev/null +++ b/apps/server/tests/fixtures/github/pull_request_opened.json @@ -0,0 +1 @@ +{"action":"opened","number":12,"pull_request":{"id":1,"number":12,"state":"open","head":{"ref":"feat/x","sha":"abc123","repo":{"id":9001,"full_name":"bobdivx/demo"}},"base":{"ref":"main","repo":{"id":9001,"full_name":"bobdivx/demo"}}},"repository":{"id":9001,"full_name":"bobdivx/demo"},"installation":{"id":77},"sender":{"login":"bobdivx"}} diff --git a/apps/server/tests/fixtures/github/push.json b/apps/server/tests/fixtures/github/push.json new file mode 100644 index 000000000..0932c8b08 --- /dev/null +++ b/apps/server/tests/fixtures/github/push.json @@ -0,0 +1,6 @@ +{"ref":"refs/heads/main","before":"0000000000000000000000000000000000000000","after":"6dcb09b5b57875f334f61aebed695e2e4193db5e","created":false,"deleted":false,"forced":false, + "repository":{"id":9001,"node_id":"R_1","name":"demo","full_name":"bobdivx/demo","private":true,"html_url":"https://github.com/bobdivx/demo","default_branch":"main"}, + "pusher":{"name":"bobdivx","email":"noreply@example.com"}, + "sender":{"login":"bobdivx","id":6737167,"type":"User"}, + "installation":{"id":77,"node_id":"I_1"}, + "head_commit":{"id":"6dcb09b5b57875f334f61aebed695e2e4193db5e","tree_id":"t","distinct":true,"message":"feat: hello\n\nbody","timestamp":"2026-10-14T01:00:00+02:00","url":"https://github.com/bobdivx/demo/commit/6dcb09b","author":{"name":"Mathieu","email":"noreply@example.com","username":"bobdivx"}}} diff --git a/apps/server/tests/fixtures/github/push_deleted.json b/apps/server/tests/fixtures/github/push_deleted.json new file mode 100644 index 000000000..874a3eb33 --- /dev/null +++ b/apps/server/tests/fixtures/github/push_deleted.json @@ -0,0 +1,3 @@ +{"ref":"refs/heads/feature","before":"6dcb09b5b57875f334f61aebed695e2e4193db5e","after":"0000000000000000000000000000000000000000","created":false,"deleted":true,"forced":false, + "repository":{"id":9001,"full_name":"bobdivx/demo","html_url":"https://github.com/bobdivx/demo"}, + "installation":{"id":77},"head_commit":null}