Skip to main content

nyx_agent_core/store/
webhook.rs

1//! `webhooks` table - incoming hook config per repo.
2
3use sqlx::SqlitePool;
4
5use crate::store::StoreError;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct WebhookRecord {
9    pub id: String,
10    pub repo: String,
11    pub hmac_secret_ref: String,
12    pub branch_filter: Option<String>,
13    pub enabled: bool,
14}
15
16pub struct WebhookStore<'a> {
17    pool: &'a SqlitePool,
18}
19
20impl<'a> WebhookStore<'a> {
21    pub fn new(pool: &'a SqlitePool) -> Self {
22        Self { pool }
23    }
24
25    pub async fn insert(&self, w: &WebhookRecord) -> Result<(), StoreError> {
26        let enabled = i64::from(w.enabled);
27        sqlx::query!(
28            r#"
29            INSERT INTO webhooks (id, repo, hmac_secret_ref, branch_filter, enabled)
30            VALUES (?, ?, ?, ?, ?)
31            "#,
32            w.id,
33            w.repo,
34            w.hmac_secret_ref,
35            w.branch_filter,
36            enabled,
37        )
38        .execute(self.pool)
39        .await?;
40        Ok(())
41    }
42
43    pub async fn get(&self, id: &str) -> Result<Option<WebhookRecord>, StoreError> {
44        let row = sqlx::query!(
45            r#"
46            SELECT id AS "id!", repo AS "repo!",
47                   hmac_secret_ref AS "hmac_secret_ref!",
48                   branch_filter,
49                   enabled AS "enabled!: i64"
50            FROM webhooks WHERE id = ?
51            "#,
52            id
53        )
54        .fetch_optional(self.pool)
55        .await?;
56        Ok(row.map(|r| WebhookRecord {
57            id: r.id,
58            repo: r.repo,
59            hmac_secret_ref: r.hmac_secret_ref,
60            branch_filter: r.branch_filter,
61            enabled: r.enabled != 0,
62        }))
63    }
64
65    pub async fn list_enabled_for_repo(
66        &self,
67        repo: &str,
68    ) -> Result<Vec<WebhookRecord>, StoreError> {
69        let rows = sqlx::query!(
70            r#"
71            SELECT id AS "id!", repo AS "repo!",
72                   hmac_secret_ref AS "hmac_secret_ref!",
73                   branch_filter,
74                   enabled AS "enabled!: i64"
75            FROM webhooks WHERE repo = ? AND enabled = 1
76            "#,
77            repo
78        )
79        .fetch_all(self.pool)
80        .await?;
81        Ok(rows
82            .into_iter()
83            .map(|r| WebhookRecord {
84                id: r.id,
85                repo: r.repo,
86                hmac_secret_ref: r.hmac_secret_ref,
87                branch_filter: r.branch_filter,
88                enabled: r.enabled != 0,
89            })
90            .collect())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::store::testutil::{fresh_store, sample_repo};
98
99    fn hook(id: &str, repo: &str, enabled: bool) -> WebhookRecord {
100        WebhookRecord {
101            id: id.to_string(),
102            repo: repo.to_string(),
103            hmac_secret_ref: "secret/manager/key#1".to_string(),
104            branch_filter: Some("main".to_string()),
105            enabled,
106        }
107    }
108
109    #[tokio::test]
110    async fn insert_then_get_roundtrips() {
111        let (_tmp, s) = fresh_store().await;
112        s.repos().upsert(&sample_repo("r")).await.expect("repo");
113        let h = hook("h-1", "r", true);
114        s.webhooks().insert(&h).await.expect("insert");
115        let got = s.webhooks().get("h-1").await.expect("get").expect("row");
116        assert_eq!(got, h);
117    }
118
119    #[tokio::test]
120    async fn list_enabled_excludes_disabled_and_other_repos() {
121        let (_tmp, s) = fresh_store().await;
122        s.repos().upsert(&sample_repo("r1")).await.expect("r1");
123        s.repos().upsert(&sample_repo("r2")).await.expect("r2");
124        s.webhooks().insert(&hook("a", "r1", true)).await.expect("a");
125        s.webhooks().insert(&hook("b", "r1", false)).await.expect("b");
126        s.webhooks().insert(&hook("c", "r2", true)).await.expect("c");
127        let got: Vec<_> = s
128            .webhooks()
129            .list_enabled_for_repo("r1")
130            .await
131            .expect("list")
132            .into_iter()
133            .map(|h| h.id)
134            .collect();
135        assert_eq!(got, vec!["a".to_string()]);
136    }
137
138    #[tokio::test]
139    async fn cascade_from_repo_delete() {
140        let (_tmp, s) = fresh_store().await;
141        s.repos().upsert(&sample_repo("doomed")).await.expect("repo");
142        s.webhooks().insert(&hook("h", "doomed", true)).await.expect("insert");
143        s.repos().delete("doomed").await.expect("del");
144        assert!(
145            s.webhooks().get("h").await.expect("get").is_none(),
146            "webhook should cascade-delete with parent repo"
147        );
148    }
149}