Skip to content
309 linesCodeBlameRaw

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.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1//! 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
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`).
42pub fn payload(event: &Event, workspace: &str, repo: Option<(&str, &str)>, actor_name: Option<&str>) -> Value {
43 g1t_kit::wire::snake_case(json!({
44 "id": event.id,
45 "type": event.kind,
46 "time": event.time,
47 "workspace": workspace,
48 "repository": repo.map(|(id, full_name)| json!({ "id": id, "full_name": full_name })),
49 "actor": event.actor.as_ref().map(|id| json!({ "id": id, "username": actor_name })),
50 "data": event.data,
51 }))
52}
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
@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 member72pub 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 bar77 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 member78 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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API86/// What is sent to check a webhook works.
87pub 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.
97pub 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.
103pub 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)]
137mod 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]
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 rights200 #[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 look223 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 member236 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 bar247 // 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 merge253 // 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 member264 // 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 look274 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 API303 #[test]
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.