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.

g1t.sh
320 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.

Fixes from the page sweep1//! 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;
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.5use g1t_contracts::webhooks::{EVENT_TYPES, RETIRED_EVENT_TYPES};
Fixes from the page sweep6use 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
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.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.
Fixes from the page sweep27pub 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 }
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.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();
Fixes from the page sweep36 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
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge74/// 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.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.
Fixes from the page sweep79pub 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.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";
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar84 if event.repo_id.as_deref().is_some_and(|id| !id.is_empty()) || !own {
Fixes from the page sweep85 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/// What is sent to check a webhook works.
94pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
95 json!({
96 "type": "ping",
97 "time": time,
98 "hook": { "id": hook_id, "url": url, "events": events },
99 "message": "g1t will send this webhook's events here.",
100 })
101}
102
103/// The signature header's value: the body's HMAC-SHA256 under the secret.
104pub fn signature(secret: &str, body: &str) -> String {
105 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
106}
107
108/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
109/// not.
110pub fn check_url(url: &str) -> Result<(), String> {
111 let Some(rest) = url.strip_prefix("https://") else {
112 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
113 };
114 if url.len() > 2000 {
115 return Err("That address is too long.".to_owned());
116 }
117 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
118 let host = authority.rsplit('@').next().unwrap_or_default();
119 let host = host.strip_prefix('[').map_or_else(
120 || host.split(':').next().unwrap_or_default(),
121 |v6| v6.split(']').next().unwrap_or_default(),
122 );
123 let host = host.to_ascii_lowercase();
124 if host.is_empty() || !host.contains(['.', ':']) {
125 return Err("The address needs a host g1t can reach on the internet.".to_owned());
126 }
127 let private_name = host == "localhost"
128 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
129 .iter()
130 .any(|suffix| host.ends_with(suffix));
131 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
132 std::net::IpAddr::V4(v4) => {
133 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])
134 }
135 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
136 });
137 if private_name || private_ip {
138 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
139 }
140 Ok(())
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn retries_wait_longer_each_time_then_stop() {
149 assert_eq!(retry_after(1), Some(60));
150 assert_eq!(retry_after(2), Some(300));
151 assert_eq!(retry_after(5), Some(18_000));
152 assert_eq!(retry_after(6), None);
153 assert_eq!(retry_after(0), None);
154 }
155
156 #[test]
157 fn events_are_checked_and_ordered() {
158 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
159 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
160 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
161 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.162 // A list saved when agent.asked was offered saves again without it.
163 let old = vec!["agent.asked".to_owned(), "pull.merged".to_owned(), "review.completed".to_owned()];
164 assert_eq!(tidy_events(&old).unwrap(), vec!["pull.merged"]);
165 assert!(tidy_events(&["agent.asked".to_owned()]).is_err());
Fixes from the page sweep166 assert!(wants(&["*".to_owned()], "issue.opened"));
167 assert!(!wants(&["git.push".to_owned()], "issue.opened"));
168 }
169
170 #[test]
171 fn only_public_https_addresses_are_allowed() {
172 assert!(check_url("https://hooks.example.com/g1t").is_ok());
173 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
174 for url in [
175 "http://example.com/hook",
176 "https://localhost/hook",
177 "https://127.0.0.1/hook",
178 "https://10.0.0.5/hook",
179 "https://192.168.1.2:8080/hook",
180 "https://169.254.169.254/latest",
181 "https://100.64.0.1/hook",
182 "https://[::1]/hook",
183 "https://[fd00::1]/hook",
184 "https://printer.local/hook",
185 "https://intranet/hook",
186 "https://user@10.0.0.1/hook",
187 ] {
188 assert!(check_url(url).is_err(), "{url}");
189 }
190 }
191
192 #[test]
193 fn payloads_are_snake_case() {
194 let event = Event {
195 id: "evt_1".to_owned(),
196 kind: "pull.merged".to_owned(),
197 source: "work".to_owned(),
198 time: "2026-10-04T16:00:00Z".to_owned(),
199 repo_id: Some("rep_1".to_owned()),
200 actor: Some("usr_1".to_owned()),
201 data: json!({ "pullId": "pul_1", "repoId": "rep_1", "supersededBy": null, "inputs": { "dryRun": true } }),
202 };
203 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
204 assert_eq!(sent["repository"]["full_name"], "acme/rocket");
205 assert_eq!(sent["data"]["pull_id"], "pul_1");
206 assert!(sent["data"].get("superseded_by").is_some());
207 assert_eq!(sent["data"]["inputs"]["dryRun"], true);
208 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
209 }
210
211 #[test]
212 fn a_pull_request_g1t_made_names_g1t_and_who_asked() {
213 // As the work service publishes it: g1t the author, the person who
214 // asked beside it, and the actor whoever caused the event.
215 let event = Event {
216 id: "evt_1".to_owned(),
217 kind: "pull.opened".to_owned(),
218 source: "work".to_owned(),
219 time: "2026-10-06T10:00:00Z".to_owned(),
220 repo_id: Some("rep_1".to_owned()),
221 actor: Some("usr_1".to_owned()),
222 data: json!({
223 "pullId": "pr_1", "repoId": "rep_1", "number": 14, "agent": "g1t",
224 "author": { "id": "usr_g1t_agent", "username": "g1t" },
225 "requestedBy": { "id": "usr_1", "username": "syntaqx" }
226 }),
227 };
228 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
229 assert_eq!(sent["data"]["author"]["username"], "g1t");
230 assert_eq!(sent["data"]["requested_by"]["username"], "syntaqx");
231 assert_eq!(sent["actor"]["username"], "syntaqx");
232 }
233
234 fn repo_event(kind: &str, data: Value) -> Event {
235 Event {
236 id: "evt_1".to_owned(),
237 kind: kind.to_owned(),
238 source: "repos".to_owned(),
239 time: "2026-10-05T16:00:00Z".to_owned(),
240 repo_id: Some("rep_1".to_owned()),
241 actor: None,
242 data,
243 }
244 }
245
246 #[test]
247 fn a_workspaces_own_package_events_go_to_its_webhooks() {
248 let mut event = repo_event("package.published", json!({ "packageId": "pkg_1", "workspace": "Acme", "name": "tools", "repoId": null }));
249 event.repo_id = None;
250 assert_eq!(workspace_scoped(&event).as_deref(), Some("acme"));
251 let sent = payload(&event, "acme", None, Some("ana"));
252 assert_eq!(sent["repository"], Value::Null);
253 assert_eq!(sent["workspace"], "acme");
254 assert_eq!(sent["data"]["package_id"], "pkg_1", "snake_case as everything sent");
255 // A linked package's events go by its repository.
256 let linked = repo_event("package.published", json!({ "workspace": "acme", "repoId": "rep_1" }));
257 assert_eq!(workspace_scoped(&linked), None);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar258 // A team's events are the workspace's; one about a repository goes by it.
259 let mut team = repo_event("team.created", json!({ "workspace": "Acme", "team": "backend" }));
260 team.repo_id = None;
261 assert_eq!(workspace_scoped(&team).as_deref(), Some("acme"));
262 let granted = repo_event("team.repo_added", json!({ "workspace": "acme", "repoId": "rep_1" }));
263 assert_eq!(workspace_scoped(&granted), None);
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge264 // A workspace's ruleset is the workspace's; a repository's goes by it.
265 let mut ruleset = repo_event("ruleset.updated", json!({ "workspace": "acme", "ruleset": {} }));
266 ruleset.repo_id = None;
267 assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme"));
268 let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" }));
269 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.270 // Someone joining the workspace is the workspace's.
271 let mut joined = repo_event("workspace.member_joined", json!({ "workspaceId": "wsp_1", "workspace": "Acme", "userId": "usr_1", "username": "ada", "role": "member", "how": "invitation" }));
272 joined.repo_id = None;
273 assert_eq!(workspace_scoped(&joined).as_deref(), Some("acme"));
274 assert_eq!(payload(&joined, "acme", None, Some("ada"))["data"]["user_id"], "usr_1", "snake_case as everything sent");
Fixes from the page sweep275 // Other events without a repository are not workspace events.
276 let mut other = repo_event("issue.opened", json!({ "workspace": "acme" }));
277 other.repo_id = None;
278 assert_eq!(workspace_scoped(&other), None);
279 let mut nameless = repo_event("package.deleted", json!({ "workspace": "" }));
280 nameless.repo_id = None;
281 assert_eq!(workspace_scoped(&nameless), None);
282 }
283
284 #[test]
285 fn repository_events_name_where_it_is_now() {
286 let named = |kind: &str, data: Value| named_in(&repo_event(kind, data));
287 let at = |namespace: &str, name: &str| Some((namespace.to_owned(), name.to_owned()));
288 assert_eq!(named("repo.renamed", json!({ "namespace": "acme", "from": "old", "to": "new" })), at("acme", "new"));
289 assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web"));
290 assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web"));
291 assert_eq!(named("repo.deleted", json!({ "repoId": "rep_1", "namespace": "", "name": "web" })), None);
292 assert_eq!(named("issue.opened", json!({ "namespace": "acme", "name": "web" })), None);
293 }
294
295 #[test]
296 fn repository_lifecycle_events_can_be_chosen() {
297 for kind in [
298 "repo.updated",
299 "repo.visibility_changed",
300 "repo.renamed",
301 "repo.transferred",
302 "repo.archived",
303 "repo.unarchived",
304 "repo.deleted",
305 "repo.restored",
306 "repo.purged",
307 "repo.default_branch_changed",
308 "branch.renamed",
309 ] {
310 assert_eq!(tidy_events(&[kind.to_owned()]).unwrap(), vec![kind], "{kind}");
311 }
312 }
313
314 #[test]
315 fn the_signature_is_the_bodys_hmac() {
316 let signature = signature("shh", "{\"a\":1}");
317 assert!(signature.starts_with("sha256="));
318 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
319 }
320}

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