| 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 | | |
| 17 | | pub format: String, |
| 18 | | } |
| 19 | |
|
| 20 | |
|
| 21 | | pub const FORMATS: [&str; 3] = ["oxygenhub", "discord", "slack"]; |
| 22 | |
|
| 23 | |
|
| 24 | |
|
| 25 | 9× | pub fn format_for_url(url: &str) -> &'static str { |
| 26 | 9× | let host = url.split("://").nth(1).unwrap_or("").split(['/', ':']).next().unwrap_or("").to_ascii_lowercase(); |
| 27 | 9× | if matches!(host.as_str(), "discord.com" | "discordapp.com" | "canary.discord.com" | "ptb.discord.com") |
| 28 | 1× | && url.contains("/api/webhooks/") |
| 29 | | { |
| 30 | 1× | "discord" |
| 31 | 8× | } else if host == "hooks.slack.com" { |
| 32 | 1× | "slack" |
| 33 | | } else { |
| 34 | 7× | "oxygenhub" |
| 35 | | } |
| 36 | 9× | } |
| 37 | |
|
| 38 | |
|
| 39 | |
|
| 40 | 19× | pub fn masked_url(url: &str) -> String { |
| 41 | 19× | let (scheme, rest) = url.split_once("://").unwrap_or(("", url)); |
| 42 | 19× | let (path, query) = rest.split_once('?').map(|(p, q)| (p, Some(q))).unwrap_or((rest, None)); |
| 43 | 19× | let parts: Vec<String> = |
| 44 | 19× | path.split('/') |
| 45 | 19× | .enumerate() |
| 46 | 41× | .map(|(i, seg)| { |
| 47 | 41× | if i > 0 && seg.chars().count() > 14 { |
| 48 | 2× | format!("{}…", seg.chars().take(4).collect::<String>()) |
| 49 | | } else { |
| 50 | 39× | seg.to_string() |
| 51 | | } |
| 52 | 41× | }) |
| 53 | 19× | .collect(); |
| 54 | 19× | let mut out = if scheme.is_empty() { parts.join("/") } else { format!("{scheme}://{}", parts.join("/")) }; |
| 55 | 19× | if query.is_some() { |
| 56 | 1× | out.push_str("?…"); |
| 57 | 18× | } |
| 58 | 19× | out |
| 59 | 19× | } |
| 60 | |
|
| 61 | | impl Hook { |
| 62 | | |
| 63 | 18× | pub fn wants(&self, event: &str) -> bool { |
| 64 | 36× | self.active && self.events.split(',').map(str::trim).any(|e| e == "*" || event == e || event.starts_with(&format!("{e}."))) |
| 65 | 18× | } |
| 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 | 1249× | pub async fn hooks_for_repo(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Hook>> { |
| 84 | 1249× | sqlx::query_as("SELECT * FROM hooks WHERE repo_id = $1 ORDER BY id").bind(repo_id).fetch_all(pool).await |
| 85 | 1248× | } |
| 86 | |
|
| 87 | 39× | pub async fn hooks_for_org(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<Hook>> { |
| 88 | 39× | sqlx::query_as("SELECT * FROM hooks WHERE org_id = $1 ORDER BY id").bind(org_id).fetch_all(pool).await |
| 89 | 39× | } |
| 90 | |
|
| 91 | |
|
| 92 | |
|
| 93 | 1114× | pub async fn hooks_hearing(pool: &Pool, repo_id: i64, owner: &str) -> sqlx::Result<Vec<Hook>> { |
| 94 | 1114× | let mut out = hooks_for_repo(pool, repo_id).await?; |
| 95 | 1113× | if let Some(org) = super::accounts::org_by_name(pool, owner).await? { |
| 96 | 7× | out.extend(hooks_for_org(pool, org.id).await?); |
| 97 | 1105× | } |
| 98 | 1112× | Ok(out) |
| 99 | 1112× | } |
| 100 | |
|
| 101 | 27× | pub async fn hook(pool: &Pool, id: i64) -> sqlx::Result<Option<Hook>> { |
| 102 | 27× | sqlx::query_as("SELECT * FROM hooks WHERE id = $1").bind(id).fetch_optional(pool).await |
| 103 | 27× | } |
| 104 | |
|
| 105 | 5× | pub async fn create_hook( |
| 106 | 5× | pool: &Pool, |
| 107 | 5× | repo_id: Option<i64>, |
| 108 | 5× | org_id: Option<i64>, |
| 109 | 5× | url: &str, |
| 110 | 5× | secret: &str, |
| 111 | 5× | events: &str, |
| 112 | 5× | ) -> sqlx::Result<Hook> { |
| 113 | 5× | sqlx::query_as( |
| 114 | 5× | "INSERT INTO hooks (repo_id, org_id, url, secret, events, created_ms, format) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", |
| 115 | 5× | ) |
| 116 | 5× | .bind(repo_id) |
| 117 | 5× | .bind(org_id) |
| 118 | 5× | .bind(url) |
| 119 | 5× | .bind(secret) |
| 120 | 5× | .bind(events) |
| 121 | 5× | .bind(now_ms()) |
| 122 | 5× | .bind(format_for_url(url)) |
| 123 | 5× | .fetch_one(pool) |
| 124 | 5× | .await |
| 125 | 5× | } |
| 126 | |
|
| 127 | |
|
| 128 | 2× | pub async fn update_hook( |
| 129 | 2× | pool: &Pool, |
| 130 | 2× | id: i64, |
| 131 | 2× | url: Option<&str>, |
| 132 | 2× | secret: Option<&str>, |
| 133 | 2× | events: &str, |
| 134 | 2× | format: &str, |
| 135 | 2× | active: bool, |
| 136 | 2× | ) -> sqlx::Result<()> { |
| 137 | 2× | sqlx::query( |
| 138 | 2× | "UPDATE hooks SET url = COALESCE($1, url), secret = COALESCE($2, secret), events = $3, format = $4, active = $5 WHERE id = $6", |
| 139 | 2× | ) |
| 140 | 2× | .bind(url) |
| 141 | 2× | .bind(secret) |
| 142 | 2× | .bind(events) |
| 143 | 2× | .bind(format) |
| 144 | 2× | .bind(active) |
| 145 | 2× | .bind(id) |
| 146 | 2× | .execute(pool) |
| 147 | 2× | .await?; |
| 148 | 2× | Ok(()) |
| 149 | 2× | } |
| 150 | |
|
| 151 | 1× | pub async fn set_hook_format(pool: &Pool, id: i64, format: &str) -> sqlx::Result<()> { |
| 152 | 1× | sqlx::query("UPDATE hooks SET format = $1 WHERE id = $2").bind(format).bind(id).execute(pool).await?; |
| 153 | 1× | Ok(()) |
| 154 | 1× | } |
| 155 | |
|
| 156 | |
|
| 157 | 12× | pub async fn last_attempt(pool: &Pool, hook_id: i64) -> sqlx::Result<Option<Delivery>> { |
| 158 | 12× | sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = $1 AND attempts > 0 ORDER BY id DESC LIMIT 1") |
| 159 | 12× | .bind(hook_id) |
| 160 | 12× | .fetch_optional(pool) |
| 161 | 12× | .await |
| 162 | 12× | } |
| 163 | |
|
| 164 | 0× | pub async fn set_hook_active(pool: &Pool, id: i64, active: bool) -> sqlx::Result<()> { |
| 165 | 0× | sqlx::query("UPDATE hooks SET active = $1 WHERE id = $2").bind(active).bind(id).execute(pool).await?; |
| 166 | 0× | Ok(()) |
| 167 | 0× | } |
| 168 | |
|
| 169 | 0× | pub async fn delete_hook(pool: &Pool, id: i64) -> sqlx::Result<()> { |
| 170 | 0× | sqlx::query("DELETE FROM hooks WHERE id = $1").bind(id).execute(pool).await?; |
| 171 | 0× | Ok(()) |
| 172 | 0× | } |
| 173 | |
|
| 174 | |
|
| 175 | |
|
| 176 | | pub const MAX_WAITING: i64 = 1000; |
| 177 | |
|
| 178 | |
|
| 179 | |
|
| 180 | 17× | pub async fn queue_delivery(pool: &Pool, hook_id: i64, delivery: &str, event: &str, payload: &str) -> sqlx::Result<Option<i64>> { |
| 181 | 17× | let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = $1 AND delivered_ms IS NULL AND next_ms < $2") |
| 182 | 17× | .bind(hook_id) |
| 183 | 17× | .bind(i64::MAX / 4) |
| 184 | 17× | .fetch_one(pool) |
| 185 | 17× | .await?; |
| 186 | 17× | if waiting >= MAX_WAITING { |
| 187 | 1× | return Ok(None); |
| 188 | 16× | } |
| 189 | 16× | let now = now_ms(); |
| 190 | 16× | sqlx::query_scalar( |
| 191 | 16× | "INSERT INTO deliveries (hook_id, delivery, event, payload, created_ms, next_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", |
| 192 | 16× | ) |
| 193 | 16× | .bind(hook_id) |
| 194 | 16× | .bind(delivery) |
| 195 | 16× | .bind(event) |
| 196 | 16× | .bind(payload) |
| 197 | 16× | .bind(now) |
| 198 | 16× | .bind(now) |
| 199 | 16× | .fetch_one(pool) |
| 200 | 16× | .await |
| 201 | 16× | .map(Some) |
| 202 | 17× | } |
| 203 | |
|
| 204 | 222× | pub async fn due_deliveries(pool: &Pool, limit: i64) -> sqlx::Result<Vec<Delivery>> { |
| 205 | 222× | sqlx::query_as("SELECT * FROM deliveries WHERE delivered_ms IS NULL AND next_ms <= $1 ORDER BY next_ms LIMIT $2") |
| 206 | 222× | .bind(now_ms()) |
| 207 | 222× | .bind(limit) |
| 208 | 222× | .fetch_all(pool) |
| 209 | 222× | .await |
| 210 | 222× | } |
| 211 | |
|
| 212 | 1× | pub async fn deliveries_for_hook(pool: &Pool, hook_id: i64, limit: i64) -> sqlx::Result<Vec<Delivery>> { |
| 213 | 1× | sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = $1 ORDER BY created_ms DESC, id DESC LIMIT $2") |
| 214 | 1× | .bind(hook_id) |
| 215 | 1× | .bind(limit) |
| 216 | 1× | .fetch_all(pool) |
| 217 | 1× | .await |
| 218 | 1× | } |
| 219 | |
|
| 220 | 0× | pub async fn delivery(pool: &Pool, id: i64) -> sqlx::Result<Option<Delivery>> { |
| 221 | 0× | sqlx::query_as("SELECT * FROM deliveries WHERE id = $1").bind(id).fetch_optional(pool).await |
| 222 | 0× | } |
| 223 | |
|
| 224 | |
|
| 225 | 14× | pub async fn record_attempt(pool: &Pool, id: i64, attempts: i64, status: Option<i64>, response: &str) -> sqlx::Result<()> { |
| 226 | 14× | let ok = status.is_some_and(|s| (200..300).contains(&s)); |
| 227 | 14× | let now = now_ms(); |
| 228 | 14× | let delivered: Option<i64> = if ok { Some(now) } else { None }; |
| 229 | | |
| 230 | 14× | let backoff_ms = [60_000i64, 300_000, 1_800_000, 7_200_000, 21_600_000]; |
| 231 | 14× | let next = if ok || attempts as usize >= backoff_ms.len() { i64::MAX / 4 } else { now + backoff_ms[attempts as usize] }; |
| 232 | 14× | sqlx::query("UPDATE deliveries SET attempts = $1, status = $2, response = $3, delivered_ms = $4, next_ms = $5 WHERE id = $6") |
| 233 | 14× | .bind(attempts + 1) |
| 234 | 14× | .bind(status) |
| 235 | 14× | .bind(response.chars().take(2000).collect::<String>()) |
| 236 | 14× | .bind(delivered) |
| 237 | 14× | .bind(next) |
| 238 | 14× | .bind(id) |
| 239 | 14× | .execute(pool) |
| 240 | 14× | .await?; |
| 241 | 14× | Ok(()) |
| 242 | 14× | } |
| 243 | |
|
| 244 | |
|
| 245 | 0× | pub async fn redeliver(pool: &Pool, id: i64) -> sqlx::Result<()> { |
| 246 | 0× | sqlx::query("UPDATE deliveries SET delivered_ms = NULL, next_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?; |
| 247 | 0× | Ok(()) |
| 248 | 0× | } |
| 249 | |
|
| 250 | 219× | pub async fn prune_deliveries(pool: &Pool, older_than_ms: i64) -> sqlx::Result<()> { |
| 251 | 219× | sqlx::query("DELETE FROM deliveries WHERE created_ms < $1").bind(older_than_ms).execute(pool).await?; |
| 252 | 218× | Ok(()) |
| 253 | 218× | } |
| 254 | |
|
| 255 | |
|
| 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 | 0× | pub fn done(&self) -> bool { |
| 275 | 0× | matches!(self.status.as_str(), "passed" | "failed" | "cancelled" | "skipped") |
| 276 | 0× | } |
| 277 | | } |
| 278 | |
|
| 279 | 159× | pub async fn checks_for_state(pool: &Pool, repo_id: i64, state: &str) -> sqlx::Result<Vec<Check>> { |
| 280 | 159× | 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 | 159× | } |
| 282 | |
|
| 283 | |
|
| 284 | |
|
| 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 | 728× | pub async fn upsert_check(pool: &Pool, repo_id: i64, c: CheckReport<'_>) -> sqlx::Result<Check> { |
| 296 | 728× | let CheckReport { state, name, status, summary, url, reporter, seal } = c; |
| 297 | 728× | let now = now_ms(); |
| 298 | 728× | let finished: Option<i64> = if matches!(status, "passed" | "failed" | "cancelled" | "skipped") { Some(now) } else { None }; |
| 299 | 728× | sqlx::query_as( |
| 300 | 728× | "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 | 728× | 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 | 728× | .bind(repo_id) |
| 304 | 728× | .bind(state) |
| 305 | 728× | .bind(name) |
| 306 | 728× | .bind(status) |
| 307 | 728× | .bind(summary) |
| 308 | 728× | .bind(url) |
| 309 | 728× | .bind(reporter.map(|r| r.0)) |
| 310 | 728× | .bind(reporter.map(|r| r.1).unwrap_or("")) |
| 311 | 728× | .bind(seal) |
| 312 | 728× | .bind(now) |
| 313 | 728× | .bind(finished) |
| 314 | 728× | .fetch_one(pool) |
| 315 | 728× | .await |
| 316 | 728× | } |
| 317 | |
|
| 318 | 348× | pub async fn required_checks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> { |
| 319 | 348× | let s: String = sqlx::query_scalar("SELECT required_checks FROM repositories WHERE id = $1").bind(repo_id).fetch_one(pool).await?; |
| 320 | 351× | Ok(s.split(',').map(str::trim).filter(|x| !x.is_empty()).map(String::from).collect()) |
| 321 | 348× | } |
| 322 | |
|
| 323 | 5× | pub async fn set_required_checks(pool: &Pool, repo_id: i64, names: &[String]) -> sqlx::Result<()> { |
| 324 | 5× | sqlx::query("UPDATE repositories SET required_checks = $1 WHERE id = $2").bind(names.join(",")).bind(repo_id).execute(pool).await?; |
| 325 | 5× | Ok(()) |
| 326 | 5× | } |