Webhooks: Discord and Slack formats (Discord hooks switched over), a masked URL that wraps, the last delivery in the table, an edit form, and web issue events delivered 9756c67d3aec
closes #16 closes #17
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 15:20 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
9756c67d3aec8640ec1c40e2e05a6b9d740a39e357f10c6012f749bb93a1914a- Depends on
- 18e381ea88c2 19bb31ff1f5c 76f262e1a666 8fb35df7d66e 93be9ce4653e b64e2537d702 d23464c0ac60 de505df5cf8d decb6304ba97
- Depended on by
- dc5c64296b0a c7067c7bda56 a7aa76ac52d7 c8684d44d549 5d90e0e045e2 c98935537c57 9a777fec4ed9 7974adb0be66 83d5052bf76c ef20fdc14e8b
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 36 atoms, 1 tree operation, 12 files
Browse the tree at this change
added crates/hub/migrations/0016_hook_format.sql +8 −0
| 1 | + | -- The body a webhook receives (hub spec §12.2): `oxygenhub` (the signed |
|
| 2 | + | -- envelope), `discord` or `slack`. Discord and Slack webhooks made before |
|
| 3 | + | -- formats existed switch to their format, since they never accepted the envelope. |
|
| 4 | + | ALTER TABLE hooks ADD COLUMN format TEXT NOT NULL DEFAULT 'oxygenhub' CHECK (format IN ('oxygenhub', 'discord', 'slack')); |
|
| 5 | + | UPDATE hooks SET format = 'discord' |
|
| 6 | + | WHERE url LIKE 'https://discord.com/api/webhooks/%' OR url LIKE 'https://discordapp.com/api/webhooks/%' |
|
| 7 | + | OR url LIKE 'https://canary.discord.com/api/webhooks/%' OR url LIKE 'https://ptb.discord.com/api/webhooks/%'; |
|
| 8 | + | UPDATE hooks SET format = 'slack' WHERE url LIKE 'https://hooks.slack.com/%'; |
modified crates/hub/src/db/hooks.rs +80 −1
| … | 12 unchanged lines |
||
| 13 | 13 | pub events: String, |
|
| 14 | 14 | pub active: bool, |
|
| 15 | 15 | pub created_ms: i64, |
|
| 16 | + | /// `oxygenhub`, `discord` or `slack`. |
|
| 17 | + | pub format: String, |
|
| 16 | 18 | } |
|
| 19 | + | ||
| 20 | + | /// The formats a webhook can be delivered in. |
|
| 21 | + | pub const FORMATS: [&str; 3] = ["oxygenhub", "discord", "slack"]; |
|
| 17 | 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 | + | ||
| 18 | 61 | impl Hook { |
|
| 19 | 62 | /// Whether the hook wants `event` (`*`, or a comma list of prefixes such as `push,proposal`). |
|
| 20 | 63 | pub fn wants(&self, event: &str) -> bool { |
|
| … | 46 unchanged lines |
||
| 67 | 110 | secret: &str, |
|
| 68 | 111 | events: &str, |
|
| 69 | 112 | ) -> sqlx::Result<Hook> { |
|
| 70 | - | sqlx::query_as("INSERT INTO hooks (repo_id, org_id, url, secret, events, created_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *") |
|
| 113 | + | sqlx::query_as("INSERT INTO hooks (repo_id, org_id, url, secret, events, created_ms, format) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *") |
|
| 71 | 114 | .bind(repo_id) |
|
| 72 | 115 | .bind(org_id) |
|
| 73 | 116 | .bind(url) |
|
| 74 | 117 | .bind(secret) |
|
| 75 | 118 | .bind(events) |
|
| 76 | 119 | .bind(now_ms()) |
|
| 120 | + | .bind(format_for_url(url)) |
|
| 77 | 121 | .fetch_one(pool) |
|
| 122 | + | .await |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | /// Change a hook; `None` keeps the URL or secret as it is. |
|
| 126 | + | pub async fn update_hook( |
|
| 127 | + | pool: &Pool, |
|
| 128 | + | id: i64, |
|
| 129 | + | url: Option<&str>, |
|
| 130 | + | secret: Option<&str>, |
|
| 131 | + | events: &str, |
|
| 132 | + | format: &str, |
|
| 133 | + | active: bool, |
|
| 134 | + | ) -> sqlx::Result<()> { |
|
| 135 | + | sqlx::query("UPDATE hooks SET url = COALESCE(?, url), secret = COALESCE(?, secret), events = ?, format = ?, active = ? WHERE id = ?") |
|
| 136 | + | .bind(url) |
|
| 137 | + | .bind(secret) |
|
| 138 | + | .bind(events) |
|
| 139 | + | .bind(format) |
|
| 140 | + | .bind(active) |
|
| 141 | + | .bind(id) |
|
| 142 | + | .execute(pool) |
|
| 143 | + | .await?; |
|
| 144 | + | Ok(()) |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | pub async fn set_hook_format(pool: &Pool, id: i64, format: &str) -> sqlx::Result<()> { |
|
| 148 | + | sqlx::query("UPDATE hooks SET format = ? WHERE id = ?").bind(format).bind(id).execute(pool).await?; |
|
| 149 | + | Ok(()) |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | /// The newest delivery that was attempted, for the settings table. |
|
| 153 | + | pub async fn last_attempt(pool: &Pool, hook_id: i64) -> sqlx::Result<Option<Delivery>> { |
|
| 154 | + | sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = ? AND attempts > 0 ORDER BY id DESC LIMIT 1") |
|
| 155 | + | .bind(hook_id) |
|
| 156 | + | .fetch_optional(pool) |
|
| 78 | 157 | .await |
|
| 79 | 158 | } |
|
| 80 | 159 | ||
| … | 146 unchanged lines |
modified crates/hub/src/hooks.rs +187 −2
| … | 44 unchanged lines |
||
| 45 | 45 | app.events.notify_deliveries(); |
|
| 46 | 46 | } |
|
| 47 | 47 | ||
| 48 | + | /// What a chat message says about an event: a title, the page it links |
|
| 49 | + | /// to (absolute) and a short detail. |
|
| 50 | + | pub fn summary(env: &WebhookEnvelope, base: &str) -> (String, String, String) { |
|
| 51 | + | let repo = env.repository.as_ref().map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default(); |
|
| 52 | + | let page = |path: &str| format!("{}/{repo}{path}", base.trim_end_matches('/')); |
|
| 53 | + | let d = &env.data; |
|
| 54 | + | let text = |v: &serde_json::Value| v.as_str().unwrap_or("").to_string(); |
|
| 55 | + | let num = |v: &serde_json::Value| v.as_i64().unwrap_or(0); |
|
| 56 | + | let actor = if env.actor.is_empty() { "Someone".to_string() } else { env.actor.clone() }; |
|
| 57 | + | let issue_n = if d["issue"]["number"].is_i64() { num(&d["issue"]["number"]) } else { num(&d["number"]) }; |
|
| 58 | + | let issue_title = text(&d["issue"]["title"]); |
|
| 59 | + | match env.event.as_str() { |
|
| 60 | + | "ping" => ( |
|
| 61 | + | "OxygenHub is connected".into(), |
|
| 62 | + | if repo.is_empty() { base.to_string() } else { page("") }, |
|
| 63 | + | "Events from OxygenHub will arrive here.".into(), |
|
| 64 | + | ), |
|
| 65 | + | "push" => { |
|
| 66 | + | let lines: Vec<String> = d["updates"] |
|
| 67 | + | .as_array() |
|
| 68 | + | .map(|u| { |
|
| 69 | + | u.iter() |
|
| 70 | + | .map(|x| { |
|
| 71 | + | let n = num(&x["changes"]); |
|
| 72 | + | format!("{}: {n} change{}", text(&x["reference"]), if n == 1 { "" } else { "s" }) |
|
| 73 | + | }) |
|
| 74 | + | .collect() |
|
| 75 | + | }) |
|
| 76 | + | .unwrap_or_default(); |
|
| 77 | + | let first = d["updates"][0]["reference"].as_str().unwrap_or("").to_string(); |
|
| 78 | + | (format!("{actor} pushed to {repo}"), page(&format!("/changes/{first}")), lines.join("\n")) |
|
| 79 | + | } |
|
| 80 | + | "issue.opened" => { |
|
| 81 | + | (format!("{actor} opened issue #{issue_n}: {issue_title}"), page(&format!("/issue/{issue_n}")), text(&d["issue"]["body"])) |
|
| 82 | + | } |
|
| 83 | + | "issue.closed" | "issue.reopened" | "issue.edited" => { |
|
| 84 | + | let verb = env.event.trim_start_matches("issue."); |
|
| 85 | + | (format!("{actor} {verb} issue #{issue_n}: {issue_title}"), page(&format!("/issue/{issue_n}")), String::new()) |
|
| 86 | + | } |
|
| 87 | + | "comment.issue" => { |
|
| 88 | + | let body = if d["comment"]["body"].is_string() { text(&d["comment"]["body"]) } else { String::new() }; |
|
| 89 | + | ( |
|
| 90 | + | format!( |
|
| 91 | + | "{actor} commented on issue #{issue_n}{}", |
|
| 92 | + | if issue_title.is_empty() { String::new() } else { format!(": {issue_title}") } |
|
| 93 | + | ), |
|
| 94 | + | page(&format!("/issue/{issue_n}")), |
|
| 95 | + | body, |
|
| 96 | + | ) |
|
| 97 | + | } |
|
| 98 | + | "review" => ( |
|
| 99 | + | format!("{actor} reviewed proposal #{}: {}", num(&d["number"]), text(&d["verdict"])), |
|
| 100 | + | page(&format!("/proposal/{}", num(&d["number"]))), |
|
| 101 | + | String::new(), |
|
| 102 | + | ), |
|
| 103 | + | "release.published" => ( |
|
| 104 | + | format!("{actor} released {} ({})", text(&d["title"]), text(&d["tag"])), |
|
| 105 | + | page(&format!("/releases/tag/{}", text(&d["tag"]))), |
|
| 106 | + | String::new(), |
|
| 107 | + | ), |
|
| 108 | + | "check" => ( |
|
| 109 | + | format!("Check {} {} on {repo}", text(&d["check"]["name"]), text(&d["check"]["status"])), |
|
| 110 | + | text(&d["check"]["url"]).chars().take(500).collect::<String>().replace(' ', "%20"), |
|
| 111 | + | text(&d["check"]["summary"]), |
|
| 112 | + | ), |
|
| 113 | + | e if e.starts_with("proposal.") => { |
|
| 114 | + | let n = num(&d["number"]); |
|
| 115 | + | let verb = e.trim_start_matches("proposal."); |
|
| 116 | + | let title = text(&d["title"]); |
|
| 117 | + | ( |
|
| 118 | + | format!("{actor} {verb} proposal #{n}{}", if title.is_empty() { String::new() } else { format!(": {title}") }), |
|
| 119 | + | page(&format!("/proposal/{n}")), |
|
| 120 | + | String::new(), |
|
| 121 | + | ) |
|
| 122 | + | } |
|
| 123 | + | e => (format!("{e} on {repo}"), page(""), String::new()), |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | fn cut(s: &str, max: usize) -> String { |
|
| 128 | + | if s.chars().count() <= max { |
|
| 129 | + | s.to_string() |
|
| 130 | + | } else { |
|
| 131 | + | format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>()) |
|
| 132 | + | } |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | /// The body a hook receives in its format; the envelope itself for `oxygenhub`. |
|
| 136 | + | pub fn body_for(format: &str, payload: &str, base: &str) -> String { |
|
| 137 | + | let Ok(env) = serde_json::from_str::<WebhookEnvelope>(payload) else { return payload.to_string() }; |
|
| 138 | + | let (title, url, detail) = summary(&env, base); |
|
| 139 | + | let repo = env.repository.as_ref().map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_else(|| "OxygenHub".into()); |
|
| 140 | + | match format { |
|
| 141 | + | "discord" => { |
|
| 142 | + | let mut embed = serde_json::json!({ |
|
| 143 | + | "title": cut(&title, 256), |
|
| 144 | + | "description": cut(&detail, 2000), |
|
| 145 | + | "color": 0x0064e0, |
|
| 146 | + | "footer": { "text": cut(&format!("{repo} · {}", env.event), 200) }, |
|
| 147 | + | "timestamp": crate::render::time::rfc3339(env.at_ms as u64), |
|
| 148 | + | }); |
|
| 149 | + | if url.starts_with("http") { |
|
| 150 | + | embed["url"] = serde_json::Value::String(url); |
|
| 151 | + | } |
|
| 152 | + | // No @everyone, @here or role pings from anything a user typed. |
|
| 153 | + | serde_json::json!({ "username": "OxygenHub", "allowed_mentions": { "parse": [] }, "embeds": [embed] }).to_string() |
|
| 154 | + | } |
|
| 155 | + | "slack" => { |
|
| 156 | + | let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); |
|
| 157 | + | let head = if url.starts_with("http") { format!("<{}|{}>", url, esc(&title)) } else { esc(&title) }; |
|
| 158 | + | let text = if detail.is_empty() { head } else { format!("{head}\n>{}", esc(&cut(&detail, 1500)).replace('\n', "\n>")) }; |
|
| 159 | + | serde_json::json!({ "text": text }).to_string() |
|
| 160 | + | } |
|
| 161 | + | _ => payload.to_string(), |
|
| 162 | + | } |
|
| 163 | + | } |
|
| 164 | + | ||
| 48 | 165 | /// `X-Oxyhub-Signature` for a body: keyed BLAKE3 with the hook's secret. |
|
| 49 | 166 | pub fn signature(secret: &str, body: &[u8]) -> String { |
|
| 50 | 167 | let key = blake3::hash(secret.as_bytes()); |
|
| … | 15 unchanged lines |
||
| 66 | 183 | for d in due { |
|
| 67 | 184 | let Ok(Some(hook)) = hdb::hook(&app.db, d.hook_id).await else { continue }; |
|
| 68 | 185 | n += 1; |
|
| 186 | + | let body = body_for(&hook.format, &d.payload, app.config.base_url()); |
|
| 69 | 187 | let (status, response) = match client |
|
| 70 | 188 | .post(&hook.url) |
|
| 71 | 189 | .header("content-type", "application/json") |
|
| 72 | 190 | .header("x-oxyhub-event", &d.event) |
|
| 73 | 191 | .header("x-oxyhub-delivery", &d.delivery) |
|
| 74 | - | .header("x-oxyhub-signature", signature(&hook.secret, d.payload.as_bytes())) |
|
| 75 | - | .body(d.payload.clone()) |
|
| 192 | + | .header("x-oxyhub-signature", signature(&hook.secret, body.as_bytes())) |
|
| 193 | + | .body(body) |
|
| 76 | 194 | .send() |
|
| 77 | 195 | .await |
|
| 78 | 196 | { |
|
| … | 26 unchanged lines |
||
| 105 | 223 | #[cfg(test)] |
|
| 106 | 224 | mod tests { |
|
| 107 | 225 | use super::*; |
|
| 226 | + | ||
| 227 | + | fn envelope(event: &str, data: serde_json::Value) -> String { |
|
| 228 | + | serde_json::to_string(&WebhookEnvelope { |
|
| 229 | + | event: event.into(), |
|
| 230 | + | delivery: "d".into(), |
|
| 231 | + | repository: Some(hub_api::Repository { |
|
| 232 | + | owner: "andymccall".into(), |
|
| 233 | + | name: "oxygen".into(), |
|
| 234 | + | description: String::new(), |
|
| 235 | + | default_bookmark: "main".into(), |
|
| 236 | + | visibility: hub_api::Visibility::Public, |
|
| 237 | + | created_ms: 0, |
|
| 238 | + | updated_ms: 0, |
|
| 239 | + | clone_url: String::new(), |
|
| 240 | + | }), |
|
| 241 | + | actor: "andy".into(), |
|
| 242 | + | at_ms: 1_790_000_000_000, |
|
| 243 | + | data, |
|
| 244 | + | }) |
|
| 245 | + | .unwrap() |
|
| 246 | + | } |
|
| 247 | + | ||
| 248 | + | #[test] |
|
| 249 | + | fn discord_gets_an_embed_without_mentions() { |
|
| 250 | + | let p = envelope( |
|
| 251 | + | "issue.opened", |
|
| 252 | + | serde_json::json!({ "issue": { "number": 12, "title": "@everyone crash", "body": "x".repeat(3000) } }), |
|
| 253 | + | ); |
|
| 254 | + | let v: serde_json::Value = serde_json::from_str(&body_for("discord", &p, "https://hub.example:7343/")).unwrap(); |
|
| 255 | + | let e = &v["embeds"][0]; |
|
| 256 | + | assert_eq!(e["title"], "andy opened issue #12: @everyone crash"); |
|
| 257 | + | assert_eq!(e["url"], "https://hub.example:7343/andymccall/oxygen/issue/12"); |
|
| 258 | + | assert_eq!(e["description"].as_str().unwrap().chars().count(), 2000, "cut to Discord's limits"); |
|
| 259 | + | assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([]), "no pings from user text"); |
|
| 260 | + | assert_eq!(v["username"], "OxygenHub"); |
|
| 261 | + | let push = envelope("push", serde_json::json!({ "updates": [{ "reference": "main", "changes": 3 }] })); |
|
| 262 | + | let v: serde_json::Value = serde_json::from_str(&body_for("discord", &push, "https://h")).unwrap(); |
|
| 263 | + | assert_eq!(v["embeds"][0]["title"], "andy pushed to andymccall/oxygen"); |
|
| 264 | + | assert_eq!(v["embeds"][0]["description"], "main: 3 changes"); |
|
| 265 | + | assert_eq!(v["embeds"][0]["url"], "https://h/andymccall/oxygen/changes/main"); |
|
| 266 | + | let ping = envelope("ping", serde_json::json!({})); |
|
| 267 | + | assert!(body_for("discord", &ping, "https://h").contains("OxygenHub is connected")); |
|
| 268 | + | } |
|
| 269 | + | ||
| 270 | + | #[test] |
|
| 271 | + | fn slack_text_is_escaped_and_the_envelope_is_unchanged() { |
|
| 272 | + | let p = envelope( |
|
| 273 | + | "comment.issue", |
|
| 274 | + | serde_json::json!({ "number": 3, "issue": { "number": 3, "title": "<b>" }, "comment": { "body": "a & b" } }), |
|
| 275 | + | ); |
|
| 276 | + | let v: serde_json::Value = serde_json::from_str(&body_for("slack", &p, "https://h")).unwrap(); |
|
| 277 | + | assert_eq!(v["text"], "<https://h/andymccall/oxygen/issue/3|andy commented on issue #3: <b>>\n>a & b"); |
|
| 278 | + | assert_eq!(body_for("oxygenhub", &p, "https://h"), p); |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | #[test] |
|
| 282 | + | fn formats_follow_the_url_and_urls_are_masked() { |
|
| 283 | + | use crate::db::hooks::{format_for_url, masked_url}; |
|
| 284 | + | assert_eq!(format_for_url("https://discord.com/api/webhooks/155/abc"), "discord"); |
|
| 285 | + | assert_eq!(format_for_url("https://hooks.slack.com/services/T/B/x"), "slack"); |
|
| 286 | + | assert_eq!(format_for_url("https://discord.com.evil.example/api/webhooks/1"), "oxygenhub"); |
|
| 287 | + | assert_eq!(format_for_url("https://ci.example/hook"), "oxygenhub"); |
|
| 288 | + | let m = masked_url("https://discord.com/api/webhooks/1554146511347126332/6g_WnXRdNOiVoo8sGXXW_OuIUceluD8lIUckxb9KHPwZ895XE"); |
|
| 289 | + | assert_eq!(m, "https://discord.com/api/webhooks/1554…/6g_W…"); |
|
| 290 | + | assert!(!m.contains("6g_WnX"), "the token is not shown"); |
|
| 291 | + | assert_eq!(masked_url("https://ci.example/hook?token=secret"), "https://ci.example/hook?…"); |
|
| 292 | + | } |
|
| 108 | 293 | ||
| 109 | 294 | #[test] |
|
| 110 | 295 | fn signatures_are_keyed() { |
|
| … | 6 unchanged lines |
modified crates/hub/src/web/issues.rs +25 −0
| … | 274 unchanged lines |
||
| 275 | 275 | }, |
|
| 276 | 276 | ) |
|
| 277 | 277 | .await; |
|
| 278 | + | crate::hooks::emit( |
|
| 279 | + | &app, |
|
| 280 | + | &row, |
|
| 281 | + | "issue.opened", |
|
| 282 | + | &me.account.username, |
|
| 283 | + | serde_json::json!({ "issue": { "number": issue.number, "title": issue.title, "body": issue.body } }), |
|
| 284 | + | ) |
|
| 285 | + | .await; |
|
| 278 | 286 | Ok(Redirect::to(&url).into_response()) |
|
| 279 | 287 | } |
|
| 280 | 288 | ||
| … | 178 unchanged lines |
||
| 459 | 467 | kind = "issue.reopened"; |
|
| 460 | 468 | } |
|
| 461 | 469 | reindex(&app, row.id, &i).await; |
|
| 470 | + | let hook_event = if kind == "issue.comment" { "comment.issue" } else { kind }; |
|
| 471 | + | crate::hooks::emit( |
|
| 472 | + | &app, |
|
| 473 | + | &row, |
|
| 474 | + | hook_event, |
|
| 475 | + | &me.account.username, |
|
| 476 | + | serde_json::json!({ "number": number, "issue": { "number": number, "title": i.title }, "comment": { "body": f.body.trim() } }), |
|
| 477 | + | ) |
|
| 478 | + | .await; |
|
| 462 | 479 | let direct = i.author_id.into_iter().collect(); |
|
| 463 | 480 | notify::deliver( |
|
| 464 | 481 | &app, |
|
| … | 121 unchanged lines |
||
| 586 | 603 | }; |
|
| 587 | 604 | idb::add_event(&app.db, i.id, &me.account.username, event, "").await.ok(); |
|
| 588 | 605 | if !kind.is_empty() { |
|
| 606 | + | crate::hooks::emit( |
|
| 607 | + | &app, |
|
| 608 | + | &row, |
|
| 609 | + | kind, |
|
| 610 | + | &me.account.username, |
|
| 611 | + | serde_json::json!({ "issue": { "number": number, "title": i.title }, "reason": event }), |
|
| 612 | + | ) |
|
| 613 | + | .await; |
|
| 589 | 614 | let direct = i.author_id.into_iter().collect(); |
|
| 590 | 615 | notify::deliver( |
|
| 591 | 616 | &app, |
|
| … | 367 unchanged lines |
modified crates/hub/src/web/orgs.rs +2 −1
| … | 60 unchanged lines |
||
| 61 | 61 | org: accounts::Organisation, |
|
| 62 | 62 | members: Vec<(accounts::Account, String)>, |
|
| 63 | 63 | teams: Vec<(accounts::Team, Vec<accounts::Account>)>, |
|
| 64 | - | hooks: Vec<crate::db::hooks::Hook>, |
|
| 64 | + | hooks: Vec<super::webhooks::HookRow>, |
|
| 65 | 65 | notice: Option<String>, |
|
| 66 | 66 | error: Option<String>, |
|
| 67 | 67 | } |
|
| … | 12 unchanged lines |
||
| 80 | 80 | teams.push((t, members)); |
|
| 81 | 81 | } |
|
| 82 | 82 | let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 83 | + | let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 83 | 84 | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error })) |
|
| 84 | 85 | } |
|
| 85 | 86 | ||
| … | 213 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +2 −1
| … | 86 unchanged lines |
||
| 87 | 87 | teams: Vec<accounts::Team>, |
|
| 88 | 88 | role: Role, |
|
| 89 | 89 | requirements: db::proposals::Requirements, |
|
| 90 | - | hooks: Vec<db::hooks::Hook>, |
|
| 90 | + | hooks: Vec<super::webhooks::HookRow>, |
|
| 91 | 91 | required_checks: String, |
|
| 92 | 92 | notice: Option<String>, |
|
| 93 | 93 | error: Option<String>, |
|
| … | 15 unchanged lines |
||
| 109 | 109 | let repo = RepoShell::new(app, &row, "settings").with_role(role); |
|
| 110 | 110 | let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 111 | 111 | let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 112 | + | let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 112 | 113 | let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", "); |
|
| 113 | 114 | Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, requirements, hooks, required_checks, notice, error })) |
|
| 114 | 115 | } |
|
| … | 273 unchanged lines |
modified crates/hub/src/web/webhooks.rs +76 −3
| … | 51 unchanged lines |
||
| 52 | 52 | id: Option<i64>, |
|
| 53 | 53 | #[serde(default)] |
|
| 54 | 54 | action: String, |
|
| 55 | + | /// `oxygenhub`, `discord` or `slack`; guessed from the URL when absent. |
|
| 56 | + | #[serde(default)] |
|
| 57 | + | format: String, |
|
| 58 | + | /// For `update`: whether the hook stays active. |
|
| 59 | + | #[serde(default)] |
|
| 60 | + | active: Option<String>, |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /// A hook as the settings table shows it: the URL masked, and how its |
|
| 64 | + | /// newest delivery went. |
|
| 65 | + | pub struct HookRow { |
|
| 66 | + | pub hook: db::hooks::Hook, |
|
| 67 | + | pub masked: String, |
|
| 68 | + | pub last: Option<db::hooks::Delivery>, |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | impl HookRow { |
|
| 72 | + | /// `✔ 204` or `✘ 400: why`, for the table. |
|
| 73 | + | pub fn last_result(&self) -> Option<(bool, String)> { |
|
| 74 | + | let d = self.last.as_ref()?; |
|
| 75 | + | let ok = d.delivered_ms.is_some(); |
|
| 76 | + | let status = d.status.map(|s| s.to_string()).unwrap_or_else(|| "no answer".into()); |
|
| 77 | + | let why: String = d.response.chars().take(80).collect(); |
|
| 78 | + | Some((ok, if ok || why.is_empty() { status } else { format!("{status}: {why}") })) |
|
| 79 | + | } |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | pub async fn rows(app: &Shared, hooks: Vec<db::hooks::Hook>) -> sqlx::Result<Vec<HookRow>> { |
|
| 83 | + | let mut out = Vec::with_capacity(hooks.len()); |
|
| 84 | + | for hook in hooks { |
|
| 85 | + | let last = db::hooks::last_attempt(&app.db, hook.id).await?; |
|
| 86 | + | out.push(HookRow { masked: db::hooks::masked_url(&hook.url), last, hook }); |
|
| 87 | + | } |
|
| 88 | + | Ok(out) |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | fn url_ok(url: &str) -> bool { |
|
| 92 | + | (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace) |
|
| 55 | 93 | } |
|
| 56 | 94 | ||
| 57 | 95 | fn star() -> String { |
|
| … | 33 unchanged lines |
||
| 91 | 129 | } |
|
| 92 | 130 | "Ping queued.".to_string() |
|
| 93 | 131 | } |
|
| 132 | + | ("update", Some(id)) => { |
|
| 133 | + | let Some(h) = owned(db::hooks::hook(&app.db, id).await?) else { return Ok(Err("No such webhook.".into())) }; |
|
| 134 | + | let url = f.url.trim(); |
|
| 135 | + | if !url.is_empty() && !url_ok(url) { |
|
| 136 | + | return Ok(Err("A webhook URL starts with http:// or https://.".into())); |
|
| 137 | + | } |
|
| 138 | + | let format = if db::hooks::FORMATS.contains(&f.format.as_str()) { f.format.as_str() } else { h.format.as_str() }; |
|
| 139 | + | let events = if f.events.trim().is_empty() { "*" } else { f.events.trim() }; |
|
| 140 | + | let secret = f.secret.trim(); |
|
| 141 | + | db::hooks::update_hook( |
|
| 142 | + | &app.db, |
|
| 143 | + | id, |
|
| 144 | + | (!url.is_empty()).then_some(url), |
|
| 145 | + | (!secret.is_empty()).then_some(secret), |
|
| 146 | + | events, |
|
| 147 | + | format, |
|
| 148 | + | f.active.is_some(), |
|
| 149 | + | ) |
|
| 150 | + | .await?; |
|
| 151 | + | accounts::audit( |
|
| 152 | + | &app.db, |
|
| 153 | + | viewer.account(), |
|
| 154 | + | "hook.updated", |
|
| 155 | + | scope.label(), |
|
| 156 | + | &db::hooks::masked_url(if url.is_empty() { &h.url } else { url }), |
|
| 157 | + | ) |
|
| 158 | + | .await |
|
| 159 | + | .ok(); |
|
| 160 | + | "Webhook saved.".to_string() |
|
| 161 | + | } |
|
| 94 | 162 | _ => { |
|
| 95 | - | if !(f.url.starts_with("http://") || f.url.starts_with("https://")) { |
|
| 163 | + | let url = f.url.trim(); |
|
| 164 | + | if !url_ok(url) { |
|
| 96 | 165 | return Ok(Err("A webhook URL starts with http:// or https://.".into())); |
|
| 97 | 166 | } |
|
| 98 | 167 | let (repo_id, org_id) = match scope { |
|
| 99 | 168 | Scope::Repo { id, .. } => (Some(*id), None), |
|
| 100 | 169 | Scope::Org { id, .. } => (None, Some(*id)), |
|
| 101 | 170 | }; |
|
| 102 | - | db::hooks::create_hook(&app.db, repo_id, org_id, f.url.trim(), f.secret.trim(), f.events.trim()).await?; |
|
| 103 | - | accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), f.url.trim()).await.ok(); |
|
| 171 | + | let h = db::hooks::create_hook(&app.db, repo_id, org_id, url, f.secret.trim(), f.events.trim()).await?; |
|
| 172 | + | if db::hooks::FORMATS.contains(&f.format.as_str()) && f.format != h.format { |
|
| 173 | + | db::hooks::set_hook_format(&app.db, h.id, &f.format).await?; |
|
| 174 | + | } |
|
| 175 | + | // Never the full URL in the log: it can hold a token. |
|
| 176 | + | accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), &db::hooks::masked_url(url)).await.ok(); |
|
| 104 | 177 | "Webhook added.".to_string() |
|
| 105 | 178 | } |
|
| 106 | 179 | })) |
|
| … | 37 unchanged lines |
modified crates/hub/tests/api.rs +94 −0
| … | 483 unchanged lines |
||
| 484 | 484 | assert_eq!(v["labels"], serde_json::json!([])); |
|
| 485 | 485 | assert_eq!(api(app, "DELETE", "/api/v1/repos/andy/demo/labels/flow", Some(&andy_t), None).await.0, StatusCode::NOT_FOUND); |
|
| 486 | 486 | } |
|
| 487 | + | ||
| 488 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 489 | + | async fn webhook_formats_editing_and_web_issue_events() { |
|
| 490 | + | let h = hub().await; |
|
| 491 | + | let app = &h.app; |
|
| 492 | + | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 493 | + | .await |
|
| 494 | + | .cookie |
|
| 495 | + | .unwrap(); |
|
| 496 | + | let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await; |
|
| 497 | + | let r = call( |
|
| 498 | + | app, |
|
| 499 | + | "POST", |
|
| 500 | + | "/settings/tokens", |
|
| 501 | + | None, |
|
| 502 | + | Some(&andy), |
|
| 503 | + | None, |
|
| 504 | + | Some(&format!("_csrf={}&name=t&scopes=repo%3Aadmin", csrf_of(&page.body))), |
|
| 505 | + | ) |
|
| 506 | + | .await; |
|
| 507 | + | let token = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap(); |
|
| 508 | + | assert_eq!( |
|
| 509 | + | api(app, "POST", "/api/v1/repos", Some(&token), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await.0, |
|
| 510 | + | StatusCode::CREATED |
|
| 511 | + | ); |
|
| 512 | + | let rx = receiver().await; |
|
| 513 | + | *rx.fail_first.lock().unwrap() = false; |
|
| 514 | + | let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body; |
|
| 515 | + | let csrf = csrf_of(&settings); |
|
| 516 | + | let form = |f: String| { |
|
| 517 | + | let andy = andy.clone(); |
|
| 518 | + | async move { call(app, "POST", "/andy/demo/settings/hooks", None, Some(&andy), None, Some(&f)).await.body } |
|
| 519 | + | }; |
|
| 520 | + | let url = rx.url.replace(':', "%3A").replace('/', "%2F"); |
|
| 521 | + | assert!(form(format!("_csrf={csrf}&url={url}&format=discord&events=*")).await.contains("Webhook added")); |
|
| 522 | + | let hook = oxygenhub::db::hooks::hooks_for_repo(&app.db, 1).await.unwrap().remove(0); |
|
| 523 | + | assert_eq!(hook.format, "discord"); |
|
| 524 | + | // Ping arrives as a Discord message. |
|
| 525 | + | form(format!("_csrf={csrf}&id={}&action=ping", hook.id)).await; |
|
| 526 | + | let mut got = String::new(); |
|
| 527 | + | for _ in 0..100 { |
|
| 528 | + | if let Some((_, _, body)) = rx.seen.lock().unwrap().first() { |
|
| 529 | + | got = body.clone(); |
|
| 530 | + | } |
|
| 531 | + | if !got.is_empty() { |
|
| 532 | + | break; |
|
| 533 | + | } |
|
| 534 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 535 | + | } |
|
| 536 | + | let v: serde_json::Value = serde_json::from_str(&got).unwrap(); |
|
| 537 | + | assert_eq!(v["embeds"][0]["title"], "OxygenHub is connected"); |
|
| 538 | + | assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([])); |
|
| 539 | + | let page = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body; |
|
| 540 | + | assert!(page.contains("last delivery <span class=\"sig sig-valid\">✔</span> 200"), "the table shows how it went"); |
|
| 541 | + | ||
| 542 | + | // Editing: a blank URL and secret keep theirs; format, events and active change. |
|
| 543 | + | assert!(form(format!("_csrf={csrf}&id={}&action=update&url=&secret=&events=issue&format=oxygenhub&active=1", hook.id)) |
|
| 544 | + | .await |
|
| 545 | + | .contains("Webhook saved")); |
|
| 546 | + | let edited = oxygenhub::db::hooks::hook(&app.db, hook.id).await.unwrap().unwrap(); |
|
| 547 | + | assert_eq!( |
|
| 548 | + | (edited.url.as_str(), edited.events.as_str(), edited.format.as_str(), edited.active), |
|
| 549 | + | (rx.url.as_str(), "issue", "oxygenhub", true) |
|
| 550 | + | ); |
|
| 551 | + | assert!(form(format!("_csrf={csrf}&id={}&action=update&url=ftp%3A%2F%2Fx&events=*", hook.id)).await.contains("starts with http")); |
|
| 552 | + | ||
| 553 | + | // An issue opened on the web now reaches the hook, as the signed envelope. |
|
| 554 | + | let new = call(app, "GET", "/andy/demo/issues/new", None, Some(&andy), None, None).await.body; |
|
| 555 | + | call( |
|
| 556 | + | app, |
|
| 557 | + | "POST", |
|
| 558 | + | "/andy/demo/issues/new", |
|
| 559 | + | None, |
|
| 560 | + | Some(&andy), |
|
| 561 | + | None, |
|
| 562 | + | Some(&format!("_csrf={}&title=From+the+web&body=hello", csrf_of(&new))), |
|
| 563 | + | ) |
|
| 564 | + | .await; |
|
| 565 | + | let mut envelope = None; |
|
| 566 | + | for _ in 0..100 { |
|
| 567 | + | envelope = rx.seen.lock().unwrap().iter().find(|(e, _, _)| e == "issue.opened").cloned(); |
|
| 568 | + | if envelope.is_some() { |
|
| 569 | + | break; |
|
| 570 | + | } |
|
| 571 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 572 | + | } |
|
| 573 | + | let (_, sig, body) = envelope.expect("issue.opened delivered"); |
|
| 574 | + | let v: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 575 | + | assert_eq!(v["data"]["issue"]["title"], "From the web"); |
|
| 576 | + | assert!(sig.starts_with("blake3-keyed=")); |
|
| 577 | + | // A disabled hook through the edit form (no active box). |
|
| 578 | + | form(format!("_csrf={csrf}&id={}&action=update&url=&events=*&format=oxygenhub", hook.id)).await; |
|
| 579 | + | assert!(!oxygenhub::db::hooks::hook(&app.db, hook.id).await.unwrap().unwrap().active); |
|
| 580 | + | } |
modified docs/spec.md +1 −1
| … | 402 unchanged lines |
||
| 403 | 403 | ||
| 404 | 404 | ### 12.2 Webhooks |
|
| 405 | 405 | ||
| 406 | - | Per repository and per organisation: a URL, a secret, an event selection, active flag, and delivery log with redelivery. Repository admins manage a repository's webhooks under its settings; organisation owners manage the organisation's under its settings, and those hear every repository the organisation owns. Payloads are JSON with `X-Oxyhub-Event`, `X-Oxyhub-Delivery`, and `X-Oxyhub-Signature: blake3-keyed=<hex>` computed as keyed BLAKE3 over the body. Events: `push` (reference updated: old and new state, heads, changes), `proposal` (opened, edited, review requested, landed, closed, conflicted), `review`, `comment`, `issue`, `release`, `check`, `policy`. Deliveries retry with backoff up to 24 hours. |
|
| 406 | + | Per repository and per organisation: a URL, a secret, an event selection, active flag, and delivery log with redelivery. Each webhook has a **format**: `oxygenhub` (the signed JSON envelope below), `discord` (an embed with a title, link and short detail for the event, sent with mentions disabled so no user text can ping anyone) or `slack` (escaped `text`); a Discord or Slack URL picks its format by default, and every event has a one-line summary for the chat formats. The settings show a webhook's URL with its long path segments and query cut short, since chat webhooks keep a secret token there (the audit log records the same masked form), and each webhook's last delivery result; a webhook can be edited (URL, secret, events, format, active), a blank URL or secret keeping the current one. Repository admins manage a repository's webhooks under its settings; organisation owners manage the organisation's under its settings, and those hear every repository the organisation owns. Payloads are JSON with `X-Oxyhub-Event`, `X-Oxyhub-Delivery`, and `X-Oxyhub-Signature: blake3-keyed=<hex>` computed as keyed BLAKE3 over the body. Events: `push` (reference updated: old and new state, heads, changes), `proposal` (opened, edited, review requested, landed, closed, conflicted), `review`, `comment`, `issue`, `release`, `check`, `policy`. Deliveries retry with backoff up to 24 hours. |
|
| 407 | 407 | ||
| 408 | 408 | ## 13. Checks and OxygenFlow |
|
| 409 | 409 | ||
| … | 167 unchanged lines |
modified templates/deliveries.html +1 −1
| … | 3 unchanged lines |
||
| 4 | 4 | {% block tabs %}{% if let Some(r) = repo %}{% call m::tabs(r, "settings") %}{% endif %}{% endblock %} |
|
| 5 | 5 | {% block mainclass %} class="wide"{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | - | <div class="head"><h1>Deliveries <small>{{ hook.url }} · events {{ hook.events }}</small></h1></div> |
|
| 7 | + | <div class="head"><h1>Deliveries <small><code class="hook-url">{{ crate::db::hooks::masked_url(hook.url) }}</code> · {{ hook.format }} · events {{ hook.events }}</small></h1></div> |
|
| 8 | 8 | <p><a href="{{ settings_url }}#webhooks">← settings</a></p> |
|
| 9 | 9 | {% if rows.is_empty() %}<p class="empty">Nothing delivered yet.</p>{% else %} |
|
| 10 | 10 | <div class="table"><table> |
|
| … | 10 unchanged lines |
modified templates/style.css +8 −0
| … | 509 unchanged lines |
||
| 510 | 510 | .state-open { color: var(--ok); } .state-closed, .state-landed { color: var(--acc); } |
|
| 511 | 511 | .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } |
|
| 512 | 512 | .card-item.note > p { color: var(--ink); } |
|
| 513 | + | ||
| 514 | + | /* Webhooks (hub spec §12.2): long URLs wrap inside the panel. */ |
|
| 515 | + | ul.hooks { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 8px; } |
|
| 516 | + | li.hook { border: 1px solid var(--line); background: var(--soft); padding: 8px 12px; min-width: 0; } |
|
| 517 | + | .hook-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } |
|
| 518 | + | code.hook-url { overflow-wrap: anywhere; word-break: break-all; } |
|
| 519 | + | li.hook p { margin: 4px 0; } |
|
| 520 | + | li.hook .hook-edit { margin-top: 4px; } |
modified templates/webhooks_panel.html +42 −7
| … | 2 unchanged lines |
||
| 3 | 3 | <h2>Webhooks</h2> |
|
| 4 | 4 | <p class="small mute">{{ what }}</p> |
|
| 5 | 5 | {% if hooks.is_empty() %}<p class="mute small">None.</p>{% else %} |
|
| 6 | - | <table class="small"><tr><th>URL</th><th>Events</th><th>Active</th><th></th></tr> |
|
| 7 | - | {% for h in hooks %}<tr><td>{{ h.url }}</td><td>{{ h.events }}</td><td>{% if h.active %}yes{% else %}no{% endif %}</td> |
|
| 8 | - | <td><a class="small" href="{{ base }}/hooks/{{ h.id }}/deliveries">deliveries</a> |
|
| 9 | - | <form class="tiny" method="post" action="{{ base }}/hooks"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ h.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if h.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete">delete</button></form></td></tr>{% endfor %} |
|
| 10 | - | </table>{% endif %} |
|
| 6 | + | <ul class="hooks"> |
|
| 7 | + | {% for r in hooks %} |
|
| 8 | + | <li class="hook"> |
|
| 9 | + | <div class="hook-head"> |
|
| 10 | + | <code class="hook-url" title="The path is shortened: webhook URLs often carry a secret token">{{ r.masked }}</code> |
|
| 11 | + | <span class="tag">{{ r.hook.format }}</span> |
|
| 12 | + | {% if r.hook.active %}<span class="tag head">active</span>{% else %}<span class="tag dim">disabled</span>{% endif %} |
|
| 13 | + | </div> |
|
| 14 | + | <p class="small mute">events <code>{{ r.hook.events }}</code> · |
|
| 15 | + | {% if let Some((ok, text)) = r.last_result() %}last delivery {% if ok %}<span class="sig sig-valid">✔</span>{% else %}<span class="sig sig-invalid">✘</span>{% endif %} {{ text }}{% else %}nothing delivered yet{% endif %} |
|
| 16 | + | · <a href="{{ base }}/hooks/{{ r.hook.id }}/deliveries">deliveries</a></p> |
|
| 17 | + | <form class="tiny" method="post" action="{{ base }}/hooks"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.hook.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if r.hook.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete" onclick="return confirm('Delete this webhook?')">delete</button></form> |
|
| 18 | + | <details class="hook-edit"><summary class="small">edit</summary> |
|
| 19 | + | <form class="card" method="post" action="{{ base }}/hooks"> |
|
| 20 | + | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.hook.id }}"><input type="hidden" name="action" value="update"> |
|
| 21 | + | <label for="hu{{ r.hook.id }}">URL</label><input type="text" id="hu{{ r.hook.id }}" name="url" placeholder="{{ r.masked }}" autocomplete="off"> |
|
| 22 | + | <p class="small mute">Leave empty to keep the current URL.</p> |
|
| 23 | + | <label for="hs{{ r.hook.id }}">Secret</label><input type="text" id="hs{{ r.hook.id }}" name="secret" placeholder="unchanged" autocomplete="off"> |
|
| 24 | + | <label for="he{{ r.hook.id }}">Events</label><input type="text" id="he{{ r.hook.id }}" name="events" value="{{ r.hook.events }}"> |
|
| 25 | + | <label for="hf{{ r.hook.id }}">Format</label> |
|
| 26 | + | <select id="hf{{ r.hook.id }}" name="format"> |
|
| 27 | + | <option value="oxygenhub"{% if r.hook.format == "oxygenhub" %} selected{% endif %}>OxygenHub (signed JSON envelope)</option> |
|
| 28 | + | <option value="discord"{% if r.hook.format == "discord" %} selected{% endif %}>Discord</option> |
|
| 29 | + | <option value="slack"{% if r.hook.format == "slack" %} selected{% endif %}>Slack</option> |
|
| 30 | + | </select> |
|
| 31 | + | <div class="choice"><label><input type="checkbox" name="active" value="1"{% if r.hook.active %} checked{% endif %}> active</label></div> |
|
| 32 | + | <button type="submit">SAVE</button> |
|
| 33 | + | </form> |
|
| 34 | + | </details> |
|
| 35 | + | </li> |
|
| 36 | + | {% endfor %} |
|
| 37 | + | </ul>{% endif %} |
|
| 11 | 38 | <form class="card" method="post" action="{{ base }}/hooks"> |
|
| 12 | 39 | <input type="hidden" name="_csrf" value="{{ csrf }}"> |
|
| 13 | - | <label for="hurl">URL</label><input type="text" id="hurl" name="url" placeholder="https://…" required> |
|
| 14 | - | <label for="hsecret">Secret (signs each delivery)</label><input type="text" id="hsecret" name="secret"> |
|
| 40 | + | <h3>Add a webhook</h3> |
|
| 41 | + | <label for="hurl">URL</label><input type="text" id="hurl" name="url" placeholder="https://…" required autocomplete="off"> |
|
| 42 | + | <label for="hformat">Format</label> |
|
| 43 | + | <select id="hformat" name="format"> |
|
| 44 | + | <option value="">From the URL (Discord and Slack are recognised)</option> |
|
| 45 | + | <option value="oxygenhub">OxygenHub (signed JSON envelope)</option> |
|
| 46 | + | <option value="discord">Discord</option> |
|
| 47 | + | <option value="slack">Slack</option> |
|
| 48 | + | </select> |
|
| 49 | + | <label for="hsecret">Secret (signs each delivery)</label><input type="text" id="hsecret" name="secret" autocomplete="off"> |
|
| 15 | 50 | <label for="hevents">Events (<code>*</code>, or a list such as <code>push,proposal,issue</code>)</label><input type="text" id="hevents" name="events" value="*"> |
|
| 16 | 51 | <button type="submit">ADD WEBHOOK</button> |
|
| 17 | 52 | </form> |
|
| … | 2 unchanged lines |