Skip to content

g1t/services/webhooks/src/deliver.rs

301 lines13,525 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
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge67/// package of the workspace's own, unlinked from any repository, one of
68/// its teams, or one of its rulesets. Such an event goes to the
69/// workspace's webhooks only. Events about a repository (a team given a
70/// role on one among them, a repository's own ruleset), and every other
71/// 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 member72pub fn workspace_scoped(event: &Event) -> Option<String> {
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge73 let own = event.kind.starts_with("package.") || event.kind.starts_with("team.") || event.kind.starts_with("ruleset.");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar74 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 member75 return None;
76 }
77 event.data["workspace"]
78 .as_str()
79 .map(|slug| slug.trim().to_lowercase())
80 .filter(|slug| !slug.is_empty())
81}
82
Webhooks: every event, to your own addresses, signed and retried83/// What is sent to check a webhook works.
84pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
85 json!({
86 "type": "ping",
87 "time": time,
88 "hook": { "id": hook_id, "url": url, "events": events },
89 "message": "g1t will send this webhook's events here.",
90 })
91}
92
93/// The signature header's value: the body's HMAC-SHA256 under the secret.
94pub fn signature(secret: &str, body: &str) -> String {
95 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
96}
97
98/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
99/// not.
100pub fn check_url(url: &str) -> Result<(), String> {
101 let Some(rest) = url.strip_prefix("https://") else {
102 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
103 };
104 if url.len() > 2000 {
105 return Err("That address is too long.".to_owned());
106 }
107 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
108 let host = authority.rsplit('@').next().unwrap_or_default();
109 let host = host.strip_prefix('[').map_or_else(
110 || host.split(':').next().unwrap_or_default(),
111 |v6| v6.split(']').next().unwrap_or_default(),
112 );
113 let host = host.to_ascii_lowercase();
114 if host.is_empty() || !host.contains(['.', ':']) {
115 return Err("The address needs a host g1t can reach on the internet.".to_owned());
116 }
117 let private_name = host == "localhost"
118 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
119 .iter()
120 .any(|suffix| host.ends_with(suffix));
121 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
122 std::net::IpAddr::V4(v4) => {
123 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])
124 }
125 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
126 });
127 if private_name || private_ip {
128 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
129 }
130 Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn retries_wait_longer_each_time_then_stop() {
139 assert_eq!(retry_after(1), Some(60));
140 assert_eq!(retry_after(2), Some(300));
141 assert_eq!(retry_after(5), Some(18_000));
142 assert_eq!(retry_after(6), None);
143 assert_eq!(retry_after(0), None);
144 }
145
146 #[test]
147 fn events_are_checked_and_ordered() {
148 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
149 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
150 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
151 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
152 assert!(wants(&["*".to_owned()], "issue.opened"));
153 assert!(!wants(&["git.push".to_owned()], "issue.opened"));
154 }
155
156 #[test]
157 fn only_public_https_addresses_are_allowed() {
158 assert!(check_url("https://hooks.example.com/g1t").is_ok());
159 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
160 for url in [
161 "http://example.com/hook",
162 "https://localhost/hook",
163 "https://127.0.0.1/hook",
164 "https://10.0.0.5/hook",
165 "https://192.168.1.2:8080/hook",
166 "https://169.254.169.254/latest",
167 "https://100.64.0.1/hook",
168 "https://[::1]/hook",
169 "https://[fd00::1]/hook",
170 "https://printer.local/hook",
171 "https://intranet/hook",
172 "https://user@10.0.0.1/hook",
173 ] {
174 assert!(check_url(url).is_err(), "{url}");
175 }
176 }
177
178 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API179 fn payloads_are_snake_case() {
180 let event = Event {
181 id: "evt_1".to_owned(),
182 kind: "pull.merged".to_owned(),
183 source: "work".to_owned(),
184 time: "2026-10-04T16:00:00Z".to_owned(),
185 repo_id: Some("rep_1".to_owned()),
186 actor: Some("usr_1".to_owned()),
187 data: json!({ "pullId": "pul_1", "repoId": "rep_1", "supersededBy": null, "inputs": { "dryRun": true } }),
188 };
189 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
190 assert_eq!(sent["repository"]["full_name"], "acme/rocket");
191 assert_eq!(sent["data"]["pull_id"], "pul_1");
192 assert!(sent["data"].get("superseded_by").is_some());
193 assert_eq!(sent["data"]["inputs"]["dryRun"], true);
194 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
195 }
196
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights197 #[test]
198 fn a_pull_request_g1t_made_names_g1t_and_who_asked() {
199 // As the work service publishes it: g1t the author, the person who
200 // asked beside it, and the actor whoever caused the event.
201 let event = Event {
202 id: "evt_1".to_owned(),
203 kind: "pull.opened".to_owned(),
204 source: "work".to_owned(),
205 time: "2026-10-06T10:00:00Z".to_owned(),
206 repo_id: Some("rep_1".to_owned()),
207 actor: Some("usr_1".to_owned()),
208 data: json!({
209 "pullId": "pr_1", "repoId": "rep_1", "number": 14, "agent": "g1t",
210 "author": { "id": "usr_g1t_agent", "username": "g1t" },
211 "requestedBy": { "id": "usr_1", "username": "syntaqx" }
212 }),
213 };
214 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
215 assert_eq!(sent["data"]["author"]["username"], "g1t");
216 assert_eq!(sent["data"]["requested_by"]["username"], "syntaqx");
217 assert_eq!(sent["actor"]["username"], "syntaqx");
218 }
219
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look220 fn repo_event(kind: &str, data: Value) -> Event {
221 Event {
222 id: "evt_1".to_owned(),
223 kind: kind.to_owned(),
224 source: "repos".to_owned(),
225 time: "2026-10-05T16:00:00Z".to_owned(),
226 repo_id: Some("rep_1".to_owned()),
227 actor: None,
228 data,
229 }
230 }
231
232 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member233 fn a_workspaces_own_package_events_go_to_its_webhooks() {
234 let mut event = repo_event("package.published", json!({ "packageId": "pkg_1", "workspace": "Acme", "name": "tools", "repoId": null }));
235 event.repo_id = None;
236 assert_eq!(workspace_scoped(&event).as_deref(), Some("acme"));
237 let sent = payload(&event, "acme", None, Some("ana"));
238 assert_eq!(sent["repository"], Value::Null);
239 assert_eq!(sent["workspace"], "acme");
240 assert_eq!(sent["data"]["package_id"], "pkg_1", "snake_case as everything sent");
241 // A linked package's events go by its repository.
242 let linked = repo_event("package.published", json!({ "workspace": "acme", "repoId": "rep_1" }));
243 assert_eq!(workspace_scoped(&linked), None);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar244 // A team's events are the workspace's; one about a repository goes by it.
245 let mut team = repo_event("team.created", json!({ "workspace": "Acme", "team": "backend" }));
246 team.repo_id = None;
247 assert_eq!(workspace_scoped(&team).as_deref(), Some("acme"));
248 let granted = repo_event("team.repo_added", json!({ "workspace": "acme", "repoId": "rep_1" }));
249 assert_eq!(workspace_scoped(&granted), None);
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge250 // A workspace's ruleset is the workspace's; a repository's goes by it.
251 let mut ruleset = repo_event("ruleset.updated", json!({ "workspace": "acme", "ruleset": {} }));
252 ruleset.repo_id = None;
253 assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme"));
254 let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" }));
255 assert_eq!(workspace_scoped(&own), None);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member256 // Other events without a repository are not workspace events.
257 let mut other = repo_event("issue.opened", json!({ "workspace": "acme" }));
258 other.repo_id = None;
259 assert_eq!(workspace_scoped(&other), None);
260 let mut nameless = repo_event("package.deleted", json!({ "workspace": "" }));
261 nameless.repo_id = None;
262 assert_eq!(workspace_scoped(&nameless), None);
263 }
264
265 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look266 fn repository_events_name_where_it_is_now() {
267 let named = |kind: &str, data: Value| named_in(&repo_event(kind, data));
268 let at = |namespace: &str, name: &str| Some((namespace.to_owned(), name.to_owned()));
269 assert_eq!(named("repo.renamed", json!({ "namespace": "acme", "from": "old", "to": "new" })), at("acme", "new"));
270 assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web"));
271 assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web"));
272 assert_eq!(named("repo.deleted", json!({ "repoId": "rep_1", "namespace": "", "name": "web" })), None);
273 assert_eq!(named("issue.opened", json!({ "namespace": "acme", "name": "web" })), None);
274 }
275
276 #[test]
277 fn repository_lifecycle_events_can_be_chosen() {
278 for kind in [
279 "repo.updated",
280 "repo.visibility_changed",
281 "repo.renamed",
282 "repo.transferred",
283 "repo.archived",
284 "repo.unarchived",
285 "repo.deleted",
286 "repo.restored",
287 "repo.purged",
288 "repo.default_branch_changed",
289 "branch.renamed",
290 ] {
291 assert_eq!(tidy_events(&[kind.to_owned()]).unwrap(), vec![kind], "{kind}");
292 }
293 }
294
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API295 #[test]
Webhooks: every event, to your own addresses, signed and retried296 fn the_signature_is_the_bodys_hmac() {
297 let signature = signature("shh", "{\"a\":1}");
298 assert!(signature.starts_with("sha256="));
299 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
300 }
301}

This file's history is long; its oldest lines are credited to the oldest commit read.