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