Skip to content

g1t/services/webhooks/src/deliver.rs

294 lines12,988 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Webhooks: every event, to your own addresses, signed and retried1//! What a delivery is made of, apart from sending it: the payload, the
2//! signature, when to try again, and which addresses may be sent to.
3
4use g1t_contracts::events::Event;
5use g1t_contracts::webhooks::EVENT_TYPES;
6use serde_json::{Value, json};
7
8/// How long to wait after each failed attempt before the next, so a
9/// delivery gets six attempts over about seven and a half hours.
10pub const RETRY_WAITS_SECONDS: [u64; 5] = [60, 5 * 60, 30 * 60, 2 * 60 * 60, 5 * 60 * 60];
11
12/// When to try again after `attempts` attempts, in seconds from now, or
13/// `None` when it has had them all.
14pub fn retry_after(attempts: u32) -> Option<u64> {
15 RETRY_WAITS_SECONDS.get(attempts.checked_sub(1)? as usize).copied()
16}
17
18/// Whether a webhook that wants `wanted` is sent an event of type `kind`.
19pub fn wants(wanted: &[String], kind: &str) -> bool {
20 wanted.iter().any(|event| event == "*" || event == kind)
21}
22
23/// Event types as given, checked and in catalogue order. `["*"]` when none
24/// or all are given. `Err` names the first that is not one.
25pub fn tidy_events(given: &[String]) -> Result<Vec<String>, String> {
26 if given.is_empty() || given.iter().any(|event| event == "*") {
27 return Ok(vec!["*".to_owned()]);
28 }
29 if let Some(unknown) = given.iter().find(|event| !EVENT_TYPES.contains(&event.as_str())) {
30 return Err(format!("There is no event called {unknown}."));
31 }
32 Ok(EVENT_TYPES
33 .iter()
34 .filter(|kind| given.iter().any(|event| event == *kind))
35 .map(|kind| (*kind).to_owned())
36 .collect())
37}
38
39/// What is sent for an event: the event as the bus has it, with the
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API40/// repository, the workspace and whoever caused it named. Its keys are in
41/// `snake_case`, as everything g1t sends out is (see `g1t_kit::wire`).
Webhooks: every event, to your own addresses, signed and retried42pub fn payload(event: &Event, workspace: &str, repo: Option<(&str, &str)>, actor_name: Option<&str>) -> Value {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API43 g1t_kit::wire::snake_case(json!({
Webhooks: every event, to your own addresses, signed and retried44 "id": event.id,
45 "type": event.kind,
46 "time": event.time,
47 "workspace": workspace,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API48 "repository": repo.map(|(id, full_name)| json!({ "id": id, "full_name": full_name })),
Webhooks: every event, to your own addresses, signed and retried49 "actor": event.actor.as_ref().map(|id| json!({ "id": id, "username": actor_name })),
50 "data": event.data,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API51 }))
Webhooks: every event, to your own addresses, signed and retried52}
53
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look54/// The repository a repository event names, as `(namespace, name)`, where
55/// it is now: for when repos no longer shows it (it was deleted or purged).
56pub fn named_in(event: &Event) -> Option<(String, String)> {
57 let text = |key: &str| event.data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
58 match event.kind.as_str() {
59 "repo.renamed" => Some((text("namespace")?, text("to")?)),
60 "repo.transferred" => Some((text("to")?, text("name")?)),
61 kind if kind.starts_with("repo.") => Some((text("namespace")?, text("name")?)),
62 _ => None,
63 }
64}
65
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member66/// The workspace an event belongs to when it is about no repository: a
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar67/// package of the workspace's own, unlinked from any repository, or one of
68/// its teams. Such an event goes to the workspace's webhooks only. Events
69/// about a repository (a team given a role on one among them), and every
70/// other kind, are `None`: they are routed by their repository.
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member71pub fn workspace_scoped(event: &Event) -> Option<String> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar72 let own = event.kind.starts_with("package.") || event.kind.starts_with("team.");
73 if event.repo_id.as_deref().is_some_and(|id| !id.is_empty()) || !own {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member74 return None;
75 }
76 event.data["workspace"]
77 .as_str()
78 .map(|slug| slug.trim().to_lowercase())
79 .filter(|slug| !slug.is_empty())
80}
81
Webhooks: every event, to your own addresses, signed and retried82/// What is sent to check a webhook works.
83pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
84 json!({
85 "type": "ping",
86 "time": time,
87 "hook": { "id": hook_id, "url": url, "events": events },
88 "message": "g1t will send this webhook's events here.",
89 })
90}
91
92/// The signature header's value: the body's HMAC-SHA256 under the secret.
93pub fn signature(secret: &str, body: &str) -> String {
94 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
95}
96
97/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
98/// not.
99pub fn check_url(url: &str) -> Result<(), String> {
100 let Some(rest) = url.strip_prefix("https://") else {
101 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
102 };
103 if url.len() > 2000 {
104 return Err("That address is too long.".to_owned());
105 }
106 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
107 let host = authority.rsplit('@').next().unwrap_or_default();
108 let host = host.strip_prefix('[').map_or_else(
109 || host.split(':').next().unwrap_or_default(),
110 |v6| v6.split(']').next().unwrap_or_default(),
111 );
112 let host = host.to_ascii_lowercase();
113 if host.is_empty() || !host.contains(['.', ':']) {
114 return Err("The address needs a host g1t can reach on the internet.".to_owned());
115 }
116 let private_name = host == "localhost"
117 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
118 .iter()
119 .any(|suffix| host.ends_with(suffix));
120 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
121 std::net::IpAddr::V4(v4) => {
122 v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1])
123 }
124 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
125 });
126 if private_name || private_ip {
127 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
128 }
129 Ok(())
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn retries_wait_longer_each_time_then_stop() {
138 assert_eq!(retry_after(1), Some(60));
139 assert_eq!(retry_after(2), Some(300));
140 assert_eq!(retry_after(5), Some(18_000));
141 assert_eq!(retry_after(6), None);
142 assert_eq!(retry_after(0), None);
143 }
144
145 #[test]
146 fn events_are_checked_and_ordered() {
147 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
148 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
149 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
150 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
151 assert!(wants(&["*".to_owned()], "issue.opened"));
152 assert!(!wants(&["git.push".to_owned()], "issue.opened"));
153 }
154
155 #[test]
156 fn only_public_https_addresses_are_allowed() {
157 assert!(check_url("https://hooks.example.com/g1t").is_ok());
158 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
159 for url in [
160 "http://example.com/hook",
161 "https://localhost/hook",
162 "https://127.0.0.1/hook",
163 "https://10.0.0.5/hook",
164 "https://192.168.1.2:8080/hook",
165 "https://169.254.169.254/latest",
166 "https://100.64.0.1/hook",
167 "https://[::1]/hook",
168 "https://[fd00::1]/hook",
169 "https://printer.local/hook",
170 "https://intranet/hook",
171 "https://user@10.0.0.1/hook",
172 ] {
173 assert!(check_url(url).is_err(), "{url}");
174 }
175 }
176
177 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API178 fn payloads_are_snake_case() {
179 let event = Event {
180 id: "evt_1".to_owned(),
181 kind: "pull.merged".to_owned(),
182 source: "work".to_owned(),
183 time: "2026-10-04T16:00:00Z".to_owned(),
184 repo_id: Some("rep_1".to_owned()),
185 actor: Some("usr_1".to_owned()),
186 data: json!({ "pullId": "pul_1", "repoId": "rep_1", "supersededBy": null, "inputs": { "dryRun": true } }),
187 };
188 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
189 assert_eq!(sent["repository"]["full_name"], "acme/rocket");
190 assert_eq!(sent["data"]["pull_id"], "pul_1");
191 assert!(sent["data"].get("superseded_by").is_some());
192 assert_eq!(sent["data"]["inputs"]["dryRun"], true);
193 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
194 }
195
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights196 #[test]
197 fn a_pull_request_g1t_made_names_g1t_and_who_asked() {
198 // As the work service publishes it: g1t the author, the person who
199 // asked beside it, and the actor whoever caused the event.
200 let event = Event {
201 id: "evt_1".to_owned(),
202 kind: "pull.opened".to_owned(),
203 source: "work".to_owned(),
204 time: "2026-10-06T10:00:00Z".to_owned(),
205 repo_id: Some("rep_1".to_owned()),
206 actor: Some("usr_1".to_owned()),
207 data: json!({
208 "pullId": "pr_1", "repoId": "rep_1", "number": 14, "agent": "g1t",
209 "author": { "id": "usr_g1t_agent", "username": "g1t" },
210 "requestedBy": { "id": "usr_1", "username": "syntaqx" }
211 }),
212 };
213 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
214 assert_eq!(sent["data"]["author"]["username"], "g1t");
215 assert_eq!(sent["data"]["requested_by"]["username"], "syntaqx");
216 assert_eq!(sent["actor"]["username"], "syntaqx");
217 }
218
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look219 fn repo_event(kind: &str, data: Value) -> Event {
220 Event {
221 id: "evt_1".to_owned(),
222 kind: kind.to_owned(),
223 source: "repos".to_owned(),
224 time: "2026-10-05T16:00:00Z".to_owned(),
225 repo_id: Some("rep_1".to_owned()),
226 actor: None,
227 data,
228 }
229 }
230
231 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member232 fn a_workspaces_own_package_events_go_to_its_webhooks() {
233 let mut event = repo_event("package.published", json!({ "packageId": "pkg_1", "workspace": "Acme", "name": "tools", "repoId": null }));
234 event.repo_id = None;
235 assert_eq!(workspace_scoped(&event).as_deref(), Some("acme"));
236 let sent = payload(&event, "acme", None, Some("ana"));
237 assert_eq!(sent["repository"], Value::Null);
238 assert_eq!(sent["workspace"], "acme");
239 assert_eq!(sent["data"]["package_id"], "pkg_1", "snake_case as everything sent");
240 // A linked package's events go by its repository.
241 let linked = repo_event("package.published", json!({ "workspace": "acme", "repoId": "rep_1" }));
242 assert_eq!(workspace_scoped(&linked), None);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar243 // A team's events are the workspace's; one about a repository goes by it.
244 let mut team = repo_event("team.created", json!({ "workspace": "Acme", "team": "backend" }));
245 team.repo_id = None;
246 assert_eq!(workspace_scoped(&team).as_deref(), Some("acme"));
247 let granted = repo_event("team.repo_added", json!({ "workspace": "acme", "repoId": "rep_1" }));
248 assert_eq!(workspace_scoped(&granted), None);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member249 // Other events without a repository are not workspace events.
250 let mut other = repo_event("issue.opened", json!({ "workspace": "acme" }));
251 other.repo_id = None;
252 assert_eq!(workspace_scoped(&other), None);
253 let mut nameless = repo_event("package.deleted", json!({ "workspace": "" }));
254 nameless.repo_id = None;
255 assert_eq!(workspace_scoped(&nameless), None);
256 }
257
258 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look259 fn repository_events_name_where_it_is_now() {
260 let named = |kind: &str, data: Value| named_in(&repo_event(kind, data));
261 let at = |namespace: &str, name: &str| Some((namespace.to_owned(), name.to_owned()));
262 assert_eq!(named("repo.renamed", json!({ "namespace": "acme", "from": "old", "to": "new" })), at("acme", "new"));
263 assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web"));
264 assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web"));
265 assert_eq!(named("repo.deleted", json!({ "repoId": "rep_1", "namespace": "", "name": "web" })), None);
266 assert_eq!(named("issue.opened", json!({ "namespace": "acme", "name": "web" })), None);
267 }
268
269 #[test]
270 fn repository_lifecycle_events_can_be_chosen() {
271 for kind in [
272 "repo.updated",
273 "repo.visibility_changed",
274 "repo.renamed",
275 "repo.transferred",
276 "repo.archived",
277 "repo.unarchived",
278 "repo.deleted",
279 "repo.restored",
280 "repo.purged",
281 "repo.default_branch_changed",
282 "branch.renamed",
283 ] {
284 assert_eq!(tidy_events(&[kind.to_owned()]).unwrap(), vec![kind], "{kind}");
285 }
286 }
287
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API288 #[test]
Webhooks: every event, to your own addresses, signed and retried289 fn the_signature_is_the_bodys_hmac() {
290 let signature = signature("shh", "{\"a\":1}");
291 assert!(signature.starts_with("sha256="));
292 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
293 }
294}