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.

362 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 goes to the
95/// workspace's webhooks and to those of each of those repositories.
96pub fn item_scoped(event: &Event) -> Option<(String, Vec<String>)> {
97 let about_item = event.kind.starts_with("work_item.")
98 || (event.kind.starts_with("comment.") && event.data["itemId"].as_str().is_some_and(|id| !id.is_empty()));
99 if !about_item {
100 return None;
101 }
102 let workspace = event.data["workspace"].as_str().map(|slug| slug.trim().to_lowercase()).filter(|slug| !slug.is_empty())?;
103 let repo_ids = event.data["repoIds"]
104 .as_array()
105 .map(|ids| ids.iter().filter_map(Value::as_str).filter(|id| !id.is_empty()).map(str::to_owned).collect())
106 .unwrap_or_default();
107 Some((workspace, repo_ids))
108}
109
110/// What is sent to check a webhook works.
111pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
112 json!({
113 "type": "ping",
114 "time": time,
115 "hook": { "id": hook_id, "url": url, "events": events },
116 "message": "g1t will send this webhook's events here.",
117 })
118}
119
120/// The signature header's value: the body's HMAC-SHA256 under the secret.
121pub fn signature(secret: &str, body: &str) -> String {
122 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
123}
124
125/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
126/// not.
127pub fn check_url(url: &str) -> Result<(), String> {
128 let Some(rest) = url.strip_prefix("https://") else {
129 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
130 };
131 if url.len() > 2000 {
132 return Err("That address is too long.".to_owned());
133 }
134 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
135 let host = authority.rsplit('@').next().unwrap_or_default();
136 let host = host.strip_prefix('[').map_or_else(
137 || host.split(':').next().unwrap_or_default(),
138 |v6| v6.split(']').next().unwrap_or_default(),
139 );
140 let host = host.to_ascii_lowercase();
141 if host.is_empty() || !host.contains(['.', ':']) {
142 return Err("The address needs a host g1t can reach on the internet.".to_owned());
143 }
144 let private_name = host == "localhost"
145 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
146 .iter()
147 .any(|suffix| host.ends_with(suffix));
148 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
149 std::net::IpAddr::V4(v4) => {
150 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])
151 }
152 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
153 });
154 if private_name || private_ip {
155 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
156 }
157 Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn retries_wait_longer_each_time_then_stop() {
166 assert_eq!(retry_after(1), Some(60));
167 assert_eq!(retry_after(2), Some(300));
168 assert_eq!(retry_after(5), Some(18_000));
169 assert_eq!(retry_after(6), None);
170 assert_eq!(retry_after(0), None);
171 }
172
173 #[test]
174 fn events_are_checked_and_ordered() {
175 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
176 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
177 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
178 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
179 // A list saved when agent.asked was offered saves again without it.
180 let old = vec!["agent.asked".to_owned(), "pull.merged".to_owned(), "review.completed".to_owned()];
181 assert_eq!(tidy_events(&old).unwrap(), vec!["pull.merged"]);
182 assert!(tidy_events(&["agent.asked".to_owned()]).is_err());
183 assert!(wants(&["*".to_owned()], "work_item.opened"));
184 assert!(!wants(&["git.push".to_owned()], "work_item.opened"));
185 // Issues became work items: their old names are no events.
186 assert!(tidy_events(&["issue.opened".to_owned()]).is_err());
187 }
188
189 #[test]
190 fn only_public_https_addresses_are_allowed() {
191 assert!(check_url("https://hooks.example.com/g1t").is_ok());
192 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
193 for url in [
194 "http://example.com/hook",
195 "https://localhost/hook",
196 "https://127.0.0.1/hook",
197 "https://10.0.0.5/hook",
198 "https://192.168.1.2:8080/hook",
199 "https://169.254.169.254/latest",
200 "https://100.64.0.1/hook",
201 "https://[::1]/hook",
202 "https://[fd00::1]/hook",
203 "https://printer.local/hook",
204 "https://intranet/hook",
205 "https://user@10.0.0.1/hook",
206 ] {
207 assert!(check_url(url).is_err(), "{url}");
208 }
209 }
210
211 #[test]
212 fn payloads_are_snake_case() {
213 let event = Event {
214 id: "evt_1".to_owned(),
215 kind: "pull.merged".to_owned(),
216 source: "work".to_owned(),
217 time: "2026-10-04T16:00:00Z".to_owned(),
218 repo_id: Some("rep_1".to_owned()),
219 actor: Some("usr_1".to_owned()),
220 data: json!({ "pullId": "pul_1", "repoId": "rep_1", "supersededBy": null, "inputs": { "dryRun": true } }),
221 };
222 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
223 assert_eq!(sent["repository"]["full_name"], "acme/rocket");
224 assert_eq!(sent["data"]["pull_id"], "pul_1");
225 assert!(sent["data"].get("superseded_by").is_some());
226 assert_eq!(sent["data"]["inputs"]["dryRun"], true);
227 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
228 }
229
230 #[test]
231 fn a_pull_request_g1t_made_names_g1t_and_who_asked() {
232 // As the work service publishes it: g1t the author, the person who
233 // asked beside it, and the actor whoever caused the event.
234 let event = Event {
235 id: "evt_1".to_owned(),
236 kind: "pull.opened".to_owned(),
237 source: "work".to_owned(),
238 time: "2026-10-06T10:00:00Z".to_owned(),
239 repo_id: Some("rep_1".to_owned()),
240 actor: Some("usr_1".to_owned()),
241 data: json!({
242 "pullId": "pr_1", "repoId": "rep_1", "number": 14, "agent": "g1t",
243 "author": { "id": "usr_g1t_agent", "username": "g1t" },
244 "requestedBy": { "id": "usr_1", "username": "syntaqx" }
245 }),
246 };
247 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
248 assert_eq!(sent["data"]["author"]["username"], "g1t");
249 assert_eq!(sent["data"]["requested_by"]["username"], "syntaqx");
250 assert_eq!(sent["actor"]["username"], "syntaqx");
251 }
252
253 fn repo_event(kind: &str, data: Value) -> Event {
254 Event {
255 id: "evt_1".to_owned(),
256 kind: kind.to_owned(),
257 source: "repos".to_owned(),
258 time: "2026-10-05T16:00:00Z".to_owned(),
259 repo_id: Some("rep_1".to_owned()),
260 actor: None,
261 data,
262 }
263 }
264
265 #[test]
266 fn a_workspaces_own_package_events_go_to_its_webhooks() {
267 let mut event = repo_event("package.published", json!({ "packageId": "pkg_1", "workspace": "Acme", "name": "tools", "repoId": null }));
268 event.repo_id = None;
269 assert_eq!(workspace_scoped(&event).as_deref(), Some("acme"));
270 let sent = payload(&event, "acme", None, Some("ana"));
271 assert_eq!(sent["repository"], Value::Null);
272 assert_eq!(sent["workspace"], "acme");
273 assert_eq!(sent["data"]["package_id"], "pkg_1", "snake_case as everything sent");
274 // A linked package's events go by its repository.
275 let linked = repo_event("package.published", json!({ "workspace": "acme", "repoId": "rep_1" }));
276 assert_eq!(workspace_scoped(&linked), None);
277 // A team's events are the workspace's; one about a repository goes by it.
278 let mut team = repo_event("team.created", json!({ "workspace": "Acme", "team": "backend" }));
279 team.repo_id = None;
280 assert_eq!(workspace_scoped(&team).as_deref(), Some("acme"));
281 let granted = repo_event("team.repo_added", json!({ "workspace": "acme", "repoId": "rep_1" }));
282 assert_eq!(workspace_scoped(&granted), None);
283 // A workspace's ruleset is the workspace's; a repository's goes by it.
284 let mut ruleset = repo_event("ruleset.updated", json!({ "workspace": "acme", "ruleset": {} }));
285 ruleset.repo_id = None;
286 assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme"));
287 let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" }));
288 assert_eq!(workspace_scoped(&own), None);
289 // Someone joining the workspace is the workspace's.
290 let mut joined = repo_event("workspace.member_joined", json!({ "workspaceId": "wsp_1", "workspace": "Acme", "userId": "usr_1", "username": "ada", "role": "member", "how": "invitation" }));
291 joined.repo_id = None;
292 assert_eq!(workspace_scoped(&joined).as_deref(), Some("acme"));
293 assert_eq!(payload(&joined, "acme", None, Some("ada"))["data"]["user_id"], "usr_1", "snake_case as everything sent");
294 // Other events without a repository are not workspace events.
295 let mut other = repo_event("pull.opened", json!({ "workspace": "acme" }));
296 other.repo_id = None;
297 assert_eq!(workspace_scoped(&other), None);
298 let mut nameless = repo_event("package.deleted", json!({ "workspace": "" }));
299 nameless.repo_id = None;
300 assert_eq!(workspace_scoped(&nameless), None);
301 }
302
303 #[test]
304 fn repository_events_name_where_it_is_now() {
305 let named = |kind: &str, data: Value| named_in(&repo_event(kind, data));
306 let at = |namespace: &str, name: &str| Some((namespace.to_owned(), name.to_owned()));
307 assert_eq!(named("repo.renamed", json!({ "namespace": "acme", "from": "old", "to": "new" })), at("acme", "new"));
308 assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web"));
309 assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web"));
310 assert_eq!(named("repo.deleted", json!({ "repoId": "rep_1", "namespace": "", "name": "web" })), None);
311 assert_eq!(named("work_item.opened", json!({ "namespace": "acme", "name": "web" })), None);
312 }
313
314 #[test]
315 fn work_item_events_go_to_its_workspace_and_every_linked_repository() {
316 let opened = repo_event(
317 "work_item.opened",
318 json!({ "itemId": "wki_1", "workspace": "Acme", "number": 12, "repoIds": ["rep_1", "rep_2"] }),
319 );
320 assert_eq!(item_scoped(&opened), Some(("acme".to_owned(), vec!["rep_1".to_owned(), "rep_2".to_owned()])));
321 // Filed in the workspace, linked nowhere yet.
322 let mut alone = repo_event("work_item.opened", json!({ "itemId": "wki_2", "workspace": "acme", "number": 13 }));
323 alone.repo_id = None;
324 assert_eq!(item_scoped(&alone), Some(("acme".to_owned(), vec![])));
325 // A comment on a work item goes as the work item does; one on a
326 // pull request goes by its repository.
327 let commented = repo_event(
328 "comment.created",
329 json!({ "commentId": "cmt_1", "number": 12, "itemId": "wki_1", "workspace": "acme", "repoIds": ["rep_2"] }),
330 );
331 assert_eq!(item_scoped(&commented), Some(("acme".to_owned(), vec!["rep_2".to_owned()])));
332 let on_pull = repo_event("comment.created", json!({ "commentId": "cmt_2", "repoId": "rep_1", "number": 3, "pullId": "pul_1" }));
333 assert_eq!(item_scoped(&on_pull), None);
334 assert_eq!(item_scoped(&repo_event("pull.opened", json!({ "workspace": "acme" }))), None);
335 }
336
337 #[test]
338 fn repository_lifecycle_events_can_be_chosen() {
339 for kind in [
340 "repo.updated",
341 "repo.visibility_changed",
342 "repo.renamed",
343 "repo.transferred",
344 "repo.archived",
345 "repo.unarchived",
346 "repo.deleted",
347 "repo.restored",
348 "repo.purged",
349 "repo.default_branch_changed",
350 "branch.renamed",
351 ] {
352 assert_eq!(tidy_events(&[kind.to_owned()]).unwrap(), vec![kind], "{kind}");
353 }
354 }
355
356 #[test]
357 fn the_signature_is_the_bodys_hmac() {
358 let signature = signature("shh", "{\"a\":1}");
359 assert!(signature.starts_with("sha256="));
360 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
361 }
362}