OXYGENHUB

Coverage / crates/hub/src/db/hooks.rs 90.6%

211 of 233 lines run, 45 of 102 functions · run #545 on main · run missed

1
//! Webhooks, their delivery log, and checks (hub spec §12.2, §13.1).
2
3
use super::{now_ms, Pool};
4
use sqlx::FromRow;
5
6
#[derive(Clone, Debug, FromRow)]
7
pub struct Hook {
8
    pub id: i64,
9
    pub repo_id: Option<i64>,
10
    pub org_id: Option<i64>,
11
    pub url: String,
12
    pub secret: String,
13
    pub events: String,
14
    pub active: bool,
15
    pub created_ms: i64,
16
    /// `oxygenhub`, `discord` or `slack`.
17
    pub format: String,
18
}
19
20
/// The formats a webhook can be delivered in.
21
pub const FORMATS: [&str; 3] = ["oxygenhub", "discord", "slack"];
22
23
/// The format a URL most likely wants: Discord and Slack webhooks accept
24
/// only their own bodies.
259×
pub fn format_for_url(url: &str) -> &'static str {
269×
    let host = url.split("://").nth(1).unwrap_or("").split(['/', ':']).next().unwrap_or("").to_ascii_lowercase();
279×
    if matches!(host.as_str(), "discord.com" | "discordapp.com" | "canary.discord.com" | "ptb.discord.com")
281×
        && url.contains("/api/webhooks/")
29
    {
301×
        "discord"
318×
    } else if host == "hooks.slack.com" {
321×
        "slack"
33
    } else {
347×
        "oxygenhub"
35
    }
369×
}
37
38
/// A URL for display: scheme, host and path, with long segments (the tokens
39
/// Discord and Slack keep in the path) and any query cut to a few characters.
4019×
pub fn masked_url(url: &str) -> String {
4119×
    let (scheme, rest) = url.split_once("://").unwrap_or(("", url));
4219×
    let (path, query) = rest.split_once('?').map(|(p, q)| (p, Some(q))).unwrap_or((rest, None));
4319×
    let parts: Vec<String> =
4419×
        path.split('/')
4519×
            .enumerate()
4641×
            .map(|(i, seg)| {
4741×
                if i > 0 && seg.chars().count() > 14 {
482×
                    format!("{}…", seg.chars().take(4).collect::<String>())
49
                } else {
5039×
                    seg.to_string()
51
                }
5241×
            })
5319×
            .collect();
5419×
    let mut out = if scheme.is_empty() { parts.join("/") } else { format!("{scheme}://{}", parts.join("/")) };
5519×
    if query.is_some() {
561×
        out.push_str("?…");
5718×
    }
5819×
    out
5919×
}
60
61
impl Hook {
62
    /// Whether the hook wants `event` (`*`, or a comma list of prefixes such as `push,proposal`).
6317×
    pub fn wants(&self, event: &str) -> bool {
6435×
        self.active && self.events.split(',').map(str::trim).any(|e| e == "*" || event == e || event.starts_with(&format!("{e}.")))
6517×
    }
66
}
67
68
#[derive(Clone, Debug, FromRow)]
69
pub struct Delivery {
70
    pub id: i64,
71
    pub hook_id: i64,
72
    pub delivery: String,
73
    pub event: String,
74
    pub payload: String,
75
    pub created_ms: i64,
76
    pub attempts: i64,
77
    pub next_ms: i64,
78
    pub status: Option<i64>,
79
    pub response: String,
80
    pub delivered_ms: Option<i64>,
81
}
82
831281×
pub async fn hooks_for_repo(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Hook>> {
841281×
    sqlx::query_as("SELECT * FROM hooks WHERE repo_id = $1 ORDER BY id").bind(repo_id).fetch_all(pool).await
851280×
}
86
8740×
pub async fn hooks_for_org(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<Hook>> {
8840×
    sqlx::query_as("SELECT * FROM hooks WHERE org_id = $1 ORDER BY id").bind(org_id).fetch_all(pool).await
8940×
}
90
91
/// Every hook that should hear about an event in a repository: its own
92
/// and its organisation's.
931146×
pub async fn hooks_hearing(pool: &Pool, repo_id: i64, owner: &str) -> sqlx::Result<Vec<Hook>> {
941146×
    let mut out = hooks_for_repo(pool, repo_id).await?;
951145×
    if let Some(org) = super::accounts::org_by_name(pool, owner).await? {
968×
        out.extend(hooks_for_org(pool, org.id).await?);
971136×
    }
981144×
    Ok(out)
991144×
}
100
10127×
pub async fn hook(pool: &Pool, id: i64) -> sqlx::Result<Option<Hook>> {
10227×
    sqlx::query_as("SELECT * FROM hooks WHERE id = $1").bind(id).fetch_optional(pool).await
10327×
}
104
1055×
pub async fn create_hook(
1065×
    pool: &Pool,
1075×
    repo_id: Option<i64>,
1085×
    org_id: Option<i64>,
1095×
    url: &str,
1105×
    secret: &str,
1115×
    events: &str,
1125×
) -> sqlx::Result<Hook> {
1135×
    sqlx::query_as(
1145×
        "INSERT INTO hooks (repo_id, org_id, url, secret, events, created_ms, format) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
1155×
    )
1165×
    .bind(repo_id)
1175×
    .bind(org_id)
1185×
    .bind(url)
1195×
    .bind(secret)
1205×
    .bind(events)
1215×
    .bind(now_ms())
1225×
    .bind(format_for_url(url))
1235×
    .fetch_one(pool)
1245×
    .await
1255×
}
126
127
/// Change a hook; `None` keeps the URL or secret as it is.
1282×
pub async fn update_hook(
1292×
    pool: &Pool,
1302×
    id: i64,
1312×
    url: Option<&str>,
1322×
    secret: Option<&str>,
1332×
    events: &str,
1342×
    format: &str,
1352×
    active: bool,
1362×
) -> sqlx::Result<()> {
1372×
    sqlx::query(
1382×
        "UPDATE hooks SET url = COALESCE($1, url), secret = COALESCE($2, secret), events = $3, format = $4, active = $5 WHERE id = $6",
1392×
    )
1402×
    .bind(url)
1412×
    .bind(secret)
1422×
    .bind(events)
1432×
    .bind(format)
1442×
    .bind(active)
1452×
    .bind(id)
1462×
    .execute(pool)
1472×
    .await?;
1482×
    Ok(())
1492×
}
150
1511×
pub async fn set_hook_format(pool: &Pool, id: i64, format: &str) -> sqlx::Result<()> {
1521×
    sqlx::query("UPDATE hooks SET format = $1 WHERE id = $2").bind(format).bind(id).execute(pool).await?;
1531×
    Ok(())
1541×
}
155
156
/// The newest delivery that was attempted, for the settings table.
15712×
pub async fn last_attempt(pool: &Pool, hook_id: i64) -> sqlx::Result<Option<Delivery>> {
15812×
    sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = $1 AND attempts > 0 ORDER BY id DESC LIMIT 1")
15912×
        .bind(hook_id)
16012×
        .fetch_optional(pool)
16112×
        .await
16212×
}
163
1640×
pub async fn set_hook_active(pool: &Pool, id: i64, active: bool) -> sqlx::Result<()> {
1650×
    sqlx::query("UPDATE hooks SET active = $1 WHERE id = $2").bind(active).bind(id).execute(pool).await?;
1660×
    Ok(())
1670×
}
168
1690×
pub async fn delete_hook(pool: &Pool, id: i64) -> sqlx::Result<()> {
1700×
    sqlx::query("DELETE FROM hooks WHERE id = $1").bind(id).execute(pool).await?;
1710×
    Ok(())
1720×
}
173
174
/// The most deliveries one hook may have waiting (#220): beyond it, new
175
/// events for that hook are dropped (and logged) until it catches up.
176
pub const MAX_WAITING: i64 = 1000;
177
178
/// Queue a delivery; `Ok(None)` when the hook already has
179
/// [`MAX_WAITING`] deliveries waiting, so nothing was queued.
18016×
pub async fn queue_delivery(pool: &Pool, hook_id: i64, delivery: &str, event: &str, payload: &str) -> sqlx::Result<Option<i64>> {
18116×
    let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = $1 AND delivered_ms IS NULL AND next_ms < $2")
18216×
        .bind(hook_id)
18316×
        .bind(i64::MAX / 4)
18416×
        .fetch_one(pool)
18516×
        .await?;
18616×
    if waiting >= MAX_WAITING {
1871×
        return Ok(None);
18815×
    }
18915×
    let now = now_ms();
19015×
    sqlx::query_scalar(
19115×
        "INSERT INTO deliveries (hook_id, delivery, event, payload, created_ms, next_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
19215×
    )
19315×
    .bind(hook_id)
19415×
    .bind(delivery)
19515×
    .bind(event)
19615×
    .bind(payload)
19715×
    .bind(now)
19815×
    .bind(now)
19915×
    .fetch_one(pool)
20015×
    .await
20115×
    .map(Some)
20216×
}
203
204228×
pub async fn due_deliveries(pool: &Pool, limit: i64) -> sqlx::Result<Vec<Delivery>> {
205228×
    sqlx::query_as("SELECT * FROM deliveries WHERE delivered_ms IS NULL AND next_ms <= $1 ORDER BY next_ms LIMIT $2")
206228×
        .bind(now_ms())
207228×
        .bind(limit)
208228×
        .fetch_all(pool)
209228×
        .await
210227×
}
211
2121×
pub async fn deliveries_for_hook(pool: &Pool, hook_id: i64, limit: i64) -> sqlx::Result<Vec<Delivery>> {
2131×
    sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = $1 ORDER BY created_ms DESC, id DESC LIMIT $2")
2141×
        .bind(hook_id)
2151×
        .bind(limit)
2161×
        .fetch_all(pool)
2171×
        .await
2181×
}
219
2200×
pub async fn delivery(pool: &Pool, id: i64) -> sqlx::Result<Option<Delivery>> {
2210×
    sqlx::query_as("SELECT * FROM deliveries WHERE id = $1").bind(id).fetch_optional(pool).await
2220×
}
223
224
/// Record an attempt: done on a 2xx, else retried with backoff up to a day.
22515×
pub async fn record_attempt(pool: &Pool, id: i64, attempts: i64, status: Option<i64>, response: &str) -> sqlx::Result<()> {
22615×
    let ok = status.is_some_and(|s| (200..300).contains(&s));
22715×
    let now = now_ms();
22815×
    let delivered: Option<i64> = if ok { Some(now) } else { None };
229
    // 1 min, 5 min, 30 min, 2 h, 6 h, then give up after a day's worth.
23015×
    let backoff_ms = [60_000i64, 300_000, 1_800_000, 7_200_000, 21_600_000];
23115×
    let next = if ok || attempts as usize >= backoff_ms.len() { i64::MAX / 4 } else { now + backoff_ms[attempts as usize] };
23215×
    sqlx::query("UPDATE deliveries SET attempts = $1, status = $2, response = $3, delivered_ms = $4, next_ms = $5 WHERE id = $6")
23315×
        .bind(attempts + 1)
23415×
        .bind(status)
23515×
        .bind(response.chars().take(2000).collect::<String>())
23615×
        .bind(delivered)
23715×
        .bind(next)
23815×
        .bind(id)
23915×
        .execute(pool)
24015×
        .await?;
24115×
    Ok(())
24215×
}
243
244
/// Requeue a delivery for an immediate retry.
2450×
pub async fn redeliver(pool: &Pool, id: i64) -> sqlx::Result<()> {
2460×
    sqlx::query("UPDATE deliveries SET delivered_ms = NULL, next_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
2470×
    Ok(())
2480×
}
249
250225×
pub async fn prune_deliveries(pool: &Pool, older_than_ms: i64) -> sqlx::Result<()> {
251225×
    sqlx::query("DELETE FROM deliveries WHERE created_ms < $1").bind(older_than_ms).execute(pool).await?;
252225×
    Ok(())
253225×
}
254
255
// ------------------------------------------------------------------ checks
256
257
#[derive(Clone, Debug, FromRow)]
258
pub struct Check {
259
    pub id: i64,
260
    pub repo_id: i64,
261
    pub state: String,
262
    pub name: String,
263
    pub status: String,
264
    pub summary: String,
265
    pub url: String,
266
    pub reporter_id: Option<i64>,
267
    pub reporter: String,
268
    pub seal: String,
269
    pub started_ms: i64,
270
    pub finished_ms: Option<i64>,
271
}
272
273
impl Check {
2740×
    pub fn done(&self) -> bool {
2750×
        matches!(self.status.as_str(), "passed" | "failed" | "cancelled" | "skipped")
2760×
    }
277
}
278
279161×
pub async fn checks_for_state(pool: &Pool, repo_id: i64, state: &str) -> sqlx::Result<Vec<Check>> {
280161×
    sqlx::query_as("SELECT * FROM checks WHERE repo_id = $1 AND state = $2 ORDER BY name").bind(repo_id).bind(state).fetch_all(pool).await
281161×
}
282
283
/// Create or update a check by `(state, name)`.
284
/// A check as a reporter posts it.
285
pub struct CheckReport<'a> {
286
    pub state: &'a str,
287
    pub name: &'a str,
288
    pub status: &'a str,
289
    pub summary: &'a str,
290
    pub url: &'a str,
291
    pub reporter: Option<(i64, &'a str)>,
292
    pub seal: &'a str,
293
}
294
295749×
pub async fn upsert_check(pool: &Pool, repo_id: i64, c: CheckReport<'_>) -> sqlx::Result<Check> {
296749×
    let CheckReport { state, name, status, summary, url, reporter, seal } = c;
297749×
    let now = now_ms();
298749×
    let finished: Option<i64> = if matches!(status, "passed" | "failed" | "cancelled" | "skipped") { Some(now) } else { None };
299749×
    sqlx::query_as(
300749×
        "INSERT INTO checks (repo_id, state, name, status, summary, url, reporter_id, reporter, seal, started_ms, finished_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
301749×
         ON CONFLICT (repo_id, state, name) DO UPDATE SET status = excluded.status, summary = excluded.summary, url = excluded.url, reporter_id = excluded.reporter_id, reporter = excluded.reporter, seal = CASE WHEN excluded.seal = '' THEN checks.seal ELSE excluded.seal END, finished_ms = excluded.finished_ms RETURNING *",
302
    )
303749×
    .bind(repo_id)
304749×
    .bind(state)
305749×
    .bind(name)
306749×
    .bind(status)
307749×
    .bind(summary)
308749×
    .bind(url)
309749×
    .bind(reporter.map(|r| r.0))
310749×
    .bind(reporter.map(|r| r.1).unwrap_or(""))
311749×
    .bind(seal)
312749×
    .bind(now)
313749×
    .bind(finished)
314749×
    .fetch_one(pool)
315749×
    .await
316749×
}
317
318348×
pub async fn required_checks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> {
319348×
    let s: String = sqlx::query_scalar("SELECT required_checks FROM repositories WHERE id = $1").bind(repo_id).fetch_one(pool).await?;
320351×
    Ok(s.split(',').map(str::trim).filter(|x| !x.is_empty()).map(String::from).collect())
321348×
}
322
3235×
pub async fn set_required_checks(pool: &Pool, repo_id: i64, names: &[String]) -> sqlx::Result<()> {
3245×
    sqlx::query("UPDATE repositories SET required_checks = $1 WHERE id = $2").bind(names.join(",")).bind(repo_id).execute(pool).await?;
3255×
    Ok(())
3265×
}