Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

389 linesCodeBlameRaw
1//! 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, RETIRED_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. Types no longer sent ([`RETIRED_EVENT_TYPES`]), as a
25/// list saved before may hold, are left out. `Err` names the first that is
26/// not one, or says when only retired ones were given.
27pub fn tidy_events(given: &[String]) -> Result<Vec<String>, String> {
28 if given.is_empty() || given.iter().any(|event| event == "*") {
29 return Ok(vec!["*".to_owned()]);
30 }
31 let retired = |event: &String| RETIRED_EVENT_TYPES.contains(&event.as_str());
32 if given.iter().all(retired) {
33 return Err(format!("g1t no longer sends {}. Choose other events.", given.join(", ")));
34 }
35 let given: Vec<String> = given.iter().filter(|event| !retired(event)).cloned().collect();
36 if let Some(unknown) = given.iter().find(|event| !EVENT_TYPES.contains(&event.as_str())) {
37 return Err(format!("There is no event called {unknown}."));
38 }
39 Ok(EVENT_TYPES
40 .iter()
41 .filter(|kind| given.iter().any(|event| event == *kind))
42 .map(|kind| (*kind).to_owned())
43 .collect())
44}
45
46/// What is sent for an event: the event as the bus has it, with the
47/// repository, the workspace and whoever caused it named. Its keys are in
48/// `snake_case`, as everything g1t sends out is (see `g1t_kit::wire`).
49pub fn payload(event: &Event, workspace: &str, repo: Option<(&str, &str)>, actor_name: Option<&str>) -> Value {
50 g1t_kit::wire::snake_case(json!({
51 "id": event.id,
52 "type": event.kind,
53 "time": event.time,
54 "workspace": workspace,
55 "repository": repo.map(|(id, full_name)| json!({ "id": id, "full_name": full_name })),
56 "actor": event.actor.as_ref().map(|id| json!({ "id": id, "username": actor_name })),
57 "data": event.data,
58 }))
59}
60
61/// The repository a repository event names, as `(namespace, name)`, where
62/// it is now: for when repos no longer shows it (it was deleted or purged).
63pub fn named_in(event: &Event) -> Option<(String, String)> {
64 let text = |key: &str| event.data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
65 match event.kind.as_str() {
66 "repo.renamed" => Some((text("namespace")?, text("to")?)),
67 "repo.transferred" => Some((text("to")?, text("name")?)),
68 kind if kind.starts_with("repo.") => Some((text("namespace")?, text("name")?)),
69 _ => None,
70 }
71}
72
73/// The workspace an event belongs to when it is about no repository: a
74/// package of the workspace's own, unlinked from any repository, one of
75/// its teams, one of its rulesets, or someone joining it. Such an event
76/// goes to the workspace's webhooks only. Events about a repository (a
77/// team given a role on one among them, a repository's own ruleset), and
78/// every other kind, are `None`: they are routed by their repository.
79pub fn workspace_scoped(event: &Event) -> Option<String> {
80 let own = event.kind.starts_with("package.")
81 || event.kind.starts_with("team.")
82 || event.kind.starts_with("ruleset.")
83 || event.kind == "workspace.member_joined";
84 if event.repo_id.as_deref().is_some_and(|id| !id.is_empty()) || !own {
85 return None;
86 }
87 event.data["workspace"]
88 .as_str()
89 .map(|slug| slug.trim().to_lowercase())
90 .filter(|slug| !slug.is_empty())
91}
92
93/// For an event about a work item (`work_item.*`, or a comment on one):
94/// its workspace, and every repository it is linked to. It always goes to
95/// the workspace's webhooks; which of those repositories' webhooks hear of
96/// it is [`item_hook_repos`]'s to say.
97pub fn item_scoped(event: &Event) -> Option<(String, Vec<String>)> {
98 let about_item = event.kind.starts_with("work_item.")
99 || (event.kind.starts_with("comment.") && event.data["itemId"].as_str().is_some_and(|id| !id.is_empty()));
100 if !about_item {
101 return None;
102 }
103 let workspace = event.data["workspace"].as_str().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty())?;
104 let repo_ids = event.data["repoIds"]
105 .as_array()
106 .map(|ids| ids.iter().filter_map(Value::as_str).filter(|id| !id.is_empty()).map(str::to_owned).collect())
107 .unwrap_or_default();
108 Some((workspace, repo_ids))
109}
110
111/// The repositories, of those a work item is linked to (`repo_ids`),
112/// whose webhooks may be told of it: only those whose readers can all read
113/// it. That is its origin (the event's own repository, `origin`), whose
114/// readers may read it; and every one of them when its origin is public
115/// (`origin_public`), as anyone may then. An item with no origin is its
116/// workspace members' alone: no repository's webhooks hear of it, nor do
117/// those of a repository it only names while its origin is private.
118pub fn item_hook_repos(origin: Option<&str>, repo_ids: &[String], origin_public: bool) -> Vec<String> {
119 let Some(origin) = origin.filter(|id| !id.is_empty()) else {
120 return Vec::new();
121 };
122 repo_ids.iter().filter(|id| origin_public || id.as_str() == origin).cloned().collect()
123}
124
125/// What is sent to check a webhook works.
126pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
127 json!({
128 "type": "ping",
129 "time": time,
130 "hook": { "id": hook_id, "url": url, "events": events },
131 "message": "g1t will send this webhook's events here.",
132 })
133}
134
135/// The signature header's value: the body's HMAC-SHA256 under the secret.
136pub fn signature(secret: &str, body: &str) -> String {
137 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
138}
139
140/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
141/// not.
142pub fn check_url(url: &str) -> Result<(), String> {
143 let Some(rest) = url.strip_prefix("https://") else {
144 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
145 };
146 if url.len() > 2000 {
147 return Err("That address is too long.".to_owned());
148 }
149 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
150 let host = authority.rsplit('@').next().unwrap_or_default();
151 let host = host.strip_prefix('[').map_or_else(
152 || host.split(':').next().unwrap_or_default(),
153 |v6| v6.split(']').next().unwrap_or_default(),
154 );
155 let host = host.to_ascii_lowercase();
156 if host.is_empty() || !host.contains(['.', ':']) {
157 return Err("The address needs a host g1t can reach on the internet.".to_owned());
158 }
159 let private_name = host == "localhost"
160 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
161 .iter()
162 .any(|suffix| host.ends_with(suffix));
163 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
164 std::net::IpAddr::V4(v4) => {
165 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])
166 }
167 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
168 });
169 if private_name || private_ip {
170 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
171 }
172 Ok(())
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn retries_wait_longer_each_time_then_stop() {
181 assert_eq!(retry_after(1), Some(60));
182 assert_eq!(retry_after(2), Some(300));
183 assert_eq!(retry_after(5), Some(18_000));
184 assert_eq!(retry_after(6), None);
185 assert_eq!(retry_after(0), None);
186 }
187
188 #[test]
189 fn events_are_checked_and_ordered() {
190 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
191 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
192 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
193 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
194 // A list saved when agent.asked was offered saves again without it.
195 let old = vec!["agent.asked".to_owned(), "pull.merged".to_owned(), "review.completed".to_owned()];
196 assert_eq!(tidy_events(&old).unwrap(), vec!["pull.merged"]);
197 assert!(tidy_events(&["agent.asked".to_owned()]).is_err());
198 assert!(wants(&["*".to_owned()], "work_item.opened"));
199 assert!(!wants(&["git.push".to_owned()], "work_item.opened"));
200 // Issues became work items: their old names are no events.
201 assert!(tidy_events(&["issue.opened".to_owned()]).is_err());
202 }
203
204 #[test]
205 fn only_public_https_addresses_are_allowed() {
206 assert!(check_url("https://hooks.example.com/g1t").is_ok());
207 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
208 for url in [
209 "http://example.com/hook",
210 "https://localhost/hook",
211 "https://127.0.0.1/hook",
212 "https://10.0.0.5/hook",
213 "https://192.168.1.2:8080/hook",
214 "https://169.254.169.254/latest",
215 "https://100.64.0.1/hook",
216 "https://[::1]/hook",
217 "https://[fd00::1]/hook",
218 "https://printer.local/hook",
219 "https://intranet/hook",
220 "https://user@10.0.0.1/hook",
221 ] {
222 assert!(check_url(url).is_err(), "{url}");
223 }
224 }
225
226 #[test]
227 fn payloads_are_snake_case() {
228 let event = Event {
229 id: "evt_1".to_owned(),
230 kind: "pull.merged".to_owned(),
231 source: "work".to_owned(),
232 time: "2026-10-04T16:00:00Z".to_owned(),
233 repo_id: Some("rep_1".to_owned()),
234 actor: Some("usr_1".to_owned()),
235 data: json!({ "pullId": "pul_1", "repoId": "rep_1", "supersededBy": null, "inputs": { "dryRun": true } }),
236 };
237 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
238 assert_eq!(sent["repository"]["full_name"], "acme/rocket");
239 assert_eq!(sent["data"]["pull_id"], "pul_1");
240 assert!(sent["data"].get("superseded_by").is_some());
241 assert_eq!(sent["data"]["inputs"]["dryRun"], true);
242 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
243 }
244
245 #[test]
246 fn a_pull_request_g1t_made_names_g1t_and_who_asked() {
247 // As the work service publishes it: g1t the author, the person who
248 // asked beside it, and the actor whoever caused the event.
249 let event = Event {
250 id: "evt_1".to_owned(),
251 kind: "pull.opened".to_owned(),
252 source: "work".to_owned(),
253 time: "2026-10-06T10:00:00Z".to_owned(),
254 repo_id: Some("rep_1".to_owned()),
255 actor: Some("usr_1".to_owned()),
256 data: json!({
257 "pullId": "pr_1", "repoId": "rep_1", "number": 14, "agent": "g1t",
258 "author": { "id": "usr_g1t_agent", "username": "g1t" },
259 "requestedBy": { "id": "usr_1", "username": "syntaqx" }
260 }),
261 };
262 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
263 assert_eq!(sent["data"]["author"]["username"], "g1t");
264 assert_eq!(sent["data"]["requested_by"]["username"], "syntaqx");
265 assert_eq!(sent["actor"]["username"], "syntaqx");
266 }
267
268 fn repo_event(kind: &str, data: Value) -> Event {
269 Event {
270 id: "evt_1".to_owned(),
271 kind: kind.to_owned(),
272 source: "repos".to_owned(),
273 time: "2026-10-05T16:00:00Z".to_owned(),
274 repo_id: Some("rep_1".to_owned()),
275 actor: None,
276 data,
277 }
278 }
279
280 #[test]
281 fn a_workspaces_own_package_events_go_to_its_webhooks() {
282 let mut event = repo_event("package.published", json!({ "packageId": "pkg_1", "workspace": "Acme", "name": "tools", "repoId": null }));
283 event.repo_id = None;
284 assert_eq!(workspace_scoped(&event).as_deref(), Some("acme"));
285 let sent = payload(&event, "acme", None, Some("ana"));
286 assert_eq!(sent["repository"], Value::Null);
287 assert_eq!(sent["workspace"], "acme");
288 assert_eq!(sent["data"]["package_id"], "pkg_1", "snake_case as everything sent");
289 // A linked package's events go by its repository.
290 let linked = repo_event("package.published", json!({ "workspace": "acme", "repoId": "rep_1" }));
291 assert_eq!(workspace_scoped(&linked), None);
292 // A team's events are the workspace's; one about a repository goes by it.
293 let mut team = repo_event("team.created", json!({ "workspace": "Acme", "team": "backend" }));
294 team.repo_id = None;
295 assert_eq!(workspace_scoped(&team).as_deref(), Some("acme"));
296 let granted = repo_event("team.repo_added", json!({ "workspace": "acme", "repoId": "rep_1" }));
297 assert_eq!(workspace_scoped(&granted), None);
298 // A workspace's ruleset is the workspace's; a repository's goes by it.
299 let mut ruleset = repo_event("ruleset.updated", json!({ "workspace": "acme", "ruleset": {} }));
300 ruleset.repo_id = None;
301 assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme"));
302 let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" }));
303 assert_eq!(workspace_scoped(&own), None);
304 // Someone joining the workspace is the workspace's.
305 let mut joined = repo_event("workspace.member_joined", json!({ "workspaceId": "wsp_1", "workspace": "Acme", "userId": "usr_1", "username": "ada", "role": "member", "how": "invitation" }));
306 joined.repo_id = None;
307 assert_eq!(workspace_scoped(&joined).as_deref(), Some("acme"));
308 assert_eq!(payload(&joined, "acme", None, Some("ada"))["data"]["user_id"], "usr_1", "snake_case as everything sent");
309 // Other events without a repository are not workspace events.
310 let mut other = repo_event("pull.opened", json!({ "workspace": "acme" }));
311 other.repo_id = None;
312 assert_eq!(workspace_scoped(&other), None);
313 let mut nameless = repo_event("package.deleted", json!({ "workspace": "" }));
314 nameless.repo_id = None;
315 assert_eq!(workspace_scoped(&nameless), None);
316 }
317
318 #[test]
319 fn repository_events_name_where_it_is_now() {
320 let named = |kind: &str, data: Value| named_in(&repo_event(kind, data));
321 let at = |namespace: &str, name: &str| Some((namespace.to_owned(), name.to_owned()));
322 assert_eq!(named("repo.renamed", json!({ "namespace": "acme", "from": "old", "to": "new" })), at("acme", "new"));
323 assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web"));
324 assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web"));
325 assert_eq!(named("repo.deleted", json!({ "repoId": "rep_1", "namespace": "", "name": "web" })), None);
326 assert_eq!(named("work_item.opened", json!({ "namespace": "acme", "name": "web" })), None);
327 }
328
329 #[test]
330 fn work_item_events_go_to_its_workspace_and_every_linked_repository() {
331 let opened = repo_event(
332 "work_item.opened",
333 json!({ "itemId": "wki_1", "workspace": "Acme", "number": 12, "repoIds": ["rep_1", "rep_2"] }),
334 );
335 assert_eq!(item_scoped(&opened), Some(("acme".to_owned(), vec!["rep_1".to_owned(), "rep_2".to_owned()])));
336 // Filed in the workspace, linked nowhere yet.
337 let mut alone = repo_event("work_item.opened", json!({ "itemId": "wki_2", "workspace": "acme", "number": 13 }));
338 alone.repo_id = None;
339 assert_eq!(item_scoped(&alone), Some(("acme".to_owned(), vec![])));
340 // A comment on a work item goes as the work item does; one on a
341 // pull request goes by its repository.
342 let commented = repo_event(
343 "comment.created",
344 json!({ "commentId": "cmt_1", "number": 12, "itemId": "wki_1", "workspace": "acme", "repoIds": ["rep_2"] }),
345 );
346 assert_eq!(item_scoped(&commented), Some(("acme".to_owned(), vec!["rep_2".to_owned()])));
347 let on_pull = repo_event("comment.created", json!({ "commentId": "cmt_2", "repoId": "rep_1", "number": 3, "pullId": "pul_1" }));
348 assert_eq!(item_scoped(&on_pull), None);
349 assert_eq!(item_scoped(&repo_event("pull.opened", json!({ "workspace": "acme" }))), None);
350 }
351
352 #[test]
353 fn a_work_item_reaches_only_the_webhooks_of_repositories_whose_readers_may_read_it() {
354 let linked = vec!["rep_1".to_owned(), "rep_2".to_owned()];
355 // A private origin: its own webhooks, not those of a repository it only names.
356 assert_eq!(item_hook_repos(Some("rep_1"), &linked, false), vec!["rep_1".to_owned()]);
357 // A public one: anyone may read it, so every repository it is linked to.
358 assert_eq!(item_hook_repos(Some("rep_1"), &linked, true), linked);
359 // No origin: the workspace's webhooks alone, whatever it is linked to.
360 assert!(item_hook_repos(None, &["rep_2".to_owned()], false).is_empty());
361 assert!(item_hook_repos(None, &["rep_2".to_owned()], true).is_empty());
362 }
363
364 #[test]
365 fn repository_lifecycle_events_can_be_chosen() {
366 for kind in [
367 "repo.updated",
368 "repo.visibility_changed",
369 "repo.renamed",
370 "repo.transferred",
371 "repo.archived",
372 "repo.unarchived",
373 "repo.deleted",
374 "repo.restored",
375 "repo.purged",
376 "repo.default_branch_changed",
377 "branch.renamed",
378 ] {
379 assert_eq!(tidy_events(&[kind.to_owned()]).unwrap(), vec![kind], "{kind}");
380 }
381 }
382
383 #[test]
384 fn the_signature_is_the_bodys_hmac() {
385 let signature = signature("shh", "{\"a\":1}");
386 assert!(signature.starts_with("sha256="));
387 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
388 }
389}