OXYGENHUB
◆ v0.9.21 oxygenhub / crates / hub / src / db / hooks.rs
11.4 KiB · Rust · last changed Postgres, step 1: every query numbers its placeholders $1, $2, ... 19 days ago source blame history raw
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.
25
pub fn format_for_url(url: &str) -> &'static str {
26
    let host = url.split("://").nth(1).unwrap_or("").split(['/', ':']).next().unwrap_or("").to_ascii_lowercase();
27
    if matches!(host.as_str(), "discord.com" | "discordapp.com" | "canary.discord.com" | "ptb.discord.com")
28
        && url.contains("/api/webhooks/")
29
    {
30
        "discord"
31
    } else if host == "hooks.slack.com" {
32
        "slack"
33
    } else {
34
        "oxygenhub"
35
    }
36
}
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.
40
pub fn masked_url(url: &str) -> String {
41
    let (scheme, rest) = url.split_once("://").unwrap_or(("", url));
42
    let (path, query) = rest.split_once('?').map(|(p, q)| (p, Some(q))).unwrap_or((rest, None));
43
    let parts: Vec<String> =
44
        path.split('/')
45
            .enumerate()
46
            .map(|(i, seg)| {
47
                if i > 0 && seg.chars().count() > 14 {
48
                    format!("{}…", seg.chars().take(4).collect::<String>())
49
                } else {
50
                    seg.to_string()
51
                }
52
            })
53
            .collect();
54
    let mut out = if scheme.is_empty() { parts.join("/") } else { format!("{scheme}://{}", parts.join("/")) };
55
    if query.is_some() {
56
        out.push_str("?…");
57
    }
58
    out
59
}
60
61
impl Hook {
62
    /// Whether the hook wants `event` (`*`, or a comma list of prefixes such as `push,proposal`).
63
    pub fn wants(&self, event: &str) -> bool {
64
        self.active && self.events.split(',').map(str::trim).any(|e| e == "*" || event == e || event.starts_with(&format!("{e}.")))
65
    }
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
83
pub async fn hooks_for_repo(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Hook>> {
84
    sqlx::query_as("SELECT * FROM hooks WHERE repo_id = $1 ORDER BY id").bind(repo_id).fetch_all(pool).await
85
}
86
87
pub async fn hooks_for_org(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<Hook>> {
88
    sqlx::query_as("SELECT * FROM hooks WHERE org_id = $1 ORDER BY id").bind(org_id).fetch_all(pool).await
89
}
90
91
/// Every hook that should hear about an event in a repository: its own
92
/// and its organisation's.
93
pub async fn hooks_hearing(pool: &Pool, repo_id: i64, owner: &str) -> sqlx::Result<Vec<Hook>> {
94
    let mut out = hooks_for_repo(pool, repo_id).await?;
95
    if let Some(org) = super::accounts::org_by_name(pool, owner).await? {
96
        out.extend(hooks_for_org(pool, org.id).await?);
97
    }
98
    Ok(out)
99
}
100
101
pub async fn hook(pool: &Pool, id: i64) -> sqlx::Result<Option<Hook>> {
102
    sqlx::query_as("SELECT * FROM hooks WHERE id = $1").bind(id).fetch_optional(pool).await
103
}
104
105
pub async fn create_hook(
106
    pool: &Pool,
107
    repo_id: Option<i64>,
108
    org_id: Option<i64>,
109
    url: &str,
110
    secret: &str,
111
    events: &str,
112
) -> sqlx::Result<Hook> {
113
    sqlx::query_as(
114
        "INSERT INTO hooks (repo_id, org_id, url, secret, events, created_ms, format) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
115
    )
116
    .bind(repo_id)
117
    .bind(org_id)
118
    .bind(url)
119
    .bind(secret)
120
    .bind(events)
121
    .bind(now_ms())
122
    .bind(format_for_url(url))
123
    .fetch_one(pool)
124
    .await
125
}
126
127
/// Change a hook; `None` keeps the URL or secret as it is.
128
pub async fn update_hook(
129
    pool: &Pool,
130
    id: i64,
131
    url: Option<&str>,
132
    secret: Option<&str>,
133
    events: &str,
134
    format: &str,
135
    active: bool,
136
) -> sqlx::Result<()> {
137
    sqlx::query(
138
        "UPDATE hooks SET url = COALESCE($1, url), secret = COALESCE($2, secret), events = $3, format = $4, active = $5 WHERE id = $6",
139
    )
140
    .bind(url)
141
    .bind(secret)
142
    .bind(events)
143
    .bind(format)
144
    .bind(active)
145
    .bind(id)
146
    .execute(pool)
147
    .await?;
148
    Ok(())
149
}
150
151
pub async fn set_hook_format(pool: &Pool, id: i64, format: &str) -> sqlx::Result<()> {
152
    sqlx::query("UPDATE hooks SET format = $1 WHERE id = $2").bind(format).bind(id).execute(pool).await?;
153
    Ok(())
154
}
155
156
/// The newest delivery that was attempted, for the settings table.
157
pub async fn last_attempt(pool: &Pool, hook_id: i64) -> sqlx::Result<Option<Delivery>> {
158
    sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = $1 AND attempts > 0 ORDER BY id DESC LIMIT 1")
159
        .bind(hook_id)
160
        .fetch_optional(pool)
161
        .await
162
}
163
164
pub async fn set_hook_active(pool: &Pool, id: i64, active: bool) -> sqlx::Result<()> {
165
    sqlx::query("UPDATE hooks SET active = $1 WHERE id = $2").bind(active).bind(id).execute(pool).await?;
166
    Ok(())
167
}
168
169
pub async fn delete_hook(pool: &Pool, id: i64) -> sqlx::Result<()> {
170
    sqlx::query("DELETE FROM hooks WHERE id = $1").bind(id).execute(pool).await?;
171
    Ok(())
172
}
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.
180
pub async fn queue_delivery(pool: &Pool, hook_id: i64, delivery: &str, event: &str, payload: &str) -> sqlx::Result<Option<i64>> {
181
    let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = $1 AND delivered_ms IS NULL AND next_ms < $2")
182
        .bind(hook_id)
183
        .bind(i64::MAX / 4)
184
        .fetch_one(pool)
185
        .await?;
186
    if waiting >= MAX_WAITING {
187
        return Ok(None);
188
    }
189
    let now = now_ms();
190
    sqlx::query_scalar(
191
        "INSERT INTO deliveries (hook_id, delivery, event, payload, created_ms, next_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
192
    )
193
    .bind(hook_id)
194
    .bind(delivery)
195
    .bind(event)
196
    .bind(payload)
197
    .bind(now)
198
    .bind(now)
199
    .fetch_one(pool)
200
    .await
201
    .map(Some)
202
}
203
204
pub async fn due_deliveries(pool: &Pool, limit: i64) -> sqlx::Result<Vec<Delivery>> {
205
    sqlx::query_as("SELECT * FROM deliveries WHERE delivered_ms IS NULL AND next_ms <= $1 ORDER BY next_ms LIMIT $2")
206
        .bind(now_ms())
207
        .bind(limit)
208
        .fetch_all(pool)
209
        .await
210
}
211
212
pub async fn deliveries_for_hook(pool: &Pool, hook_id: i64, limit: i64) -> sqlx::Result<Vec<Delivery>> {
213
    sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = $1 ORDER BY created_ms DESC, id DESC LIMIT $2")
214
        .bind(hook_id)
215
        .bind(limit)
216
        .fetch_all(pool)
217
        .await
218
}
219
220
pub async fn delivery(pool: &Pool, id: i64) -> sqlx::Result<Option<Delivery>> {
221
    sqlx::query_as("SELECT * FROM deliveries WHERE id = $1").bind(id).fetch_optional(pool).await
222
}
223
224
/// Record an attempt: done on a 2xx, else retried with backoff up to a day.
225
pub async fn record_attempt(pool: &Pool, id: i64, attempts: i64, status: Option<i64>, response: &str) -> sqlx::Result<()> {
226
    let ok = status.is_some_and(|s| (200..300).contains(&s));
227
    let now = now_ms();
228
    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.
230
    let backoff_ms = [60_000i64, 300_000, 1_800_000, 7_200_000, 21_600_000];
231
    let next = if ok || attempts as usize >= backoff_ms.len() { i64::MAX / 4 } else { now + backoff_ms[attempts as usize] };
232
    sqlx::query("UPDATE deliveries SET attempts = $1, status = $2, response = $3, delivered_ms = $4, next_ms = $5 WHERE id = $6")
233
        .bind(attempts + 1)
234
        .bind(status)
235
        .bind(response.chars().take(2000).collect::<String>())
236
        .bind(delivered)
237
        .bind(next)
238
        .bind(id)
239
        .execute(pool)
240
        .await?;
241
    Ok(())
242
}
243
244
/// Requeue a delivery for an immediate retry.
245
pub async fn redeliver(pool: &Pool, id: i64) -> sqlx::Result<()> {
246
    sqlx::query("UPDATE deliveries SET delivered_ms = NULL, next_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
247
    Ok(())
248
}
249
250
pub async fn prune_deliveries(pool: &Pool, older_than_ms: i64) -> sqlx::Result<()> {
251
    sqlx::query("DELETE FROM deliveries WHERE created_ms < $1").bind(older_than_ms).execute(pool).await?;
252
    Ok(())
253
}
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 {
274
    pub fn done(&self) -> bool {
275
        matches!(self.status.as_str(), "passed" | "failed" | "cancelled" | "skipped")
276
    }
277
}
278
279
pub async fn checks_for_state(pool: &Pool, repo_id: i64, state: &str) -> sqlx::Result<Vec<Check>> {
280
    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
281
}
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
295
pub async fn upsert_check(pool: &Pool, repo_id: i64, c: CheckReport<'_>) -> sqlx::Result<Check> {
296
    let CheckReport { state, name, status, summary, url, reporter, seal } = c;
297
    let now = now_ms();
298
    let finished: Option<i64> = if matches!(status, "passed" | "failed" | "cancelled" | "skipped") { Some(now) } else { None };
299
    sqlx::query_as(
300
        "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) \
301
         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
    )
303
    .bind(repo_id)
304
    .bind(state)
305
    .bind(name)
306
    .bind(status)
307
    .bind(summary)
308
    .bind(url)
309
    .bind(reporter.map(|r| r.0))
310
    .bind(reporter.map(|r| r.1).unwrap_or(""))
311
    .bind(seal)
312
    .bind(now)
313
    .bind(finished)
314
    .fetch_one(pool)
315
    .await
316
}
317
318
pub async fn required_checks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> {
319
    let s: String = sqlx::query_scalar("SELECT required_checks FROM repositories WHERE id = $1").bind(repo_id).fetch_one(pool).await?;
320
    Ok(s.split(',').map(str::trim).filter(|x| !x.is_empty()).map(String::from).collect())
321
}
322
323
pub async fn set_required_checks(pool: &Pool, repo_id: i64, names: &[String]) -> sqlx::Result<()> {
324
    sqlx::query("UPDATE repositories SET required_checks = $1 WHERE id = $2").bind(names.join(",")).bind(repo_id).execute(pool).await?;
325
    Ok(())
326
}