Commit

Webhooks: every event, to your own addresses, signed and retried

A new webhooks service subscribes to the event bus and delivers each event to the repository and workspace webhooks that want it: an HTTPS POST of JSON naming the repository, workspace and actor, signed with HMAC-SHA256 in X-G1t-Signature-256. A receiver that does not answer with a 2xx within ten seconds is tried again after 1m, 5m, 30m, 2h and 5h. Every delivery is kept for a fortnight with its request and response, and can be redelivered. Addresses must be public HTTPS; private and local ones are refused. Each event reaches a webhook once. Members manage a repository's webhooks under its settings, now with General and Webhooks tabs; owners manage the workspace's under Settings. Seven API and MCP tools cover both. The secret sealing that integrations used moves into a shared crate, g1t-secrets. Also: an address no route matches now shows its 404 inside the signed-in sidebar instead of the public frame.

syntaqxcommitted Parent54e0330Browse files
41 files+2541−2930/41 viewed
+26−2
937937 name = "g1t-integrations"
938938 version = "0.1.0"
939939 dependencies = [
940− "aes-gcm 0.10.3",
941940 "base64 0.22.1",
942941 "g1t-contracts",
943942 "g1t-kit",
943+ "g1t-secrets",
944944 "getrandom 0.2.17",
945945 "hex",
946− "hmac 0.12.1",
947946 "serde",
948947 "serde_json",
949948 "sha2 0.10.9",
985984 ]
986985
987986 [[package]]
987+name = "g1t-secrets"
988+version = "0.1.0"
989+dependencies = [
990+ "aes-gcm 0.10.3",
991+ "base64 0.22.1",
992+ "getrandom 0.2.17",
993+ "hex",
994+ "hmac 0.12.1",
995+ "sha2 0.10.9",
996+]
997+
998+[[package]]
988999 name = "g1t-sshd"
9891000 version = "0.1.0"
9901001 dependencies = [
10001011 ]
10011012
10021013 [[package]]
1014+name = "g1t-webhooks"
1015+version = "0.1.0"
1016+dependencies = [
1017+ "futures-util",
1018+ "g1t-contracts",
1019+ "g1t-kit",
1020+ "g1t-secrets",
1021+ "serde",
1022+ "serde_json",
1023+ "worker",
1024+]
1025+
1026+[[package]]
10031027 name = "g1t-work"
10041028 version = "0.1.0"
10051029 dependencies = [
+2−1
11 [workspace]
22 resolver = "3"
3−members = ["apps/api", "crates/*", "services/billing", "services/events", "services/identity", "services/integrations", "services/repos", "services/work"]
3+members = ["apps/api", "crates/*", "services/billing", "services/events", "services/identity", "services/integrations", "services/webhooks", "services/repos", "services/work"]
44
55 [workspace.package]
66 edition = "2024"
1010 [workspace.dependencies]
1111 g1t-contracts = { path = "crates/contracts" }
1212 g1t-kit = { path = "crates/kit" }
13+g1t-secrets = { path = "crates/secrets" }
1314 serde = { version = "1", features = ["derive"] }
1415 serde_json = "1"
1516 worker = { version = "0.8", features = ["d1", "queue"] }
+15−2
88 /// The section of the API reference an operation is listed under.
99 fn tag(op: Op) -> &'static str {
1010 let name = op.name();
11− if name.contains("integration") || name.contains("model_routes") || op == Op::GetContext {
11+ if name.contains("webhook") {
12+ "Webhooks"
13+ } else if name.contains("integration") || name.contains("model_routes") || op == Op::GetContext {
1214 "Integrations"
1315 } else if op == Op::Whoami || name.contains("workspace") {
1416 "Accounts"
104106 });
105107 }
106108
109+ // An operation reached at a workspace's address as well as a
110+ // repository's is documented once for each, with its own id.
111+ let id = if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) {
112+ format!("{}_for_workspace", op.name())
113+ } else {
114+ op.name().to_owned()
115+ };
107116 let mut described = json!({
108− "operationId": op.name(),
117+ "operationId": id,
109118 "tags": [tag(op)],
110119 "summary": title(op),
111120 "description": op.description(),
292301 op.name()
293302 );
294303 }
304+ let mut unique = ids.clone();
305+ unique.sort();
306+ unique.dedup();
307+ assert_eq!(unique.len(), ids.len(), "operation ids repeat");
295308 }
296309
297310 #[test]
+144−1
2424 pub runner: Fetcher,
2525 pub billing: Fetcher,
2626 pub integrations: Fetcher,
27+ pub webhooks: Fetcher,
2728 /// Set for a request made with an agent's token: all it may do.
2829 pub scope: Option<AgentScope>,
2930 }
3839 runner: env.service("RUNNER")?,
3940 billing: env.service("BILLING")?,
4041 integrations: env.service("INTEGRATIONS")?,
42+ webhooks: env.service("WEBHOOKS")?,
4143 scope: None,
4244 })
4345 }
8890 ImportIssue,
8991 GetModelRoutes,
9092 SetModelRoutes,
93+ ListWebhooks,
94+ CreateWebhook,
95+ UpdateWebhook,
96+ DeleteWebhook,
97+ PingWebhook,
98+ ListWebhookDeliveries,
99+ RedeliverWebhook,
91100 }
92101
93102 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
219228 Value::Object(out)
220229 }
221230
231+/// The inputs that say whose webhooks: a repository's, or a workspace's own.
232+fn hook_owner(properties: Value) -> Value {
233+ let mut properties = properties;
234+ properties["repo"] = json!({
235+ "type": "string",
236+ "description": "Repository as \"owner/name\", for its webhooks.",
237+ });
238+ properties["workspace"] = json!({
239+ "type": "string",
240+ "description": "Instead of repo: the workspace, for its own webhooks.",
241+ });
242+ properties
243+}
244+
245+fn webhook_events() -> Vec<&'static str> {
246+ g1t_contracts::webhooks::EVENT_TYPES.to_vec()
247+}
248+
222249 fn repo_schema() -> Value {
223250 json!({
224251 "type": "string",
227254 }
228255
229256 impl Op {
230− pub const ALL: [Op; 43] = [
257+ pub const ALL: [Op; 50] = [
231258 Op::Whoami,
232259 Op::CreateWorkspace,
233260 Op::ListRepos,
271298 Op::ImportIssue,
272299 Op::GetModelRoutes,
273300 Op::SetModelRoutes,
301+ Op::ListWebhooks,
302+ Op::CreateWebhook,
303+ Op::UpdateWebhook,
304+ Op::DeleteWebhook,
305+ Op::PingWebhook,
306+ Op::ListWebhookDeliveries,
307+ Op::RedeliverWebhook,
274308 ];
275309
276310 pub fn by_name(name: &str) -> Option<Op> {
323357 Op::ImportIssue => "import_issue",
324358 Op::GetModelRoutes => "get_model_routes",
325359 Op::SetModelRoutes => "set_model_routes",
360+ Op::ListWebhooks => "list_webhooks",
361+ Op::CreateWebhook => "create_webhook",
362+ Op::UpdateWebhook => "update_webhook",
363+ Op::DeleteWebhook => "delete_webhook",
364+ Op::PingWebhook => "ping_webhook",
365+ Op::ListWebhookDeliveries => "list_webhook_deliveries",
366+ Op::RedeliverWebhook => "redeliver_webhook",
326367 }
327368 }
328369
440481 Op::SetModelRoutes => {
441482 "Replace a workspace's model routes. Each route names a task (default, implement, review, plan or update), a connection_id (null for g1t's hosted models) and a model at that provider. Providers that speak OpenAI's API need a model. Owners only."
442483 }
484+ Op::ListWebhooks => {
485+ "A repository's webhooks, or with workspace instead of repo, the workspace's own, which are sent the events of all its repositories. Secrets are never returned. Members only."
486+ }
487+ Op::CreateWebhook => {
488+ "Register an HTTPS address to be sent events as they happen: a signed JSON POST for each, retried for hours if the receiver does not answer with a 2xx. events lists the event types, or leave it out for all. Without a secret, g1t makes one and returns it once. A ping is sent at once. Members, for a repository; owners, for a workspace."
489+ }
490+ Op::UpdateWebhook => {
491+ "Change a webhook's address, its events, or whether it is active. Only the fields given change."
492+ }
493+ Op::DeleteWebhook => "Remove a webhook and its delivery log.",
494+ Op::PingWebhook => "Send a webhook a ping, to check that its receiver answers.",
495+ Op::ListWebhookDeliveries => {
496+ "A webhook's latest deliveries, newest first: what was sent, how the receiver answered, and when it will be tried again."
497+ }
498+ Op::RedeliverWebhook => "Send a delivery's payload again, as a new delivery.",
443499 Op::ImportIssue => {
444500 "Open an issue from a ticket in Jira or Linear, or from a Sentry issue, by its key or address. The issue is linked to it: agents read the original, and when the work lands the ticket is told. Importing the same ticket again returns the issue already made. With assign, a g1t agent starts on it."
445501 }
781837 &["workspace", "provider"],
782838 ),
783839 Op::GetModelRoutes => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
840+ Op::ListWebhooks => object(hook_owner(json!({})), &[]),
841+ Op::CreateWebhook => object(
842+ hook_owner(json!({
843+ "url": { "type": "string", "description": "An HTTPS address on the public internet." },
844+ "events": {
845+ "type": "array",
846+ "items": { "type": "string", "enum": webhook_events() },
847+ "description": "Event types to send. All of them if left out.",
848+ },
849+ "secret": { "type": "string", "description": "What deliveries are signed with. g1t makes one if left out." },
850+ })),
851+ &["url"],
852+ ),
853+ Op::UpdateWebhook => object(
854+ hook_owner(json!({
855+ "id": { "type": "string", "description": "The webhook's id." },
856+ "url": { "type": "string" },
857+ "events": { "type": "array", "items": { "type": "string", "enum": webhook_events() } },
858+ "active": { "type": "boolean" },
859+ })),
860+ &["id"],
861+ ),
862+ Op::DeleteWebhook | Op::PingWebhook | Op::ListWebhookDeliveries => object(
863+ hook_owner(json!({ "id": { "type": "string", "description": "The webhook's id." } })),
864+ &["id"],
865+ ),
866+ Op::RedeliverWebhook => object(
867+ hook_owner(json!({
868+ "id": { "type": "string", "description": "The webhook's id." },
869+ "delivery": { "type": "string", "description": "The delivery's id." },
870+ })),
871+ &["delivery"],
872+ ),
784873 Op::SetModelRoutes => object(
785874 json!({
786875 "workspace": workspace_schema(),
862951 | Op::TestIntegration
863952 | Op::GetModelRoutes
864953 | Op::SetModelRoutes
954+ | Op::ListWebhooks
955+ | Op::CreateWebhook
956+ | Op::UpdateWebhook
957+ | Op::DeleteWebhook
958+ | Op::PingWebhook
959+ | Op::ListWebhookDeliveries
960+ | Op::RedeliverWebhook
865961 )
866962 }
867963
9381034 events,
9391035 runner,
9401036 integrations,
1037+ webhooks,
9411038 ..
9421039 } = services;
9431040 let workspace = || text(input, "workspace").to_lowercase();
13611458 )
13621459 .await
13631460 }
1461+ Op::ListWebhooks
1462+ | Op::CreateWebhook
1463+ | Op::UpdateWebhook
1464+ | Op::DeleteWebhook
1465+ | Op::PingWebhook
1466+ | Op::ListWebhookDeliveries
1467+ | Op::RedeliverWebhook => {
1468+ // A repository's webhooks, or with no repository named, the
1469+ // workspace's own.
1470+ let owner = match repo_path(input) {
1471+ Some(repo) => json!({ "workspace": repo.namespace.to_lowercase(), "repo": repo }),
1472+ None if !workspace().is_empty() => json!({ "workspace": workspace() }),
1473+ None => return failed(FailureCode::Invalid, "Name the repository as repo, or the workspace as workspace."),
1474+ };
1475+ let mut args = owner.as_object().cloned().unwrap_or_default();
1476+ let mut put = |key: &str, value: Value| {
1477+ args.insert(key.to_owned(), value);
1478+ };
1479+ let (method, who) = match self {
1480+ Op::ListWebhooks => ("list", "viewer"),
1481+ Op::CreateWebhook => ("create", "actor"),
1482+ Op::UpdateWebhook => ("update", "actor"),
1483+ Op::DeleteWebhook => ("delete", "actor"),
1484+ Op::PingWebhook => ("ping", "actor"),
1485+ Op::ListWebhookDeliveries => ("deliveries", "viewer"),
1486+ _ => ("redeliver", "actor"),
1487+ };
1488+ put(who, if who == "viewer" { json!(viewer) } else { json!(actor()) });
1489+ put("id", json!(text(input, "id")));
1490+ put("deliveryId", json!(text(input, "delivery")));
1491+ if self == Op::CreateWebhook || self == Op::UpdateWebhook {
1492+ if let Some(url) = optional_text(input, "url") {
1493+ put("url", json!(url));
1494+ }
1495+ if input["events"].is_array() {
1496+ put("events", input["events"].clone());
1497+ }
1498+ if let Some(secret) = optional_text(input, "secret") {
1499+ put("secret", json!(secret));
1500+ }
1501+ if let Some(active) = input["active"].as_bool() {
1502+ put("active", json!(active));
1503+ }
1504+ }
1505+ pass(webhooks, method, &Value::Object(args)).await
1506+ }
13641507 Op::GetModelRoutes => {
13651508 pass(integrations, "routes", &json!({ "workspace": workspace(), "viewer": viewer })).await
13661509 }
+85−1
135135 ),
136136 route(
137137 "GET",
138+ "/repos/:owner/:name/hooks",
139+ Op::ListWebhooks,
140+ &[],
141+ ),
142+ route(
143+ "POST",
144+ "/repos/:owner/:name/hooks",
145+ Op::CreateWebhook,
146+ &[],
147+ ),
148+ route(
149+ "PATCH",
150+ "/repos/:owner/:name/hooks/:id",
151+ Op::UpdateWebhook,
152+ &[],
153+ ),
154+ route(
155+ "DELETE",
156+ "/repos/:owner/:name/hooks/:id",
157+ Op::DeleteWebhook,
158+ &[],
159+ ),
160+ route(
161+ "POST",
162+ "/repos/:owner/:name/hooks/:id/pings",
163+ Op::PingWebhook,
164+ &[],
165+ ),
166+ route(
167+ "GET",
168+ "/repos/:owner/:name/hooks/:id/deliveries",
169+ Op::ListWebhookDeliveries,
170+ &[],
171+ ),
172+ route(
173+ "POST",
174+ "/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver",
175+ Op::RedeliverWebhook,
176+ &[],
177+ ),
178+ route(
179+ "GET",
180+ "/workspaces/:workspace/hooks",
181+ Op::ListWebhooks,
182+ &[],
183+ ),
184+ route(
185+ "POST",
186+ "/workspaces/:workspace/hooks",
187+ Op::CreateWebhook,
188+ &[],
189+ ),
190+ route(
191+ "PATCH",
192+ "/workspaces/:workspace/hooks/:id",
193+ Op::UpdateWebhook,
194+ &[],
195+ ),
196+ route(
197+ "DELETE",
198+ "/workspaces/:workspace/hooks/:id",
199+ Op::DeleteWebhook,
200+ &[],
201+ ),
202+ route(
203+ "POST",
204+ "/workspaces/:workspace/hooks/:id/pings",
205+ Op::PingWebhook,
206+ &[],
207+ ),
208+ route(
209+ "GET",
210+ "/workspaces/:workspace/hooks/:id/deliveries",
211+ Op::ListWebhookDeliveries,
212+ &[],
213+ ),
214+ route(
215+ "POST",
216+ "/workspaces/:workspace/hooks/:id/deliveries/:delivery/redeliver",
217+ Op::RedeliverWebhook,
218+ &[],
219+ ),
220+ route(
221+ "GET",
138222 "/workspaces/:workspace/model-routes",
139223 Op::GetModelRoutes,
140224 &[],
292376 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
293377 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
294378 }
295− for key in ["plan", "id", "workspace"] {
379+ for key in ["plan", "id", "workspace", "delivery"] {
296380 if let Some(value) = param(key) {
297381 input.insert(key.to_owned(), Value::String(value.to_owned()));
298382 }
+2−1
1919 { "binding": "EVENTS", "service": "g1t-events" },
2020 { "binding": "RUNNER", "service": "g1t-runner" },
2121 { "binding": "BILLING", "service": "g1t-billing" },
22− { "binding": "INTEGRATIONS", "service": "g1t-integrations" }
22+ { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
23+ { "binding": "WEBHOOKS", "service": "g1t-webhooks" }
2324 ],
2425 "observability": { "enabled": true }
2526 }
+1−0
7676 items: [
7777 { label: 'Integrations', slug: 'guides/integrations' },
7878 { label: 'Model providers', slug: 'guides/models' },
79+ { label: 'Webhooks', slug: 'guides/webhooks' },
7980 ],
8081 },
8182 {
+145−0
1+---
2+title: Webhooks
3+description: Have g1t send a repository's or a workspace's events to your own address as they happen, signed and retried.
4+---
5+
6+A webhook sends events to an HTTPS address you choose, as they happen: a
7+push, an issue opened, a pull request merged, checks finished, the merge
8+queue moving. Use them to build on g1t: post to chat, start a deploy, keep
9+another system in step.
10+
11+A webhook belongs to one of two things:
12+
13+| | Sent the events of | Managed by | Where |
14+| --- | --- | --- | --- |
15+| A repository's | That repository | Members of its workspace | The repository's **Settings → Webhooks** |
16+| A workspace's | Every repository in the workspace | Owners | The workspace's **Settings → Webhooks** |
17+
18+## Add one
19+
20+1. Open **Settings → Webhooks** for the repository or the workspace.
21+2. Give the **payload URL**: an HTTPS address on the public internet.
22+3. Choose the events, or leave **Everything**, which also includes event
23+ types added later.
24+4. Give a **secret**, or leave it empty and g1t makes one. A secret g1t
25+ makes is shown once.
26+5. **Add webhook**. g1t sends it a `ping` at once, so you can see straight
27+ away whether your receiver answers.
28+
29+## What is sent
30+
31+Each delivery is a `POST` with a JSON body:
32+
33+```json
34+{
35+ "id": "evt_01m401tecce9h8wt7k3ayvs72n",
36+ "type": "comment.created",
37+ "time": "2026-10-03T04:54:37.708Z",
38+ "workspace": "acme",
39+ "repository": { "id": "rep_cf985171afeee00a62a1c0acb0", "fullName": "acme/web" },
40+ "actor": { "id": "usr_b51a1a09fc53e9471cbe1426b7", "username": "ada" },
41+ "data": { "commentId": "cmt_01m401te9eekb92kccgwhympr4", "number": 3, "repoId": "rep_cf985171afeee00a62a1c0acb0" }
42+}
43+```
44+
45+`data` holds what the event is about: ids and numbers to fetch the rest with
46+the [API](/reference/api/). `actor` is null for something g1t did by
47+itself.
48+
49+With these headers:
50+
51+| Header | |
52+| --- | --- |
53+| `X-G1t-Event` | The event type, such as `pull.merged`, or `ping`. |
54+| `X-G1t-Delivery` | The delivery's id. A redelivery has a new one. |
55+| `X-G1t-Hook` | The webhook's id. |
56+| `X-G1t-Signature-256` | `sha256=` and the HMAC-SHA256 of the body, keyed with the secret. |
57+| `User-Agent` | `g1t-webhooks/1` |
58+
59+## Events
60+
61+| Event | When |
62+| --- | --- |
63+| `git.push` | A branch moved. `data.ref`, `data.after`, `data.defaultBranch`. |
64+| `repo.created`, `repo.forked` | A repository was made, or forked for a pull request. |
65+| `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number`; on close, `data.reason` and `data.resolvedBy`. |
66+| `comment.created` | A comment or review on an issue or pull request. |
67+| `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. `data.number`, `data.issue`; on merge, `data.commit`. |
68+| `checks.completed` | An issue's acceptance checks finished on a pull request. `data.status` is `passed`, `failed` or `errored`. |
69+| `review.completed` | A g1t agent reviewed a pull request. `data.verdict`. |
70+| `queue.changed` | The merge queue gained, lost or settled an entry. |
71+| `session.appended` | An agent's session grew. Busy: choose it only if you need it. |
72+
73+## Check the signature
74+
75+Compute the HMAC-SHA256 of the raw body with your secret and compare it to
76+`X-G1t-Signature-256` in constant time, before you parse the body.
77+
78+```js
79+// Node.js
80+import { createHmac, timingSafeEqual } from "node:crypto";
81+
82+function fromG1t(rawBody, signature, secret) {
83+ const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
84+ return signature?.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
85+}
86+```
87+
88+```python
89+# Python
90+import hashlib, hmac
91+
92+def from_g1t(raw_body: bytes, signature: str, secret: str) -> bool:
93+ expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
94+ return hmac.compare_digest(signature or "", expected)
95+```
96+
97+## Answering, and retries
98+
99+Answer with any 2xx within 10 seconds. Do slow work after answering.
100+
101+A delivery that gets anything else, or no answer, is tried again after 1
102+minute, 5 minutes, 30 minutes, 2 hours and 5 hours: six attempts over
103+about seven and a half hours. Then it is marked failed. Pausing or removing
104+a webhook stops its retries.
105+
106+An event is delivered to a webhook once. Use `X-G1t-Delivery`, or the
107+event's `id`, to ignore one you have already handled.
108+
109+## The delivery log
110+
111+Each webhook keeps its deliveries for 14 days. Open **Deliveries** to see,
112+for each one, the request g1t sent, the status and body your receiver
113+answered with, how long it took, and when it will be tried again.
114+**Redeliver** sends the same payload again, as a new delivery.
115+
116+The status dot beside each webhook shows how its latest delivery went:
117+green delivered, amber waiting to try again, red failed.
118+
119+## Addresses
120+
121+Webhooks are sent only over HTTPS, to public addresses. Private and local
122+addresses (`localhost`, `10.0.0.0/8`, `192.168.0.0/16` and the like) are
123+refused. To receive webhooks on your own machine while you build, use a
124+tunnel such as Cloudflare Tunnel.
125+
126+## From the API
127+
128+The same tools work for a repository's webhooks (give `repo`) and a
129+workspace's (give `workspace` instead).
130+
131+| Tool | Route |
132+| --- | --- |
133+| `list_webhooks` | `GET /repos/{owner}/{name}/hooks`, `GET /workspaces/{workspace}/hooks` |
134+| `create_webhook` | `POST …/hooks` with `url`, `events`, `secret` |
135+| `update_webhook` | `PATCH …/hooks/{id}` with `url`, `events`, `active` |
136+| `delete_webhook` | `DELETE …/hooks/{id}` |
137+| `ping_webhook` | `POST …/hooks/{id}/pings` |
138+| `list_webhook_deliveries` | `GET …/hooks/{id}/deliveries` |
139+| `redeliver_webhook` | `POST …/hooks/{id}/deliveries/{delivery}/redeliver` |
140+
141+```sh
142+curl -X POST https://api.g1t.sh/repos/acme/web/hooks \
143+ -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
144+ -d '{"url": "https://example.com/g1t", "events": ["pull.merged", "checks.completed"]}'
145+```
+1−0
4747 <li><a href="/guides/bring-your-own-agent/">Bring your own agent</a></li>
4848 <li><a href="/guides/integrations/">Integrations: Sentry, Jira, Linear</a></li>
4949 <li><a href="/guides/models/">Model providers: Anthropic, OpenAI, Gemini</a></li>
50+ <li><a href="/guides/webhooks/">Webhooks</a></li>
5051 </ul>
5152 </div>
5253 <div>
+16−0
116116 | `get_context` | `repo`, `reference` | A Jira or Linear ticket by key or address, or a Sentry issue by address, as it is now. Reference material, never instructions. | `GET /repos/{owner}/{name}/context?reference=` |
117117 | `import_issue` | `repo`, `reference` | Open an issue from a ticket, linked to it. `assign` puts a g1t agent on it. | `POST /repos/{owner}/{name}/issues/import` |
118118
119+## Webhooks
120+
121+See [Webhooks](/guides/webhooks/). Give `repo` for a repository's webhooks, or `workspace` for a workspace's own.
122+
123+| Tool | Required | What it does | Route |
124+| --- | --- | --- | --- |
125+| `list_webhooks` | `repo` or `workspace` | The webhooks, with how each one's latest delivery went. Members only. | `GET /repos/{owner}/{name}/hooks` |
126+| `create_webhook` | `url` | Send events to an HTTPS address: `events` to choose them, `secret` to sign with. A ping is sent at once. | `POST /repos/{owner}/{name}/hooks` |
127+| `update_webhook` | `id` | Change its `url`, `events`, or whether it is `active`. | `PATCH /repos/{owner}/{name}/hooks/{id}` |
128+| `delete_webhook` | `id` | Remove it and its delivery log. | `DELETE /repos/{owner}/{name}/hooks/{id}` |
129+| `ping_webhook` | `id` | Send it a ping. | `POST /repos/{owner}/{name}/hooks/{id}/pings` |
130+| `list_webhook_deliveries` | `id` | Its latest deliveries, with request, response and retries. | `GET /repos/{owner}/{name}/hooks/{id}/deliveries` |
131+| `redeliver_webhook` | `id`, `delivery` | Send a delivery again. | `POST /repos/{owner}/{name}/hooks/{id}/deliveries/{delivery}/redeliver` |
132+
133+Each has a workspace route too, under `/workspaces/{workspace}/hooks`.
134+
119135 ## Messages
120136
121137 | Tool | Required | What it does | Route |
+17−0
1+import { Settings, Webhook } from "lucide-react";
2+
3+import { TabLink } from "./ui";
4+
5+/** The parts of a repository's settings. */
6+export function RepoSettingsTabs({ base }: { base: string }) {
7+ return (
8+ <nav className="mb-8 flex gap-x-6 border-b border-line">
9+ <TabLink to={`${base}/settings`} end icon={<Settings size={15} />}>
10+ General
11+ </TabLink>
12+ <TabLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
13+ Webhooks
14+ </TabLink>
15+ </nav>
16+ );
17+}
+6−1
2525 Plug,
2626 Settings,
2727 Users,
28+ Webhook,
2829 X,
2930 } from "lucide-react";
3031 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
242243 }
243244
244245 /** A workspace's settings pages, which the sidebar slides over to. */
245−const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations)(\/|$)/;
246+const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks)(\/|$)/;
246247
247248 /**
248249 * The sidebar's menus sit side by side on one track, and the track slides:
283284 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
284285 Integrations
285286 </SidebarLink>
287+ <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
288+ Webhooks
289+ </SidebarLink>
286290 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
287291 Access tokens
288292 </SidebarLink>
512516 usage: "Usage",
513517 billing: "Billing",
514518 integrations: "Integrations",
519+ webhooks: "Webhooks",
515520 tree: "Code",
516521 blob: "Code",
517522 };
+302−0
1+/**
2+ * A repository's or a workspace's webhooks: the addresses events are sent
3+ * to, adding one, and every delivery with what was sent and what came back.
4+ */
5+import { ChevronRight, Pause, Play, RotateCw, Send, Trash2, Webhook } from "lucide-react";
6+import { useState } from "react";
7+import { Form, Link, useNavigation } from "react-router";
8+
9+import { EVENT_TYPES, type Hook, type HookDelivery } from "@g1t/contracts";
10+
11+import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server";
12+import { Button, CopyLine, EmptyState, ErrorText, Field, Input, TimeAgo } from "./ui";
13+
14+/** The events, in groups people recognise. */
15+const GROUPS: { title: string; events: string[] }[] = [
16+ { title: "Code", events: ["git.push", "repo.created", "repo.forked"] },
17+ {
18+ title: "Issues",
19+ events: ["issue.opened", "issue.updated", "issue.assigned", "issue.closed", "issue.reopened", "comment.created"],
20+ },
21+ {
22+ title: "Pull requests",
23+ events: ["pull.opened", "pull.ready", "pull.updated", "pull.merge_requested", "pull.merged", "pull.closed"],
24+ },
25+ { title: "Checks, reviews and the queue", events: ["checks.completed", "review.completed", "queue.changed"] },
26+ { title: "Agents", events: ["session.appended"] },
27+];
28+
29+function StatusDot({ status }: { status: string | null }) {
30+ const color =
31+ status === "delivered" ? "bg-accent" : status === "failed" ? "bg-danger" : status === "pending" ? "bg-warn" : "bg-faint";
32+ return <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${color}`} />;
33+}
34+
35+function describeEvents(events: string[]): string {
36+ if (events.includes("*")) return "Every event";
37+ return events.length === 1 ? events[0] : `${events.length} events`;
38+}
39+
40+/** JSON, indented for reading, or the text as it is. */
41+function pretty(text: string | null): string {
42+ if (!text) return "";
43+ try {
44+ return JSON.stringify(JSON.parse(text), null, 2);
45+ } catch {
46+ return text;
47+ }
48+}
49+
50+function DeliveryRow({ delivery, manage }: { delivery: HookDelivery; manage: boolean }) {
51+ const busy = useNavigation().state === "submitting";
52+ return (
53+ <details className="group border-t border-line first:border-t-0">
54+ <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40">
55+ <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
56+ <StatusDot status={delivery.status} />
57+ <span className="font-mono text-[0.8125rem]">{delivery.event}</span>
58+ <span className="min-w-0 truncate text-xs text-muted">
59+ {delivery.responseStatus != null
60+ ? `${delivery.responseStatus}`
61+ : delivery.error
62+ ? delivery.error
63+ : "Not yet sent"}
64+ {delivery.attempts > 1 && ` · ${delivery.attempts} attempts`}
65+ {delivery.status === "pending" && delivery.nextAttemptAt && " · will try again"}
66+ </span>
67+ <span className="ml-auto shrink-0 font-mono text-xs text-faint">
68+ {delivery.durationMs != null && `${delivery.durationMs}ms · `}
69+ <TimeAgo at={delivery.createdAt} />
70+ </span>
71+ </summary>
72+ <div className="grid gap-4 border-t border-line bg-bg/40 p-4 lg:grid-cols-2">
73+ <div className="min-w-0">
74+ <p className="mb-1.5 text-xs font-medium text-muted">Request</p>
75+ <pre className="max-h-80 overflow-auto rounded-lg bg-surface p-3 font-mono text-xs ring-1 ring-line">
76+ <code>{pretty(delivery.payload)}</code>
77+ </pre>
78+ </div>
79+ <div className="min-w-0">
80+ <p className="mb-1.5 text-xs font-medium text-muted">
81+ Response{delivery.responseStatus != null && ` · ${delivery.responseStatus}`}
82+ </p>
83+ <pre className="max-h-80 overflow-auto rounded-lg bg-surface p-3 font-mono text-xs ring-1 ring-line">
84+ <code>{delivery.error ?? (pretty(delivery.responseBody) || "No body.")}</code>
85+ </pre>
86+ {manage && (
87+ <Form method="post" className="mt-3">
88+ <input type="hidden" name="intent" value="redeliver" />
89+ <input type="hidden" name="delivery" value={delivery.id} />
90+ <Button type="submit" variant="quiet" disabled={busy}>
91+ <RotateCw size={14} />
92+ Redeliver
93+ </Button>
94+ </Form>
95+ )}
96+ </div>
97+ </div>
98+ </details>
99+ );
100+}
101+
102+function HookRow({ hook, open, deliveries, manage }: { hook: Hook; open: boolean; deliveries: HookDelivery[]; manage: boolean }) {
103+ const busy = useNavigation().state === "submitting";
104+ return (
105+ <li className="border-t border-line first:border-t-0">
106+ <div className="flex items-center gap-3 px-4 py-3">
107+ <StatusDot status={hook.active ? hook.lastStatus : null} />
108+ <div className="min-w-0 grow">
109+ <p className="truncate font-mono text-[0.8125rem]">{hook.url}</p>
110+ <p className="truncate text-xs text-muted">
111+ {describeEvents(hook.events)}
112+ {!hook.active && " · paused"}
113+ {hook.lastDeliveredAt && (
114+ <>
115+ {" · last sent "}
116+ <TimeAgo at={hook.lastDeliveredAt} />
117+ </>
118+ )}
119+ {` · secret ${hook.secretHint}`}
120+ </p>
121+ </div>
122+ <Link
123+ to={open ? "?" : `?hook=${hook.id}`}
124+ preventScrollReset
125+ className="shrink-0 rounded-md px-2.5 py-1.5 text-xs text-muted transition-colors hover:bg-raised hover:text-fg"
126+ >
127+ {open ? "Hide deliveries" : "Deliveries"}
128+ </Link>
129+ {manage && (
130+ <Form method="post" className="flex shrink-0 gap-1">
131+ <input type="hidden" name="id" value={hook.id} />
132+ <IconButton intent="ping" label="Send a ping" disabled={busy}>
133+ <Send size={14} />
134+ </IconButton>
135+ <input type="hidden" name="active" value={hook.active ? "false" : "true"} />
136+ <IconButton intent="toggle" label={hook.active ? "Pause" : "Resume"} disabled={busy}>
137+ {hook.active ? <Pause size={14} /> : <Play size={14} />}
138+ </IconButton>
139+ <IconButton intent="delete" label="Delete" disabled={busy}>
140+ <Trash2 size={14} />
141+ </IconButton>
142+ </Form>
143+ )}
144+ </div>
145+ {open && (
146+ <div className="mx-4 mb-4 overflow-hidden rounded-lg border border-line">
147+ {deliveries.length === 0 ? (
148+ <p className="px-4 py-3 text-sm text-muted">Nothing sent yet.</p>
149+ ) : (
150+ deliveries.map((delivery) => <DeliveryRow key={delivery.id} delivery={delivery} manage={manage} />)
151+ )}
152+ </div>
153+ )}
154+ </li>
155+ );
156+}
157+
158+function IconButton({ intent, label, disabled, children }: { intent: string; label: string; disabled: boolean; children: React.ReactNode }) {
159+ return (
160+ <button
161+ type="submit"
162+ name="intent"
163+ value={intent}
164+ title={label}
165+ aria-label={label}
166+ disabled={disabled}
167+ className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
168+ >
169+ {children}
170+ </button>
171+ );
172+}
173+
174+function AddWebhook() {
175+ const busy = useNavigation().state === "submitting";
176+ const [which, setWhich] = useState<"all" | "some">("all");
177+ return (
178+ <Form method="post" className="space-y-4 rounded-xl border border-line bg-surface p-5">
179+ <input type="hidden" name="intent" value="create" />
180+ <p className="font-medium">Add a webhook</p>
181+ <Field label="Payload URL" hint="An HTTPS address on the public internet. g1t sends it a ping as soon as you add it.">
182+ <Input name="url" type="url" required placeholder="https://example.com/g1t/events" />
183+ </Field>
184+ <fieldset>
185+ <legend className="mb-1.5 text-sm font-medium text-muted">Which events</legend>
186+ <div className="flex flex-wrap gap-4 text-sm">
187+ <label className="flex items-center gap-2">
188+ <input type="radio" name="which" value="all" checked={which === "all"} onChange={() => setWhich("all")} />
189+ Everything, including events added later
190+ </label>
191+ <label className="flex items-center gap-2">
192+ <input type="radio" name="which" value="some" checked={which === "some"} onChange={() => setWhich("some")} />
193+ Let me choose
194+ </label>
195+ </div>
196+ {which === "some" && (
197+ <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
198+ {GROUPS.map((group) => (
199+ <div key={group.title}>
200+ <p className="mb-1.5 text-xs font-medium text-faint">{group.title}</p>
201+ <div className="space-y-1">
202+ {group.events
203+ .filter((event) => (EVENT_TYPES as readonly string[]).includes(event))
204+ .map((event) => (
205+ <label key={event} className="flex items-center gap-2 font-mono text-[0.8125rem]">
206+ <input type="checkbox" name="event" value={event} />
207+ {event}
208+ </label>
209+ ))}
210+ </div>
211+ </div>
212+ ))}
213+ </div>
214+ )}
215+ </fieldset>
216+ <Field label="Secret" hint="Optional. Deliveries are signed with it. Leave it empty and g1t makes one, shown once.">
217+ <Input name="secret" type="password" autoComplete="off" />
218+ </Field>
219+ <Button type="submit" disabled={busy}>
220+ {busy ? "Adding…" : "Add webhook"}
221+ </Button>
222+ </Form>
223+ );
224+}
225+
226+export function WebhooksPanel({
227+ data,
228+ action,
229+ manage,
230+ scope,
231+}: {
232+ data: WebhooksData;
233+ action: WebhooksAction | undefined;
234+ manage: boolean;
235+ /** Says whose events these are. */
236+ scope: string;
237+}) {
238+ const created = action?.created;
239+ return (
240+ <div className="max-w-4xl space-y-6">
241+ {created && (
242+ <div className="space-y-3 rounded-xl border border-accent-dim/60 bg-accent/5 p-5 text-sm">
243+ <p className="font-medium">Added. g1t sent it a ping.</p>
244+ {created.secret ? (
245+ <>
246+ <p className="text-muted">
247+ Every delivery is signed with this secret in <code>X-G1t-Signature-256</code>. It is shown this once.
248+ </p>
249+ <CopyLine text={created.secret} />
250+ </>
251+ ) : (
252+ <p className="text-muted">Deliveries are signed with the secret you gave.</p>
253+ )}
254+ </div>
255+ )}
256+ {action?.sent && (
257+ <p className="text-sm text-muted">
258+ Sent {action.sent.event}:{" "}
259+ {action.sent.status === "delivered" ? (
260+ <span className="text-accent">delivered, {action.sent.responseStatus}</span>
261+ ) : (
262+ <span className="text-danger">{action.sent.error ?? `answered ${action.sent.responseStatus}`}</span>
263+ )}
264+ .
265+ </p>
266+ )}
267+ <ErrorText>{action?.error}</ErrorText>
268+
269+ {data.hooks.length === 0 ? (
270+ <EmptyState title="No webhooks yet">
271+ <span className="inline-flex items-center gap-1.5">
272+ <Webhook size={14} />
273+ Add one and {scope} sends its events to your address as they happen, signed, and retried until it answers.
274+ </span>
275+ </EmptyState>
276+ ) : (
277+ <ul className="overflow-hidden rounded-xl border border-line bg-surface">
278+ {data.hooks.map((hook) => (
279+ <HookRow
280+ key={hook.id}
281+ hook={hook}
282+ open={data.open === hook.id}
283+ deliveries={data.open === hook.id ? data.deliveries : []}
284+ manage={manage}
285+ />
286+ ))}
287+ </ul>
288+ )}
289+
290+ {manage && <AddWebhook />}
291+ <p className="text-xs text-faint">
292+ Each delivery is an HTTPS POST of JSON with <code>X-G1t-Event</code>, <code>X-G1t-Delivery</code> and{" "}
293+ <code>X-G1t-Signature-256</code>. One that is not answered with a 2xx is tried again after 1 minute, 5 minutes,
294+ 30 minutes, 2 hours and 5 hours.{" "}
295+ <a href="https://docs.g1t.sh/guides/webhooks/" className="text-muted underline underline-offset-4">
296+ How to check the signature
297+ </a>
298+ .
299+ </p>
300+ </div>
301+ );
302+}
+2−0
66 identityClient,
77 integrationsClient,
88 reposClient,
9+ webhooksClient,
910 workClient,
1011 } from "@g1t/contracts";
1112
1516 export const billing = billingClient(env.BILLING);
1617 export const events = eventsClient(env.EVENTS);
1718 export const integrations = integrationsClient(env.INTEGRATIONS);
19+export const webhooks = webhooksClient(env.WEBHOOKS);
+62−0
1+import type { Hook, HookDelivery, HookOwner, User, Viewer } from "@g1t/contracts";
2+
3+import { webhooks } from "./services.server";
4+import { unwrap } from "./session.server";
5+
6+/** What a webhooks page shows: the webhooks, and one's deliveries if it is open. */
7+export type WebhooksData = {
8+ hooks: Hook[];
9+ open: string | null;
10+ deliveries: HookDelivery[];
11+};
12+
13+export async function loadWebhooks(owner: HookOwner, viewer: Viewer, request: Request): Promise<WebhooksData> {
14+ const hooks = unwrap(await webhooks.list(viewer, owner));
15+ const wanted = new URL(request.url).searchParams.get("hook");
16+ const open = hooks.find((hook) => hook.id === wanted)?.id ?? null;
17+ const deliveries = open ? unwrap(await webhooks.deliveries(viewer, owner, open)) : [];
18+ return { hooks, open, deliveries };
19+}
20+
21+/** What a webhooks form asked for, done. */
22+export type WebhooksAction = {
23+ error?: string;
24+ /** A webhook just made, and its secret if g1t made it: shown once. */
25+ created?: { hook: Hook; secret: string | null };
26+ /** The latest delivery a ping or redelivery made. */
27+ sent?: HookDelivery;
28+};
29+
30+export async function actOnWebhooks(owner: HookOwner, actor: User, form: FormData): Promise<WebhooksAction> {
31+ const id = String(form.get("id") ?? "");
32+ switch (form.get("intent")) {
33+ case "create": {
34+ const events = form.get("which") === "some" ? form.getAll("event").map(String) : ["*"];
35+ if (events.length === 0) return { error: "Choose at least one event, or send everything." };
36+ const created = await webhooks.create(actor, owner, {
37+ url: String(form.get("url") ?? ""),
38+ events,
39+ secret: String(form.get("secret") ?? "").trim() || undefined,
40+ });
41+ return created.ok ? { created: created.value } : { error: created.error.message };
42+ }
43+ case "toggle": {
44+ const updated = await webhooks.update(actor, owner, id, { active: form.get("active") === "true" });
45+ return updated.ok ? {} : { error: updated.error.message };
46+ }
47+ case "delete": {
48+ const removed = await webhooks.delete(actor, owner, id);
49+ return removed.ok ? {} : { error: removed.error.message };
50+ }
51+ case "ping": {
52+ const sent = await webhooks.ping(actor, owner, id);
53+ return sent.ok ? { sent: sent.value } : { error: sent.error.message };
54+ }
55+ case "redeliver": {
56+ const sent = await webhooks.redeliver(actor, owner, String(form.get("delivery") ?? ""));
57+ return sent.ok ? { sent: sent.value } : { error: sent.error.message };
58+ }
59+ default:
60+ return { error: "Nothing to do." };
61+ }
62+}
+4−0
2323 route("-/usage", "routes/workspace/usage.tsx"),
2424 route("-/billing", "routes/workspace/billing.tsx"),
2525 route("-/integrations", "routes/workspace/integrations.tsx"),
26+ route("-/webhooks", "routes/workspace/webhooks.tsx"),
2627 route("-/settings", "routes/workspace/settings.tsx"),
2728 ]),
2829 // Why a line is the way it is, fetched by the blame view.
4344 route("plans", "routes/repo/plans.tsx"),
4445 route("plans/:id", "routes/repo/plan.tsx"),
4546 route("settings", "routes/repo/settings.tsx"),
47+ route("settings/webhooks", "routes/repo/webhooks.tsx"),
4648 ]),
49+ // Anything else: a 404 that still knows who is signed in.
50+ route("*", "routes/not-found.tsx"),
4751 ] satisfies RouteConfig;
+14−0
1+import { data } from "react-router";
2+
3+/**
4+ * Any address no other route matches. Throwing the 404 from a route, rather
5+ * than leaving the router to, means the root loader still runs, so someone
6+ * signed in sees the page in their own sidebar and not the public frame.
7+ */
8+export function loader() {
9+ throw data(null, { status: 404 });
10+}
11+
12+export default function NotFound() {
13+ return null;
14+}
+143−139
11 import type { ReactNode } from "react";
22 import { Form, data, useNavigation } from "react-router";
33
4+import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
45 import type { Route } from "./+types/settings";
56 import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
67 import { repos, work } from "../../lib/services.server";
148149 const saving = useNavigation().state === "submitting";
149150 const branch = repo.defaultBranch;
150151 return (
151− <Form method="post" className="max-w-4xl space-y-8">
152− <Section title="General" about="What the repository is and who can see it.">
153− <Field label="Description">
154− <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} />
155− </Field>
156− <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
157− <legend className="sr-only">Visibility</legend>
158− <label className="flex items-start gap-3">
159− <input
160− type="radio"
161− name="visibility"
162− value="public"
163− defaultChecked={!repo.isPrivate}
164− className="mt-1 accent-accent"
165− />
166− <span>
167− <span className="block font-medium">Public</span>
168− <span className="text-muted">Anyone can see and clone it.</span>
169− </span>
170− </label>
171− <label className="flex items-start gap-3">
172− <input
173− type="radio"
174− name="visibility"
175− value="private"
176− defaultChecked={repo.isPrivate}
177− className="mt-1 accent-accent"
178− />
179− <span>
180− <span className="block font-medium">Private</span>
181− <span className="text-muted">Only members of the workspace can see it.</span>
182− </span>
183− </label>
184− </fieldset>
185− </Section>
152+ <>
153+ <RepoSettingsTabs base={`/${repo.namespace}/${repo.name}`} />
154+ <Form method="post" className="max-w-4xl space-y-8">
155+ <Section title="General" about="What the repository is and who can see it.">
156+ <Field label="Description">
157+ <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} />
158+ </Field>
159+ <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
160+ <legend className="sr-only">Visibility</legend>
161+ <label className="flex items-start gap-3">
162+ <input
163+ type="radio"
164+ name="visibility"
165+ value="public"
166+ defaultChecked={!repo.isPrivate}
167+ className="mt-1 accent-accent"
168+ />
169+ <span>
170+ <span className="block font-medium">Public</span>
171+ <span className="text-muted">Anyone can see and clone it.</span>
172+ </span>
173+ </label>
174+ <label className="flex items-start gap-3">
175+ <input
176+ type="radio"
177+ name="visibility"
178+ value="private"
179+ defaultChecked={repo.isPrivate}
180+ className="mt-1 accent-accent"
181+ />
182+ <span>
183+ <span className="block font-medium">Private</span>
184+ <span className="text-muted">Only members of the workspace can see it.</span>
185+ </span>
186+ </label>
187+ </fieldset>
188+ </Section>
186189
187− <Section
188− title="Branch protection"
189− about={`Rules for ${branch}, the branch everything lands on.`}
190− >
191− <Toggle
192− name="protected"
193− on={repo.protected}
194− title={`Require a pull request to change ${branch}`}
190+ <Section
191+ title="Branch protection"
192+ about={`Rules for ${branch}, the branch everything lands on.`}
195193 >
196− Pushing to {branch} is refused, for members and agents alike, and git says why.
197− Changes reach it only by merging a pull request. The first push to an empty
198− repository is still allowed.
199− </Toggle>
200− <Choice
201− name="requiredApprovals"
202− value={settings.requiredApprovals}
203− title="Required approvals"
204− options={[
205− [0, "None"],
206− [1, "1"],
207− [2, "2"],
208− [3, "3"],
209− ]}
210− >
211− How many reviewers must approve before a pull request can merge. A reviewer
212− who has since asked for changes blocks it, and nobody approves their own.
213− </Choice>
214− <Toggle
215− name="countAgentApprovals"
216− on={settings.countAgentApprovals}
217− title="A g1t agent's approval counts"
218− >
219− With this off, required approvals have to come from people, and an agent's
220− review is advice.
221− </Toggle>
222− <Toggle
223− name="requireChecks"
224− on={!settings.allowIgnoringChecks}
225− title="Require acceptance checks to pass"
226− >
227− With this off, a member can choose to merge although the issue's checks
228− failed or have not finished. With it on, nobody can.
229− </Toggle>
230− <Toggle
231− name="requireUpToDate"
232− on={settings.requireUpToDate}
233− title="Require pull requests to be up to date before merging"
234− >
235− With this off, a pull request can be merged after {branch} has moved: g1t
236− brings it up to date as part of merging, and asks you only if there is a
237− conflict it cannot resolve. With it on, it has to catch up first and its
238− checks run again on the result, so what lands is exactly what was checked.
239− </Toggle>
240− <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue">
241− Merging adds a pull request to the queue instead of changing {branch} at once.
242− g1t tests it together with every pull request ahead of it, several
243− combinations at a time, and {branch} only ever moves to a combination whose
244− checks passed. One that fails leaves the queue and goes back to its author,
245− and the ones behind it are tested again without it.
246− </Toggle>
247− </Section>
194+ <Toggle
195+ name="protected"
196+ on={repo.protected}
197+ title={`Require a pull request to change ${branch}`}
198+ >
199+ Pushing to {branch} is refused, for members and agents alike, and git says why.
200+ Changes reach it only by merging a pull request. The first push to an empty
201+ repository is still allowed.
202+ </Toggle>
203+ <Choice
204+ name="requiredApprovals"
205+ value={settings.requiredApprovals}
206+ title="Required approvals"
207+ options={[
208+ [0, "None"],
209+ [1, "1"],
210+ [2, "2"],
211+ [3, "3"],
212+ ]}
213+ >
214+ How many reviewers must approve before a pull request can merge. A reviewer
215+ who has since asked for changes blocks it, and nobody approves their own.
216+ </Choice>
217+ <Toggle
218+ name="countAgentApprovals"
219+ on={settings.countAgentApprovals}
220+ title="A g1t agent's approval counts"
221+ >
222+ With this off, required approvals have to come from people, and an agent's
223+ review is advice.
224+ </Toggle>
225+ <Toggle
226+ name="requireChecks"
227+ on={!settings.allowIgnoringChecks}
228+ title="Require acceptance checks to pass"
229+ >
230+ With this off, a member can choose to merge although the issue's checks
231+ failed or have not finished. With it on, nobody can.
232+ </Toggle>
233+ <Toggle
234+ name="requireUpToDate"
235+ on={settings.requireUpToDate}
236+ title="Require pull requests to be up to date before merging"
237+ >
238+ With this off, a pull request can be merged after {branch} has moved: g1t
239+ brings it up to date as part of merging, and asks you only if there is a
240+ conflict it cannot resolve. With it on, it has to catch up first and its
241+ checks run again on the result, so what lands is exactly what was checked.
242+ </Toggle>
243+ <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue">
244+ Merging adds a pull request to the queue instead of changing {branch} at once.
245+ g1t tests it together with every pull request ahead of it, several
246+ combinations at a time, and {branch} only ever moves to a combination whose
247+ checks passed. One that fails leaves the queue and goes back to its author,
248+ and the ones behind it are tested again without it.
249+ </Toggle>
250+ </Section>
248251
249− <Section
250− title="g1t agents"
251− about="What happens to a pull request a g1t agent makes, from the moment it is ready."
252− >
253− <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent">
254− A different agent reads each change and posts comments on lines, a summary
255− and a verdict. If it asks for changes, the author is sent back to make them.
256− With this off, review is left to people.
257− </Toggle>
258− <Choice
259− name="maxRevisions"
260− value={settings.maxRevisions}
261− title="Revisions before asking you"
262− options={[
263− [0, "None"],
264− [1, "1"],
265− [2, "2"],
266− [3, "3"],
267− [5, "5"],
268− ]}
252+ <Section
253+ title="g1t agents"
254+ about="What happens to a pull request a g1t agent makes, from the moment it is ready."
269255 >
270− How many times an agent is sent back to fix failed checks or address a
271− review before g1t stops and the pull request says it needs you.
272− </Choice>
273− <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready">
274− A g1t agent's pull request lands without anyone pressing merge once every
275− rule above is met. With this off, it waits for a member. Pull requests from
276− people and from other agents always wait.
277− </Toggle>
278− </Section>
256+ <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent">
257+ A different agent reads each change and posts comments on lines, a summary
258+ and a verdict. If it asks for changes, the author is sent back to make them.
259+ With this off, review is left to people.
260+ </Toggle>
261+ <Choice
262+ name="maxRevisions"
263+ value={settings.maxRevisions}
264+ title="Revisions before asking you"
265+ options={[
266+ [0, "None"],
267+ [1, "1"],
268+ [2, "2"],
269+ [3, "3"],
270+ [5, "5"],
271+ ]}
272+ >
273+ How many times an agent is sent back to fix failed checks or address a
274+ review before g1t stops and the pull request says it needs you.
275+ </Choice>
276+ <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready">
277+ A g1t agent's pull request lands without anyone pressing merge once every
278+ rule above is met. With this off, it waits for a member. Pull requests from
279+ people and from other agents always wait.
280+ </Toggle>
281+ </Section>
279282
280− <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
281− <Button type="submit" disabled={saving}>
282− {saving ? "Saving…" : "Save settings"}
283− </Button>
284− {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
285− <ErrorText>{actionData?.error}</ErrorText>
286− {settings.updatedBy && settings.updatedAt && !actionData && (
287− <span className="text-xs text-faint">
288− Merge rules last changed by{" "}
289− <span className="font-mono">{settings.updatedBy}</span>{" "}
290− <TimeAgo at={settings.updatedAt} />
291− </span>
292− )}
293− </div>
294− </Form>
283+ <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
284+ <Button type="submit" disabled={saving}>
285+ {saving ? "Saving…" : "Save settings"}
286+ </Button>
287+ {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
288+ <ErrorText>{actionData?.error}</ErrorText>
289+ {settings.updatedBy && settings.updatedAt && !actionData && (
290+ <span className="text-xs text-faint">
291+ Merge rules last changed by{" "}
292+ <span className="font-mono">{settings.updatedBy}</span>{" "}
293+ <TimeAgo at={settings.updatedAt} />
294+ </span>
295+ )}
296+ </div>
297+ </Form>
298+ </>
295299 );
296300 }
+35−0
1+import type { Route } from "./+types/webhooks";
2+import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
3+import { WebhooksPanel } from "../../components/webhooks";
4+import { actOnWebhooks, loadWebhooks } from "../../lib/webhooks.server";
5+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
6+
7+export function meta({ params }: Route.MetaArgs) {
8+ return [{ title: `Webhooks · ${params.owner}/${params.repo} · g1t` }];
9+}
10+
11+function ownerOf(params: { owner: string; repo: string }) {
12+ return { workspace: params.owner.toLowerCase(), repo: { namespace: params.owner, name: params.repo } };
13+}
14+
15+export async function loader({ params, context, request }: Route.LoaderArgs) {
16+ const viewer = getViewer(context);
17+ // A repository's settings are its workspace's members' to see.
18+ if (!roleIn(viewer, params.owner)) throw new Response(null, { status: 404 });
19+ return loadWebhooks(ownerOf(params), viewer, request);
20+}
21+
22+export async function action({ request, params, context }: Route.ActionArgs) {
23+ assertSameOrigin(request);
24+ const user = requireUser(context, request);
25+ return actOnWebhooks(ownerOf(params), user, await request.formData());
26+}
27+
28+export default function RepoWebhooks({ loaderData, actionData, params }: Route.ComponentProps) {
29+ return (
30+ <div>
31+ <RepoSettingsTabs base={`/${params.owner}/${params.repo}`} />
32+ <WebhooksPanel data={loaderData} action={actionData} manage scope={`${params.owner}/${params.repo}`} />
33+ </div>
34+ );
35+}
+4−0
2929 },
3030 usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." },
3131 billing: { title: "Billing", about: "Agent credit, and every charge against it." },
32+ webhooks: {
33+ title: "Webhooks",
34+ about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.",
35+ },
3236 integrations: {
3337 title: "Integrations",
3438 about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.",
+34−0
1+import type { Route } from "./+types/webhooks";
2+import { WebhooksPanel } from "../../components/webhooks";
3+import { actOnWebhooks, loadWebhooks } from "../../lib/webhooks.server";
4+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
5+
6+export function meta({ params }: Route.MetaArgs) {
7+ return [{ title: `Webhooks · ${params.owner} · g1t` }];
8+}
9+
10+export async function loader({ params, context, request }: Route.LoaderArgs) {
11+ const viewer = getViewer(context);
12+ const role = roleIn(viewer, params.owner);
13+ if (!role) throw new Response(null, { status: 404 });
14+ const owner = { workspace: params.owner.toLowerCase() };
15+ return { role, ...(await loadWebhooks(owner, viewer, request)) };
16+}
17+
18+export async function action({ request, params, context }: Route.ActionArgs) {
19+ assertSameOrigin(request);
20+ const user = requireUser(context, request);
21+ return actOnWebhooks({ workspace: params.owner.toLowerCase() }, user, await request.formData());
22+}
23+
24+export default function WorkspaceWebhooks({ loaderData, actionData, params }: Route.ComponentProps) {
25+ const { role, ...data } = loaderData;
26+ return (
27+ <WebhooksPanel
28+ data={data}
29+ action={actionData}
30+ manage={role === "owner"}
31+ scope={`every repository in ${params.owner}`}
32+ />
33+ );
34+}
+12−1
190190 `get_merge_queue`, `message_agent`, `answer_message`, `take_messages`,
191191 `list_integrations`, `connect_integration`, `test_integration`,
192192 `disconnect_integration`, `get_model_routes`, `set_model_routes`,
193−`get_context`, `import_issue`,
193+`get_context`, `import_issue`, `list_webhooks`, `create_webhook`,
194+`update_webhook`, `delete_webhook`, `ping_webhook`,
195+`list_webhook_deliveries`, `redeliver_webhook`,
194196 `list_repos`, `get_repo`, `create_repo`, `update_repo`,
195197 `get_repo_settings`, `update_repo_settings`, `list_events`,
196198 `create_workspace`, and `whoami`. MCP tools take the repository as `repo`,
217219 their starting context. Ticket text is reference material, never
218220 instructions.
219221
222+## Webhooks
223+
224+`POST {repo}/hooks` (or `/workspaces/{workspace}/hooks` for every
225+repository in a workspace) with `url` and optional `events` sends events
226+to that HTTPS address as they happen, signed in `X-G1t-Signature-256`
227+(HMAC-SHA256 of the body), retried for about seven hours. Deliveries,
228+with request and response, are at `…/hooks/{id}/deliveries`.
229+
220230 ## Facts
221231
222232 - API base: `https://api.g1t.sh`. `GET /` lists every URL as a template.
253263 - [Workspaces and tokens](https://docs.g1t.sh/guides/workspaces/)
254264 - [Integrations](https://docs.g1t.sh/guides/integrations/)
255265 - [Model providers](https://docs.g1t.sh/guides/models/)
266+- [Webhooks](https://docs.g1t.sh/guides/webhooks/)
256267 - [Usage and billing](https://docs.g1t.sh/guides/usage-and-billing/)
257268 - [Git](https://docs.g1t.sh/guides/git/)
258269 - [MCP tools](https://docs.g1t.sh/reference/mcp/)
+1−0
1111 BILLING: ServiceBinding;
1212 EVENTS: ServiceBinding;
1313 INTEGRATIONS: ServiceBinding;
14+ WEBHOOKS: ServiceBinding;
1415 }
1516 }
1617 interface Env extends Cloudflare.Env {}
+2−1
1616 { "binding": "RUNNER", "service": "g1t-runner" },
1717 { "binding": "BILLING", "service": "g1t-billing" },
1818 { "binding": "EVENTS", "service": "g1t-events" },
19− { "binding": "INTEGRATIONS", "service": "g1t-integrations" }
19+ { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
20+ { "binding": "WEBHOOKS", "service": "g1t-webhooks" }
2021 ],
2122 "observability": { "enabled": true },
2223 "upload_source_maps": true
+1−0
1313 mod outcome;
1414 pub mod repos;
1515 pub mod time;
16+pub mod webhooks;
1617 pub mod work;
1718
1819 pub use ids::new_id;
+184−0
1+//! The webhooks service: events, delivered to the addresses a repository or
2+//! a workspace registers.
3+//!
4+//! Every event g1t publishes can be delivered: an HTTPS `POST` of JSON,
5+//! signed with the webhook's secret in `X-G1t-Signature-256`, and retried
6+//! with growing waits when the receiver does not answer with a 2xx. Each
7+//! delivery is kept, with what was sent and what came back, and can be sent
8+//! again.
9+//!
10+//! Mirrors `packages/contracts/src/webhooks.ts`.
11+
12+use serde::{Deserialize, Serialize};
13+
14+use crate::repos::RepoPath;
15+use crate::{User, Viewer};
16+
17+/// Every event a webhook can be sent, in the order people are shown them.
18+pub const EVENT_TYPES: [&str; 19] = [
19+ "git.push",
20+ "repo.created",
21+ "repo.forked",
22+ "issue.opened",
23+ "issue.updated",
24+ "issue.assigned",
25+ "issue.closed",
26+ "issue.reopened",
27+ "comment.created",
28+ "pull.opened",
29+ "pull.ready",
30+ "pull.updated",
31+ "pull.merge_requested",
32+ "pull.merged",
33+ "pull.closed",
34+ "checks.completed",
35+ "review.completed",
36+ "queue.changed",
37+ "session.appended",
38+];
39+
40+/// What a webhook belongs to.
41+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
42+#[serde(rename_all = "snake_case")]
43+pub enum HookScope {
44+ /// One repository's events.
45+ Repo,
46+ /// The events of every repository in a workspace.
47+ Workspace,
48+}
49+
50+#[derive(Clone, Debug, Serialize, Deserialize)]
51+#[serde(rename_all = "camelCase")]
52+pub struct Hook {
53+ pub id: String,
54+ pub scope: HookScope,
55+ pub workspace: String,
56+ /// For a repository's webhook: `owner/name`.
57+ pub repo: Option<String>,
58+ pub url: String,
59+ /// The event types it is sent, or `["*"]` for all.
60+ pub events: Vec<String>,
61+ pub active: bool,
62+ /// The last four characters of its secret.
63+ pub secret_hint: String,
64+ pub created_by: String,
65+ /// RFC 3339.
66+ pub created_at: String,
67+ /// How its latest delivery went: `delivered`, `pending` or `failed`.
68+ pub last_status: Option<String>,
69+ pub last_delivered_at: Option<String>,
70+}
71+
72+/// One event sent, or being sent, to a webhook.
73+#[derive(Clone, Debug, Serialize, Deserialize)]
74+#[serde(rename_all = "camelCase")]
75+pub struct HookDelivery {
76+ pub id: String,
77+ pub hook_id: String,
78+ /// The event's id, or empty for a ping.
79+ pub event_id: String,
80+ pub event: String,
81+ /// `pending` while it will be tried again, `delivered`, or `failed` once
82+ /// it has been tried as often as it will be.
83+ pub status: String,
84+ pub attempts: u32,
85+ /// The receiver's HTTP status, the last time it answered.
86+ pub response_status: Option<u16>,
87+ /// The start of what it answered.
88+ pub response_body: Option<String>,
89+ /// Why the last attempt failed, when the receiver could not be reached.
90+ pub error: Option<String>,
91+ pub duration_ms: Option<u32>,
92+ /// The JSON that was sent.
93+ pub payload: String,
94+ /// RFC 3339.
95+ pub created_at: String,
96+ pub delivered_at: Option<String>,
97+ pub next_attempt_at: Option<String>,
98+}
99+
100+/// Which webhooks a call is about: a repository's, or with `repo` left out,
101+/// the workspace's own.
102+#[derive(Clone, Debug, Serialize, Deserialize)]
103+pub struct HookOwner {
104+ pub workspace: String,
105+ #[serde(default)]
106+ pub repo: Option<RepoPath>,
107+}
108+
109+/// `list`. Returns `Outcome<Vec<Hook>>`. Members of the workspace only.
110+#[derive(Debug, Serialize, Deserialize)]
111+pub struct ListArgs {
112+ pub viewer: Viewer,
113+ #[serde(flatten)]
114+ pub owner: HookOwner,
115+}
116+
117+/// `create`. Returns `Outcome<CreatedHook>`. Members, for a repository's
118+/// webhooks; owners, for the workspace's.
119+#[derive(Debug, Serialize, Deserialize)]
120+pub struct CreateArgs {
121+ pub actor: User,
122+ #[serde(flatten)]
123+ pub owner: HookOwner,
124+ pub url: String,
125+ /// Event types, or `["*"]` for all. All when empty.
126+ #[serde(default)]
127+ pub events: Vec<String>,
128+ /// Made by g1t when left out.
129+ #[serde(default)]
130+ pub secret: Option<String>,
131+}
132+
133+#[derive(Clone, Debug, Serialize, Deserialize)]
134+pub struct CreatedHook {
135+ pub hook: Hook,
136+ /// The secret, when g1t made it: shown this once.
137+ pub secret: Option<String>,
138+}
139+
140+/// `update`: only the fields given change. Returns `Outcome<Hook>`.
141+#[derive(Debug, Serialize, Deserialize)]
142+pub struct UpdateArgs {
143+ pub actor: User,
144+ #[serde(flatten)]
145+ pub owner: HookOwner,
146+ pub id: String,
147+ #[serde(default)]
148+ pub url: Option<String>,
149+ #[serde(default)]
150+ pub events: Option<Vec<String>>,
151+ #[serde(default)]
152+ pub active: Option<bool>,
153+}
154+
155+/// `delete` (returns `Outcome<bool>`) and `ping` (sends a `ping` event and
156+/// returns `Outcome<HookDelivery>`).
157+#[derive(Debug, Serialize, Deserialize)]
158+pub struct HookArgs {
159+ pub actor: User,
160+ #[serde(flatten)]
161+ pub owner: HookOwner,
162+ pub id: String,
163+}
164+
165+/// `deliveries`: a webhook's latest deliveries, newest first. Returns
166+/// `Outcome<Vec<HookDelivery>>`.
167+#[derive(Debug, Serialize, Deserialize)]
168+pub struct DeliveriesArgs {
169+ pub viewer: Viewer,
170+ #[serde(flatten)]
171+ pub owner: HookOwner,
172+ pub id: String,
173+}
174+
175+/// `redeliver`: sends a delivery's payload again, as a new delivery.
176+/// Returns `Outcome<HookDelivery>`.
177+#[derive(Debug, Serialize, Deserialize)]
178+#[serde(rename_all = "camelCase")]
179+pub struct RedeliverArgs {
180+ pub actor: User,
181+ #[serde(flatten)]
182+ pub owner: HookOwner,
183+ pub delivery_id: String,
184+}
+14−0
1+[package]
2+name = "g1t-secrets"
3+version = "0.1.0"
4+edition.workspace = true
5+license.workspace = true
6+description = "Secrets at rest and signatures on the wire, shared by the services that keep or check them."
7+
8+[dependencies]
9+aes-gcm = "0.10"
10+base64 = "0.22"
11+getrandom = { version = "0.2", features = ["js"] }
12+hex = "0.4"
13+hmac = "0.12"
14+sha2 = "0.10"
+137−0
1+//! Secrets at rest, and the signatures put on what crosses between g1t
2+//! and outside systems.
3+//!
4+//! A secret is sealed with AES-256-GCM under its service's own key, with the
5+//! id of the row it belongs to as associated data, so a sealed value copied
6+//! onto another row does not open.
7+
8+use aes_gcm::aead::{Aead, KeyInit, Payload};
9+use aes_gcm::{Aes256Gcm, Nonce};
10+use base64::Engine;
11+use base64::engine::general_purpose::STANDARD;
12+use hmac::{Hmac, Mac};
13+use sha2::{Digest, Sha256};
14+
15+const VERSION: &str = "v1:";
16+
17+pub struct Sealer {
18+ cipher: Aes256Gcm,
19+}
20+
21+impl Sealer {
22+ /// From the service's key: 64 hex characters.
23+ pub fn new(key_hex: &str) -> Option<Sealer> {
24+ let key = hex::decode(key_hex.trim()).ok()?;
25+ (key.len() == 32).then(|| Sealer {
26+ cipher: Aes256Gcm::new_from_slice(&key).expect("a 32-byte key"),
27+ })
28+ }
29+
30+ pub fn seal(&self, plaintext: &str, bound_to: &str) -> String {
31+ let mut nonce = [0u8; 12];
32+ getrandom::getrandom(&mut nonce).expect("no source of randomness");
33+ let sealed = self
34+ .cipher
35+ .encrypt(
36+ Nonce::from_slice(&nonce),
37+ Payload {
38+ msg: plaintext.as_bytes(),
39+ aad: bound_to.as_bytes(),
40+ },
41+ )
42+ .expect("encrypting cannot fail");
43+ let mut out = nonce.to_vec();
44+ out.extend(sealed);
45+ format!("{VERSION}{}", STANDARD.encode(out))
46+ }
47+
48+ /// `None` when it was sealed under another key or for another row.
49+ pub fn open(&self, sealed: &str, bound_to: &str) -> Option<String> {
50+ let bytes = STANDARD.decode(sealed.strip_prefix(VERSION)?).ok()?;
51+ if bytes.len() < 12 {
52+ return None;
53+ }
54+ let (nonce, ciphertext) = bytes.split_at(12);
55+ let plain = self
56+ .cipher
57+ .decrypt(
58+ Nonce::from_slice(nonce),
59+ Payload {
60+ msg: ciphertext,
61+ aad: bound_to.as_bytes(),
62+ },
63+ )
64+ .ok()?;
65+ String::from_utf8(plain).ok()
66+ }
67+}
68+
69+pub fn sha256_hex(value: &str) -> String {
70+ hex::encode(Sha256::digest(value.as_bytes()))
71+}
72+
73+pub fn random_hex(bytes: usize) -> String {
74+ let mut buffer = vec![0u8; bytes];
75+ getrandom::getrandom(&mut buffer).expect("no source of randomness");
76+ hex::encode(buffer)
77+}
78+
79+pub fn hmac_sha256_hex(secret: &str, body: &str) -> String {
80+ let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret.as_bytes()).expect("any key length");
81+ mac.update(body.as_bytes());
82+ hex::encode(mac.finalize().into_bytes())
83+}
84+
85+/// Compares in time that does not depend on where they differ.
86+pub fn same(a: &str, b: &str) -> bool {
87+ a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
88+}
89+
90+/// Whether `signature` is `body` signed with `secret`: hex HMAC-SHA256,
91+/// optionally written `sha256=<hex>`.
92+pub fn signed(secret: &str, body: &str, signature: &str) -> bool {
93+ let given = signature.trim();
94+ let given = given.strip_prefix("sha256=").unwrap_or(given);
95+ same(&hmac_sha256_hex(secret, body), &given.to_ascii_lowercase())
96+}
97+
98+/// The last four characters, to tell keys apart without showing them.
99+pub fn hint(secret: &str) -> String {
100+ let tail: String = secret.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
101+ format!("…{tail}")
102+}
103+
104+#[cfg(test)]
105+mod tests {
106+ use super::*;
107+
108+ const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
109+
110+ #[test]
111+ fn a_sealed_secret_opens_only_for_its_own_row() {
112+ let sealer = Sealer::new(KEY).unwrap();
113+ let sealed = sealer.seal("sk-ant-secret", "con_1");
114+ assert!(!sealed.contains("sk-ant"));
115+ assert_eq!(sealer.open(&sealed, "con_1").as_deref(), Some("sk-ant-secret"));
116+ assert_eq!(sealer.open(&sealed, "con_2"), None);
117+ }
118+
119+ #[test]
120+ fn a_key_of_the_wrong_length_is_refused() {
121+ assert!(Sealer::new("abcd").is_none());
122+ }
123+
124+ #[test]
125+ fn signatures_are_checked_in_either_form() {
126+ let signature = hmac_sha256_hex("shh", "{\"a\":1}");
127+ assert!(signed("shh", "{\"a\":1}", &signature));
128+ assert!(signed("shh", "{\"a\":1}", &format!("sha256={signature}")));
129+ assert!(!signed("shh", "{\"a\":2}", &signature));
130+ assert!(!signed("other", "{\"a\":1}", &signature));
131+ }
132+
133+ #[test]
134+ fn a_hint_shows_only_the_end() {
135+ assert_eq!(hint("sk-ant-api03-abcdef"), "…cdef");
136+ }
137+}
+14−0
22 import type { EventsApi } from "./events";
33 import type { IdentityApi } from "./identity";
44 import type { IntegrationsApi } from "./integrations";
5+import type { WebhooksApi } from "./webhooks";
56 import type { ReposApi } from "./repos";
67 import type { WorkApi } from "./work";
78
215216 setRoutes: (actor, workspace, routes) => call("set_routes", { actor, workspace, routes }),
216217 };
217218 }
219+
220+export function webhooksClient(service: ServiceBinding): WebhooksApi {
221+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
222+ return {
223+ list: (viewer, owner) => call("list", { viewer, ...owner }),
224+ create: (actor, owner, input) => call("create", { actor, ...owner, ...input }),
225+ update: (actor, owner, id, input) => call("update", { actor, ...owner, id, ...input }),
226+ delete: (actor, owner, id) => call("delete", { actor, ...owner, id }),
227+ ping: (actor, owner, id) => call("ping", { actor, ...owner, id }),
228+ deliveries: (viewer, owner, id) => call("deliveries", { viewer, ...owner, id }),
229+ redeliver: (actor, owner, deliveryId) => call("redeliver", { actor, ...owner, deliveryId }),
230+ };
231+}
+1−0
99 export * from "./repos";
1010 export * from "./result";
1111 export * from "./runner";
12+export * from "./webhooks";
1213 export * from "./work";
+83−0
1+import type { User, Viewer } from "./identity";
2+import type { RepoPath } from "./repos";
3+import type { Result } from "./result";
4+
5+/**
6+ * Events, delivered to the addresses a repository or a workspace registers.
7+ * Mirrors `crates/contracts/src/webhooks.rs`.
8+ */
9+
10+/** Every event a webhook can be sent, in the order people are shown them. */
11+export const EVENT_TYPES = [
12+ "git.push",
13+ "repo.created",
14+ "repo.forked",
15+ "issue.opened",
16+ "issue.updated",
17+ "issue.assigned",
18+ "issue.closed",
19+ "issue.reopened",
20+ "comment.created",
21+ "pull.opened",
22+ "pull.ready",
23+ "pull.updated",
24+ "pull.merge_requested",
25+ "pull.merged",
26+ "pull.closed",
27+ "checks.completed",
28+ "review.completed",
29+ "queue.changed",
30+ "session.appended",
31+] as const;
32+
33+export type Hook = {
34+ id: string;
35+ scope: "repo" | "workspace";
36+ workspace: string;
37+ /** For a repository's webhook: `owner/name`. */
38+ repo: string | null;
39+ url: string;
40+ /** Event types, or `["*"]` for all. */
41+ events: string[];
42+ active: boolean;
43+ secretHint: string;
44+ createdBy: string;
45+ createdAt: string;
46+ lastStatus: "delivered" | "pending" | "failed" | null;
47+ lastDeliveredAt: string | null;
48+};
49+
50+export type HookDelivery = {
51+ id: string;
52+ hookId: string;
53+ eventId: string;
54+ event: string;
55+ status: "pending" | "delivered" | "failed";
56+ attempts: number;
57+ responseStatus: number | null;
58+ responseBody: string | null;
59+ error: string | null;
60+ durationMs: number | null;
61+ /** The JSON that was sent. */
62+ payload: string;
63+ createdAt: string;
64+ deliveredAt: string | null;
65+ nextAttemptAt: string | null;
66+};
67+
68+/** A repository's webhooks, or with `repo` left out, the workspace's own. */
69+export type HookOwner = { workspace: string; repo?: RepoPath };
70+
71+export interface WebhooksApi {
72+ list(viewer: Viewer, owner: HookOwner): Promise<Result<Hook[]>>;
73+ create(
74+ actor: User,
75+ owner: HookOwner,
76+ input: { url: string; events?: string[]; secret?: string },
77+ ): Promise<Result<{ hook: Hook; secret: string | null }>>;
78+ update(actor: User, owner: HookOwner, id: string, input: { url?: string; events?: string[]; active?: boolean }): Promise<Result<Hook>>;
79+ delete(actor: User, owner: HookOwner, id: string): Promise<Result<boolean>>;
80+ ping(actor: User, owner: HookOwner, id: string): Promise<Result<HookDelivery>>;
81+ deliveries(viewer: Viewer, owner: HookOwner, id: string): Promise<Result<HookDelivery[]>>;
82+ redeliver(actor: User, owner: HookOwner, deliveryId: string): Promise<Result<HookDelivery>>;
83+}
+2−1
2525 // every event.
2626 { "binding": "SUBSCRIBER_WORK", "queue": "g1t-events-work" },
2727 { "binding": "SUBSCRIBER_RUNNER", "queue": "g1t-events-runner" },
28− { "binding": "SUBSCRIBER_INTEGRATIONS", "queue": "g1t-events-integrations" }
28+ { "binding": "SUBSCRIBER_INTEGRATIONS", "queue": "g1t-events-integrations" },
29+ { "binding": "SUBSCRIBER_WEBHOOKS", "queue": "g1t-events-webhooks" }
2930 ],
3031 "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1 }]
3132 },
+1−2
1111 [dependencies]
1212 g1t-contracts.workspace = true
1313 g1t-kit.workspace = true
14+g1t-secrets.workspace = true
1415 serde.workspace = true
1516 serde_json.workspace = true
1617 worker.workspace = true
17−aes-gcm = "0.10"
1818 base64 = "0.22"
1919 getrandom = { version = "0.2", features = ["js"] }
2020 hex = "0.4"
21−hmac = "0.12"
2221 sha2 = "0.10"
+1−1
44
55 use serde_json::Value;
66
7−use crate::crypto;
7+use g1t_secrets as crypto;
88
99 /// What an alert asks g1t to do.
1010 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+0−137
1−//! Secrets at rest, and the signatures outside systems put on what they
2−//! send.
3−//!
4−//! A connection's secrets are sealed with AES-256-GCM under the service's
5−//! own key, with the connection's id as associated data, so a sealed value
6−//! copied onto another row does not open.
7−
8−use aes_gcm::aead::{Aead, KeyInit, Payload};
9−use aes_gcm::{Aes256Gcm, Nonce};
10−use base64::Engine;
11−use base64::engine::general_purpose::STANDARD;
12−use hmac::{Hmac, Mac};
13−use sha2::{Digest, Sha256};
14−
15−const VERSION: &str = "v1:";
16−
17−pub struct Sealer {
18− cipher: Aes256Gcm,
19−}
20−
21−impl Sealer {
22− /// From the service's key: 64 hex characters.
23− pub fn new(key_hex: &str) -> Option<Sealer> {
24− let key = hex::decode(key_hex.trim()).ok()?;
25− (key.len() == 32).then(|| Sealer {
26− cipher: Aes256Gcm::new_from_slice(&key).expect("a 32-byte key"),
27− })
28− }
29−
30− pub fn seal(&self, plaintext: &str, bound_to: &str) -> String {
31− let mut nonce = [0u8; 12];
32− getrandom::getrandom(&mut nonce).expect("no source of randomness");
33− let sealed = self
34− .cipher
35− .encrypt(
36− Nonce::from_slice(&nonce),
37− Payload {
38− msg: plaintext.as_bytes(),
39− aad: bound_to.as_bytes(),
40− },
41− )
42− .expect("encrypting cannot fail");
43− let mut out = nonce.to_vec();
44− out.extend(sealed);
45− format!("{VERSION}{}", STANDARD.encode(out))
46− }
47−
48− /// `None` when it was sealed under another key or for another row.
49− pub fn open(&self, sealed: &str, bound_to: &str) -> Option<String> {
50− let bytes = STANDARD.decode(sealed.strip_prefix(VERSION)?).ok()?;
51− if bytes.len() < 12 {
52− return None;
53− }
54− let (nonce, ciphertext) = bytes.split_at(12);
55− let plain = self
56− .cipher
57− .decrypt(
58− Nonce::from_slice(nonce),
59− Payload {
60− msg: ciphertext,
61− aad: bound_to.as_bytes(),
62− },
63− )
64− .ok()?;
65− String::from_utf8(plain).ok()
66− }
67−}
68−
69−pub fn sha256_hex(value: &str) -> String {
70− hex::encode(Sha256::digest(value.as_bytes()))
71−}
72−
73−pub fn random_hex(bytes: usize) -> String {
74− let mut buffer = vec![0u8; bytes];
75− getrandom::getrandom(&mut buffer).expect("no source of randomness");
76− hex::encode(buffer)
77−}
78−
79−pub fn hmac_sha256_hex(secret: &str, body: &str) -> String {
80− let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret.as_bytes()).expect("any key length");
81− mac.update(body.as_bytes());
82− hex::encode(mac.finalize().into_bytes())
83−}
84−
85−/// Compares in time that does not depend on where they differ.
86−pub fn same(a: &str, b: &str) -> bool {
87− a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
88−}
89−
90−/// Whether `signature` is `body` signed with `secret`: hex HMAC-SHA256,
91−/// optionally written `sha256=<hex>`.
92−pub fn signed(secret: &str, body: &str, signature: &str) -> bool {
93− let given = signature.trim();
94− let given = given.strip_prefix("sha256=").unwrap_or(given);
95− same(&hmac_sha256_hex(secret, body), &given.to_ascii_lowercase())
96−}
97−
98−/// The last four characters, to tell keys apart without showing them.
99−pub fn hint(secret: &str) -> String {
100− let tail: String = secret.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
101− format!("…{tail}")
102−}
103−
104−#[cfg(test)]
105−mod tests {
106− use super::*;
107−
108− const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
109−
110− #[test]
111− fn a_sealed_secret_opens_only_for_its_own_row() {
112− let sealer = Sealer::new(KEY).unwrap();
113− let sealed = sealer.seal("sk-ant-secret", "con_1");
114− assert!(!sealed.contains("sk-ant"));
115− assert_eq!(sealer.open(&sealed, "con_1").as_deref(), Some("sk-ant-secret"));
116− assert_eq!(sealer.open(&sealed, "con_2"), None);
117− }
118−
119− #[test]
120− fn a_key_of_the_wrong_length_is_refused() {
121− assert!(Sealer::new("abcd").is_none());
122− }
123−
124− #[test]
125− fn signatures_are_checked_in_either_form() {
126− let signature = hmac_sha256_hex("shh", "{\"a\":1}");
127− assert!(signed("shh", "{\"a\":1}", &signature));
128− assert!(signed("shh", "{\"a\":1}", &format!("sha256={signature}")));
129− assert!(!signed("shh", "{\"a\":2}", &signature));
130− assert!(!signed("other", "{\"a\":1}", &signature));
131− }
132−
133− #[test]
134− fn a_hint_shows_only_the_end() {
135− assert_eq!(hint("sk-ant-api03-abcdef"), "…cdef");
136− }
137−}
+1−2
99 //! the sender give up and send it again.
1010
1111 mod alerts;
12−mod crypto;
1312 mod http;
1413 mod models;
1514 mod refs;
3029 use worker::{Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, event};
3130
3231 use alerts::{Action, Signal};
33−use crypto::Sealer;
32+use g1t_secrets::{self as crypto, Sealer};
3433 use refs::Reference;
3534
3635 /// How long a run's model token works.
+18−0
1+[package]
2+name = "g1t-webhooks"
3+version = "0.1.0"
4+edition.workspace = true
5+license.workspace = true
6+description = "Events, delivered to the addresses repositories and workspaces register."
7+
8+[lib]
9+crate-type = ["cdylib"]
10+
11+[dependencies]
12+g1t-contracts.workspace = true
13+g1t-kit.workspace = true
14+g1t-secrets.workspace = true
15+serde.workspace = true
16+serde_json.workspace = true
17+worker.workspace = true
18+futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
+58−0
1+-- Webhooks, and every delivery made to them. Every timestamp is RFC 3339 UTC.
2+
3+CREATE TABLE hooks (
4+ id TEXT PRIMARY KEY,
5+ -- repo or workspace.
6+ scope TEXT NOT NULL,
7+ -- The workspace's slug.
8+ workspace TEXT NOT NULL,
9+ -- For a repository's webhook: its id, and owner/name.
10+ repo_id TEXT,
11+ repo TEXT,
12+ url TEXT NOT NULL,
13+ -- The event types it is sent, as a JSON array; ["*"] for all.
14+ events TEXT NOT NULL,
15+ active INTEGER NOT NULL DEFAULT 1,
16+ -- The signing secret, sealed with AES-256-GCM under the service's key
17+ -- and bound to the hook's id.
18+ secret TEXT NOT NULL,
19+ secret_hint TEXT NOT NULL,
20+ created_by TEXT NOT NULL,
21+ created_at TEXT NOT NULL,
22+ last_status TEXT,
23+ last_delivered_at TEXT
24+);
25+CREATE INDEX hooks_by_repo ON hooks (repo_id);
26+CREATE INDEX hooks_by_workspace ON hooks (workspace, scope);
27+
28+CREATE TABLE deliveries (
29+ id TEXT PRIMARY KEY,
30+ hook_id TEXT NOT NULL,
31+ -- The event delivered, or empty for a ping or a redelivery.
32+ event_id TEXT NOT NULL,
33+ event TEXT NOT NULL,
34+ -- The JSON sent.
35+ payload TEXT NOT NULL,
36+ -- pending, delivered or failed.
37+ status TEXT NOT NULL,
38+ attempts INTEGER NOT NULL DEFAULT 0,
39+ response_status INTEGER,
40+ response_body TEXT,
41+ error TEXT,
42+ duration_ms INTEGER,
43+ created_at TEXT NOT NULL,
44+ delivered_at TEXT,
45+ next_attempt_at TEXT
46+);
47+CREATE INDEX deliveries_by_hook ON deliveries (hook_id, id);
48+CREATE INDEX deliveries_due ON deliveries (status, next_attempt_at);
49+-- An event is delivered to a webhook once, however often the bus repeats it.
50+CREATE UNIQUE INDEX deliveries_once ON deliveries (hook_id, event_id) WHERE event_id <> '';
51+
52+-- Which workspace a repository is in, so a workspace's webhooks find its
53+-- repositories' events. Filled from repo.created, and on demand.
54+CREATE TABLE repo_names (
55+ repo_id TEXT PRIMARY KEY,
56+ namespace TEXT NOT NULL,
57+ name TEXT NOT NULL
58+);
+154−0
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
40+/// repository, the workspace and whoever caused it named.
41+pub fn payload(event: &Event, workspace: &str, repo: Option<(&str, &str)>, actor_name: Option<&str>) -> Value {
42+ json!({
43+ "id": event.id,
44+ "type": event.kind,
45+ "time": event.time,
46+ "workspace": workspace,
47+ "repository": repo.map(|(id, full_name)| json!({ "id": id, "fullName": full_name })),
48+ "actor": event.actor.as_ref().map(|id| json!({ "id": id, "username": actor_name })),
49+ "data": event.data,
50+ })
51+}
52+
53+/// What is sent to check a webhook works.
54+pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
55+ json!({
56+ "type": "ping",
57+ "time": time,
58+ "hook": { "id": hook_id, "url": url, "events": events },
59+ "message": "g1t will send this webhook's events here.",
60+ })
61+}
62+
63+/// The signature header's value: the body's HMAC-SHA256 under the secret.
64+pub fn signature(secret: &str, body: &str) -> String {
65+ format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
66+}
67+
68+/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
69+/// not.
70+pub fn check_url(url: &str) -> Result<(), String> {
71+ let Some(rest) = url.strip_prefix("https://") else {
72+ return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
73+ };
74+ if url.len() > 2000 {
75+ return Err("That address is too long.".to_owned());
76+ }
77+ let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
78+ let host = authority.rsplit('@').next().unwrap_or_default();
79+ let host = host.strip_prefix('[').map_or_else(
80+ || host.split(':').next().unwrap_or_default(),
81+ |v6| v6.split(']').next().unwrap_or_default(),
82+ );
83+ let host = host.to_ascii_lowercase();
84+ if host.is_empty() || !host.contains(['.', ':']) {
85+ return Err("The address needs a host g1t can reach on the internet.".to_owned());
86+ }
87+ let private_name = host == "localhost"
88+ || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
89+ .iter()
90+ .any(|suffix| host.ends_with(suffix));
91+ let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
92+ std::net::IpAddr::V4(v4) => {
93+ 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])
94+ }
95+ std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
96+ });
97+ if private_name || private_ip {
98+ return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
99+ }
100+ Ok(())
101+}
102+
103+#[cfg(test)]
104+mod tests {
105+ use super::*;
106+
107+ #[test]
108+ fn retries_wait_longer_each_time_then_stop() {
109+ assert_eq!(retry_after(1), Some(60));
110+ assert_eq!(retry_after(2), Some(300));
111+ assert_eq!(retry_after(5), Some(18_000));
112+ assert_eq!(retry_after(6), None);
113+ assert_eq!(retry_after(0), None);
114+ }
115+
116+ #[test]
117+ fn events_are_checked_and_ordered() {
118+ let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
119+ assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
120+ assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
121+ assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
122+ assert!(wants(&["*".to_owned()], "issue.opened"));
123+ assert!(!wants(&["git.push".to_owned()], "issue.opened"));
124+ }
125+
126+ #[test]
127+ fn only_public_https_addresses_are_allowed() {
128+ assert!(check_url("https://hooks.example.com/g1t").is_ok());
129+ assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
130+ for url in [
131+ "http://example.com/hook",
132+ "https://localhost/hook",
133+ "https://127.0.0.1/hook",
134+ "https://10.0.0.5/hook",
135+ "https://192.168.1.2:8080/hook",
136+ "https://169.254.169.254/latest",
137+ "https://100.64.0.1/hook",
138+ "https://[::1]/hook",
139+ "https://[fd00::1]/hook",
140+ "https://printer.local/hook",
141+ "https://intranet/hook",
142+ "https://user@10.0.0.1/hook",
143+ ] {
144+ assert!(check_url(url).is_err(), "{url}");
145+ }
146+ }
147+
148+ #[test]
149+ fn the_signature_is_the_bodys_hmac() {
150+ let signature = signature("shh", "{\"a\":1}");
151+ assert!(signature.starts_with("sha256="));
152+ assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
153+ }
154+}
+765−0
1+//! The webhooks service: events, delivered to the addresses a repository or
2+//! a workspace registers. See `g1t_contracts::webhooks` for the methods and
3+//! their arguments.
4+//!
5+//! An event from the bus becomes a delivery for each active webhook that
6+//! wants it, and is sent at once. One that is not answered with a 2xx is
7+//! tried again by the minute's sweep, waiting longer each time, until it
8+//! has had every attempt. Every delivery is kept for a fortnight, with what
9+//! was sent and what came back.
10+
11+mod deliver;
12+
13+use std::time::Duration;
14+
15+use futures_util::future::{Either, select};
16+use g1t_contracts::events::Event;
17+use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
18+use g1t_contracts::repos::{GetArgs, GetByIdArgs, Repo};
19+use g1t_contracts::time::rfc3339;
20+use g1t_contracts::webhooks::*;
21+use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, Viewer, new_id};
22+use g1t_kit::{args, now_ms, reply, rpc_method};
23+use g1t_secrets::Sealer;
24+use serde::Deserialize;
25+use serde_json::Value;
26+use worker::wasm_bindgen::JsValue;
27+use worker::{
28+ Context, D1Database, Delay, Env, Fetch, Fetcher, Headers, MessageBatch, MessageExt, Method, Request, RequestInit, Response,
29+ Result, ScheduleContext, ScheduledEvent, event,
30+};
31+
32+/// How long a receiver has to answer.
33+const TIMEOUT: Duration = Duration::from_secs(10);
34+/// How much of an answer is kept.
35+const RESPONSE_KEPT: usize = 2_000;
36+const DELIVERIES_SHOWN: u32 = 50;
37+/// How long deliveries are kept.
38+const KEPT_DAYS: u64 = 14;
39+/// How many due retries one sweep makes.
40+const SWEEP: u32 = 50;
41+
42+#[derive(Deserialize)]
43+struct HookRow {
44+ id: String,
45+ scope: String,
46+ workspace: String,
47+ repo: Option<String>,
48+ url: String,
49+ events: String,
50+ active: u32,
51+ secret: String,
52+ secret_hint: String,
53+ created_by: String,
54+ created_at: String,
55+ last_status: Option<String>,
56+ last_delivered_at: Option<String>,
57+}
58+
59+impl HookRow {
60+ fn events(&self) -> Vec<String> {
61+ serde_json::from_str(&self.events).unwrap_or_else(|_| vec!["*".to_owned()])
62+ }
63+
64+ fn to_hook(&self) -> Hook {
65+ Hook {
66+ id: self.id.clone(),
67+ scope: if self.scope == "repo" { HookScope::Repo } else { HookScope::Workspace },
68+ workspace: self.workspace.clone(),
69+ repo: self.repo.clone(),
70+ url: self.url.clone(),
71+ events: self.events(),
72+ active: self.active != 0,
73+ secret_hint: self.secret_hint.clone(),
74+ created_by: self.created_by.clone(),
75+ created_at: self.created_at.clone(),
76+ last_status: self.last_status.clone(),
77+ last_delivered_at: self.last_delivered_at.clone(),
78+ }
79+ }
80+}
81+
82+#[derive(Deserialize)]
83+struct DeliveryRow {
84+ id: String,
85+ hook_id: String,
86+ event_id: String,
87+ event: String,
88+ payload: String,
89+ status: String,
90+ attempts: u32,
91+ response_status: Option<u16>,
92+ response_body: Option<String>,
93+ error: Option<String>,
94+ duration_ms: Option<u32>,
95+ created_at: String,
96+ delivered_at: Option<String>,
97+ next_attempt_at: Option<String>,
98+}
99+
100+impl From<DeliveryRow> for HookDelivery {
101+ fn from(row: DeliveryRow) -> Self {
102+ HookDelivery {
103+ id: row.id,
104+ hook_id: row.hook_id,
105+ event_id: row.event_id,
106+ event: row.event,
107+ status: row.status,
108+ attempts: row.attempts,
109+ response_status: row.response_status,
110+ response_body: row.response_body,
111+ error: row.error,
112+ duration_ms: row.duration_ms,
113+ payload: row.payload,
114+ created_at: row.created_at,
115+ delivered_at: row.delivered_at,
116+ next_attempt_at: row.next_attempt_at,
117+ }
118+ }
119+}
120+
121+#[derive(Deserialize)]
122+struct NameRow {
123+ namespace: String,
124+ name: String,
125+}
126+
127+/// What one attempt to send came to.
128+struct Attempt {
129+ status: Option<u16>,
130+ body: Option<String>,
131+ error: Option<String>,
132+ duration_ms: u32,
133+}
134+
135+impl Attempt {
136+ fn delivered(&self) -> bool {
137+ self.status.is_some_and(|status| (200..300).contains(&status))
138+ }
139+}
140+
141+fn optional(value: Option<&str>) -> JsValue {
142+ value.map_or(JsValue::NULL, JsValue::from)
143+}
144+
145+fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
146+ Outcome::fail(code, message)
147+}
148+
149+/// The workspace itself, as the one asking: it can see its own
150+/// repositories, private ones too, and no one else's.
151+fn workspace_viewer(slug: &str) -> Viewer {
152+ Some(User {
153+ id: String::new(),
154+ username: slug.to_owned(),
155+ kind: PrincipalKind::Workspace,
156+ verified: true,
157+ workspaces: vec![Membership {
158+ slug: slug.to_owned(),
159+ role: Role::Member,
160+ }],
161+ })
162+}
163+
164+struct Webhooks {
165+ db: D1Database,
166+ sealer: Option<Sealer>,
167+ repos: Fetcher,
168+ identity: Fetcher,
169+}
170+
171+impl Webhooks {
172+ fn new(env: &Env) -> Result<Self> {
173+ Ok(Webhooks {
174+ db: env.d1("DB")?,
175+ sealer: env.secret("WEBHOOKS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())),
176+ repos: env.service("REPOS")?,
177+ identity: env.service("IDENTITY")?,
178+ })
179+ }
180+
181+ // --- Who may do what --------------------------------------------------------
182+
183+ /// The repository a repository's webhooks are for, if it is the
184+ /// workspace's and the viewer can see it.
185+ async fn repository(&self, owner: &HookOwner, viewer: &Viewer) -> Result<Option<Repo>> {
186+ let Some(path) = &owner.repo else {
187+ return Ok(None);
188+ };
189+ let found: Outcome<Repo> = g1t_kit::call(
190+ &self.repos,
191+ "get",
192+ &GetArgs {
193+ path: path.clone(),
194+ viewer: viewer.clone(),
195+ },
196+ )
197+ .await?;
198+ Ok(found
199+ .into_result()
200+ .ok()
201+ .filter(|repo| repo.namespace == owner.workspace && repo.fork_of.is_none()))
202+ }
203+
204+ fn may_see(viewer: &Viewer, workspace: &str) -> bool {
205+ viewer.as_ref().is_some_and(|viewer| viewer.is_member(workspace))
206+ }
207+
208+ /// Members manage a repository's webhooks; owners, the workspace's. An
209+ /// agent's token manages neither.
210+ fn may_manage(actor: &User, owner: &HookOwner) -> Option<Outcome<()>> {
211+ if actor.kind == PrincipalKind::Agent || !actor.is_member(&owner.workspace) {
212+ return Some(fail(FailureCode::Forbidden, format!("Only members of {} can manage its webhooks.", owner.workspace)));
213+ }
214+ if owner.repo.is_none() && actor.role_in(&owner.workspace) != Some(Role::Owner) {
215+ return Some(fail(FailureCode::Forbidden, "Only an owner can manage a workspace's own webhooks."));
216+ }
217+ None
218+ }
219+
220+ fn owner(mut owner: HookOwner) -> HookOwner {
221+ owner.workspace = owner.workspace.to_lowercase();
222+ if let Some(repo) = &mut owner.repo {
223+ repo.namespace = repo.namespace.to_lowercase();
224+ }
225+ owner
226+ }
227+
228+ /// The webhook, if it belongs to `owner`.
229+ async fn hook_of(&self, owner: &HookOwner, id: &str) -> Result<Option<HookRow>> {
230+ let row = self
231+ .db
232+ .prepare("SELECT * FROM hooks WHERE id = ? AND workspace = ?")
233+ .bind(&[id.into(), owner.workspace.as_str().into()])?
234+ .first::<HookRow>(None)
235+ .await?;
236+ let repo = owner.repo.as_ref().map(|path| format!("{}/{}", path.namespace, path.name));
237+ Ok(row.filter(|row| match &repo {
238+ Some(repo) => row.scope == "repo" && row.repo.as_deref().is_some_and(|r| r.eq_ignore_ascii_case(repo)),
239+ None => row.scope == "workspace",
240+ }))
241+ }
242+
243+ // --- Managing webhooks ------------------------------------------------------
244+
245+ async fn list(&self, a: ListArgs) -> Result<Outcome<Vec<Hook>>> {
246+ let owner = Self::owner(a.owner);
247+ if !Self::may_see(&a.viewer, &owner.workspace) {
248+ return Ok(fail(FailureCode::Forbidden, "Only members can see a workspace's webhooks."));
249+ }
250+ let rows = match &owner.repo {
251+ Some(path) => self
252+ .db
253+ .prepare("SELECT * FROM hooks WHERE scope = 'repo' AND workspace = ? AND lower(repo) = lower(?) ORDER BY id")
254+ .bind(&[owner.workspace.as_str().into(), format!("{}/{}", path.namespace, path.name).into()])?,
255+ None => self
256+ .db
257+ .prepare("SELECT * FROM hooks WHERE scope = 'workspace' AND workspace = ? ORDER BY id")
258+ .bind(&[owner.workspace.as_str().into()])?,
259+ }
260+ .all()
261+ .await?
262+ .results::<HookRow>()?;
263+ Ok(Outcome::Ok(rows.iter().map(HookRow::to_hook).collect()))
264+ }
265+
266+ async fn create(&self, a: CreateArgs) -> Result<Outcome<CreatedHook>> {
267+ let owner = Self::owner(a.owner);
268+ if let Some(Outcome::Fail(refused)) = Self::may_manage(&a.actor, &owner) {
269+ return Ok(Outcome::Fail(refused));
270+ }
271+ let Some(sealer) = &self.sealer else {
272+ return Ok(fail(FailureCode::Conflict, "Webhooks are not set up on this g1t: it has no key to keep secrets with."));
273+ };
274+ let url = a.url.trim().to_owned();
275+ if let Err(problem) = deliver::check_url(&url) {
276+ return Ok(fail(FailureCode::Invalid, problem));
277+ }
278+ let events = match deliver::tidy_events(&a.events) {
279+ Ok(events) => events,
280+ Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
281+ };
282+ let repo = match &owner.repo {
283+ Some(_) => match self.repository(&owner, &Some(a.actor.clone())).await? {
284+ Some(repo) => Some(repo),
285+ None => return Ok(fail(FailureCode::NotFound, "There is no such repository in this workspace.")),
286+ },
287+ None => None,
288+ };
289+ let given = a.secret.map(|secret| secret.trim().to_owned()).filter(|secret| !secret.is_empty());
290+ let made = given.is_none();
291+ let secret = given.unwrap_or_else(|| format!("whsec_{}", g1t_secrets::random_hex(24)));
292+ let now = now_ms();
293+ let id = new_id("hk", now);
294+ self.db
295+ .prepare(
296+ "INSERT INTO hooks (id, scope, workspace, repo_id, repo, url, events, secret, secret_hint, created_by, created_at)
297+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
298+ )
299+ .bind(&[
300+ id.as_str().into(),
301+ if repo.is_some() { "repo" } else { "workspace" }.into(),
302+ owner.workspace.as_str().into(),
303+ optional(repo.as_ref().map(|repo| repo.id.as_str())),
304+ optional(repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)).as_deref()),
305+ url.as_str().into(),
306+ serde_json::to_string(&events)?.into(),
307+ sealer.seal(&secret, &id).into(),
308+ g1t_secrets::hint(&secret).into(),
309+ a.actor.username.as_str().into(),
310+ rfc3339(now).into(),
311+ ])?
312+ .run()
313+ .await?;
314+ let Some(row) = self.hook_of(&owner, &id).await? else {
315+ return Ok(fail(FailureCode::NotFound, "The webhook was not saved."));
316+ };
317+ // Tells the receiver it is wired up, and shows whether it answers.
318+ self.send_ping(&row).await?;
319+ let row = self.hook_of(&owner, &id).await?.unwrap_or(row);
320+ Ok(Outcome::Ok(CreatedHook {
321+ hook: row.to_hook(),
322+ secret: made.then_some(secret),
323+ }))
324+ }
325+
326+ async fn update(&self, a: UpdateArgs) -> Result<Outcome<Hook>> {
327+ let owner = Self::owner(a.owner);
328+ if let Some(Outcome::Fail(refused)) = Self::may_manage(&a.actor, &owner) {
329+ return Ok(Outcome::Fail(refused));
330+ }
331+ let Some(row) = self.hook_of(&owner, &a.id).await? else {
332+ return Ok(fail(FailureCode::NotFound, "No such webhook."));
333+ };
334+ let url = a.url.map(|url| url.trim().to_owned()).unwrap_or(row.url.clone());
335+ if let Err(problem) = deliver::check_url(&url) {
336+ return Ok(fail(FailureCode::Invalid, problem));
337+ }
338+ let events = match a.events {
339+ Some(events) => match deliver::tidy_events(&events) {
340+ Ok(events) => events,
341+ Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
342+ },
343+ None => row.events(),
344+ };
345+ let active = a.active.unwrap_or(row.active != 0);
346+ self.db
347+ .prepare("UPDATE hooks SET url = ?, events = ?, active = ? WHERE id = ?")
348+ .bind(&[url.into(), serde_json::to_string(&events)?.into(), (active as u32).into(), row.id.as_str().into()])?
349+ .run()
350+ .await?;
351+ Ok(match self.hook_of(&owner, &row.id).await? {
352+ Some(row) => Outcome::Ok(row.to_hook()),
353+ None => fail(FailureCode::NotFound, "No such webhook."),
354+ })
355+ }
356+
357+ async fn delete(&self, a: HookArgs) -> Result<Outcome<bool>> {
358+ let owner = Self::owner(a.owner);
359+ if let Some(Outcome::Fail(refused)) = Self::may_manage(&a.actor, &owner) {
360+ return Ok(Outcome::Fail(refused));
361+ }
362+ let Some(row) = self.hook_of(&owner, &a.id).await? else {
363+ return Ok(fail(FailureCode::NotFound, "No such webhook."));
364+ };
365+ self.db
366+ .batch(vec![
367+ self.db.prepare("DELETE FROM hooks WHERE id = ?").bind(&[row.id.as_str().into()])?,
368+ self.db.prepare("DELETE FROM deliveries WHERE hook_id = ?").bind(&[row.id.as_str().into()])?,
369+ ])
370+ .await?;
371+ Ok(Outcome::Ok(true))
372+ }
373+
374+ async fn ping(&self, a: HookArgs) -> Result<Outcome<HookDelivery>> {
375+ let owner = Self::owner(a.owner);
376+ if let Some(Outcome::Fail(refused)) = Self::may_manage(&a.actor, &owner) {
377+ return Ok(Outcome::Fail(refused));
378+ }
379+ let Some(row) = self.hook_of(&owner, &a.id).await? else {
380+ return Ok(fail(FailureCode::NotFound, "No such webhook."));
381+ };
382+ let id = self.send_ping(&row).await?;
383+ Ok(self.delivery(&id).await?.map_or_else(|| fail(FailureCode::NotFound, "The ping was not recorded."), Outcome::Ok))
384+ }
385+
386+ async fn deliveries(&self, a: DeliveriesArgs) -> Result<Outcome<Vec<HookDelivery>>> {
387+ let owner = Self::owner(a.owner);
388+ if !Self::may_see(&a.viewer, &owner.workspace) {
389+ return Ok(fail(FailureCode::Forbidden, "Only members can see a workspace's webhooks."));
390+ }
391+ if self.hook_of(&owner, &a.id).await?.is_none() {
392+ return Ok(fail(FailureCode::NotFound, "No such webhook."));
393+ }
394+ let rows = self
395+ .db
396+ .prepare("SELECT * FROM deliveries WHERE hook_id = ? ORDER BY id DESC LIMIT ?")
397+ .bind(&[a.id.as_str().into(), DELIVERIES_SHOWN.into()])?
398+ .all()
399+ .await?
400+ .results::<DeliveryRow>()?;
401+ Ok(Outcome::Ok(rows.into_iter().map(HookDelivery::from).collect()))
402+ }
403+
404+ async fn redeliver(&self, a: RedeliverArgs) -> Result<Outcome<HookDelivery>> {
405+ let owner = Self::owner(a.owner);
406+ if let Some(Outcome::Fail(refused)) = Self::may_manage(&a.actor, &owner) {
407+ return Ok(Outcome::Fail(refused));
408+ }
409+ let Some(original) = self.delivery(&a.delivery_id).await? else {
410+ return Ok(fail(FailureCode::NotFound, "No such delivery."));
411+ };
412+ let Some(row) = self.hook_of(&owner, &original.hook_id).await? else {
413+ return Ok(fail(FailureCode::NotFound, "No such delivery."));
414+ };
415+ // A new delivery of the same payload: its own attempts and log.
416+ let id = self.enqueue(&row, "", &original.event, &original.payload).await?;
417+ if let Some(id) = &id {
418+ self.attempt(&row, id).await?;
419+ }
420+ Ok(match id {
421+ Some(id) => self.delivery(&id).await?.map_or_else(|| fail(FailureCode::NotFound, "No such delivery."), Outcome::Ok),
422+ None => fail(FailureCode::Conflict, "That delivery could not be made again."),
423+ })
424+ }
425+
426+ async fn delivery(&self, id: &str) -> Result<Option<HookDelivery>> {
427+ Ok(self
428+ .db
429+ .prepare("SELECT * FROM deliveries WHERE id = ?")
430+ .bind(&[id.into()])?
431+ .first::<DeliveryRow>(None)
432+ .await?
433+ .map(HookDelivery::from))
434+ }
435+
436+ // --- Delivering -------------------------------------------------------------
437+
438+ /// Records a delivery to make. `None` when this event was already
439+ /// delivered to this webhook.
440+ async fn enqueue(&self, hook: &HookRow, event_id: &str, event: &str, payload: &str) -> Result<Option<String>> {
441+ let now = now_ms();
442+ let id = new_id("dlv", now);
443+ let inserted = self
444+ .db
445+ .prepare(
446+ "INSERT OR IGNORE INTO deliveries (id, hook_id, event_id, event, payload, status, created_at, next_attempt_at)
447+ VALUES (?, ?, ?, ?, ?, 'pending', ?, ?) RETURNING id",
448+ )
449+ .bind(&[
450+ id.as_str().into(),
451+ hook.id.as_str().into(),
452+ event_id.into(),
453+ event.into(),
454+ payload.into(),
455+ rfc3339(now).into(),
456+ rfc3339(now).into(),
457+ ])?
458+ .first::<Value>(None)
459+ .await?;
460+ Ok(inserted.map(|_| id))
461+ }
462+
463+ async fn send_ping(&self, hook: &HookRow) -> Result<String> {
464+ let body = deliver::ping(&hook.id, &hook.url, &hook.events(), &rfc3339(now_ms())).to_string();
465+ let id = self.enqueue(hook, "", "ping", &body).await?.unwrap_or_default();
466+ self.attempt(hook, &id).await?;
467+ Ok(id)
468+ }
469+
470+ /// Sends one delivery once, and records how it went.
471+ async fn attempt(&self, hook: &HookRow, delivery_id: &str) -> Result<()> {
472+ let Some(delivery) = self.delivery(delivery_id).await? else {
473+ return Ok(());
474+ };
475+ let Some(secret) = self.sealer.as_ref().and_then(|sealer| sealer.open(&hook.secret, &hook.id)) else {
476+ return Ok(());
477+ };
478+ let attempt = send(&hook.url, &hook.id, &delivery, &secret).await;
479+ let attempts = delivery.attempts + 1;
480+ let now = now_ms();
481+ let (status, next) = if attempt.delivered() {
482+ ("delivered", None)
483+ } else {
484+ match deliver::retry_after(attempts) {
485+ Some(wait) => ("pending", Some(rfc3339(now + wait * 1000))),
486+ None => ("failed", None),
487+ }
488+ };
489+ self.db
490+ .batch(vec![
491+ self.db
492+ .prepare(
493+ "UPDATE deliveries SET status = ?, attempts = ?, response_status = ?, response_body = ?, error = ?,
494+ duration_ms = ?, delivered_at = ?, next_attempt_at = ? WHERE id = ?",
495+ )
496+ .bind(&[
497+ status.into(),
498+ attempts.into(),
499+ attempt.status.map_or(JsValue::NULL, |status| status.into()),
500+ optional(attempt.body.as_deref()),
501+ optional(attempt.error.as_deref()),
502+ attempt.duration_ms.into(),
503+ optional(attempt.delivered().then(|| rfc3339(now)).as_deref()),
504+ optional(next.as_deref()),
505+ delivery_id.into(),
506+ ])?,
507+ self.db
508+ .prepare("UPDATE hooks SET last_status = ?, last_delivered_at = ? WHERE id = ?")
509+ .bind(&[status.into(), rfc3339(now).into(), hook.id.as_str().into()])?,
510+ ])
511+ .await?;
512+ Ok(())
513+ }
514+
515+ /// Which workspace a repository is in, and its name.
516+ async fn repo_name(&self, repo_id: &str, workspaces: &[String]) -> Result<Option<NameRow>> {
517+ if let Some(known) = self
518+ .db
519+ .prepare("SELECT namespace, name FROM repo_names WHERE repo_id = ?")
520+ .bind(&[repo_id.into()])?
521+ .first::<NameRow>(None)
522+ .await?
523+ {
524+ return Ok(Some(known));
525+ }
526+ // Asked as each workspace with webhooks in turn: each sees only its
527+ // own private repositories.
528+ for workspace in workspaces {
529+ let found: Outcome<Repo> = g1t_kit::call(
530+ &self.repos,
531+ "get_by_id",
532+ &GetByIdArgs {
533+ id: repo_id.to_owned(),
534+ viewer: workspace_viewer(workspace),
535+ },
536+ )
537+ .await?;
538+ if let Outcome::Ok(repo) = found
539+ && repo.fork_of.is_none()
540+ {
541+ self.remember(repo_id, &repo.namespace, &repo.name).await?;
542+ return Ok(Some(NameRow {
543+ namespace: repo.namespace,
544+ name: repo.name,
545+ }));
546+ }
547+ }
548+ Ok(None)
549+ }
550+
551+ async fn remember(&self, repo_id: &str, namespace: &str, name: &str) -> Result<()> {
552+ self.db
553+ .prepare("INSERT OR REPLACE INTO repo_names (repo_id, namespace, name) VALUES (?, ?, ?)")
554+ .bind(&[repo_id.into(), namespace.into(), name.into()])?
555+ .run()
556+ .await?;
557+ Ok(())
558+ }
559+
560+ /// An event from the bus, delivered to every webhook that wants it.
561+ async fn on_event(&self, event: &Event) -> Result<()> {
562+ if event.kind == "repo.created"
563+ && let (Some(id), Some(namespace), Some(name)) =
564+ (event.data["repoId"].as_str(), event.data["namespace"].as_str(), event.data["name"].as_str())
565+ {
566+ self.remember(id, namespace, name).await?;
567+ }
568+ let Some(repo_id) = event.repo_id.as_deref() else {
569+ return Ok(());
570+ };
571+ let mut hooks = self
572+ .db
573+ .prepare("SELECT * FROM hooks WHERE active = 1 AND scope = 'repo' AND repo_id = ?")
574+ .bind(&[repo_id.into()])?
575+ .all()
576+ .await?
577+ .results::<HookRow>()?;
578+ let workspaces: Vec<String> = self
579+ .db
580+ .prepare("SELECT DISTINCT workspace FROM hooks WHERE active = 1 AND scope = 'workspace'")
581+ .all()
582+ .await?
583+ .results::<Value>()?
584+ .into_iter()
585+ .filter_map(|row| row["workspace"].as_str().map(str::to_owned))
586+ .collect();
587+ let name = if hooks.is_empty() && workspaces.is_empty() {
588+ None
589+ } else {
590+ self.repo_name(repo_id, &workspaces).await?
591+ };
592+ if let Some(name) = &name {
593+ hooks.extend(
594+ self.db
595+ .prepare("SELECT * FROM hooks WHERE active = 1 AND scope = 'workspace' AND workspace = ?")
596+ .bind(&[name.namespace.as_str().into()])?
597+ .all()
598+ .await?
599+ .results::<HookRow>()?,
600+ );
601+ }
602+ let wanted: Vec<&HookRow> = hooks.iter().filter(|hook| deliver::wants(&hook.events(), &event.kind)).collect();
603+ if wanted.is_empty() {
604+ return Ok(());
605+ }
606+ // Who caused it, by name: people and workspaces, or g1t's agent.
607+ let actor_name = match &event.actor {
608+ Some(id) => {
609+ let names: std::collections::HashMap<String, String> =
610+ g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.clone()] }).await?;
611+ names.get(id).cloned().or_else(|| (id == AGENT_ID).then(|| AGENT_NAME.to_owned()))
612+ }
613+ None => None,
614+ };
615+ for hook in wanted {
616+ let full_name = hook
617+ .repo
618+ .clone()
619+ .or_else(|| name.as_ref().map(|name| format!("{}/{}", name.namespace, name.name)))
620+ .unwrap_or_default();
621+ let payload = deliver::payload(event, &hook.workspace, Some((repo_id, &full_name)), actor_name.as_deref()).to_string();
622+ if let Some(id) = self.enqueue(hook, &event.id, &event.kind, &payload).await? {
623+ self.attempt(hook, &id).await?;
624+ }
625+ }
626+ Ok(())
627+ }
628+
629+ /// Tries again what is due, and forgets what is old.
630+ async fn sweep(&self) -> Result<()> {
631+ let now = now_ms();
632+ let due = self
633+ .db
634+ .prepare("SELECT * FROM deliveries WHERE status = 'pending' AND next_attempt_at <= ? ORDER BY next_attempt_at LIMIT ?")
635+ .bind(&[rfc3339(now).into(), SWEEP.into()])?
636+ .all()
637+ .await?
638+ .results::<DeliveryRow>()?;
639+ for delivery in due {
640+ let hook = self
641+ .db
642+ .prepare("SELECT * FROM hooks WHERE id = ?")
643+ .bind(&[delivery.hook_id.as_str().into()])?
644+ .first::<HookRow>(None)
645+ .await?;
646+ match hook {
647+ Some(hook) if hook.active != 0 => self.attempt(&hook, &delivery.id).await?,
648+ // A webhook turned off or removed takes its retries with it.
649+ _ => {
650+ self.db
651+ .prepare("UPDATE deliveries SET status = 'failed', next_attempt_at = NULL WHERE id = ?")
652+ .bind(&[delivery.id.as_str().into()])?
653+ .run()
654+ .await?;
655+ }
656+ }
657+ }
658+ self.db
659+ .prepare("DELETE FROM deliveries WHERE created_at < ?")
660+ .bind(&[rfc3339(now.saturating_sub(KEPT_DAYS * 24 * 60 * 60 * 1000)).into()])?
661+ .run()
662+ .await?;
663+ Ok(())
664+ }
665+}
666+
667+/// One HTTPS POST of a delivery's payload, signed, given ten seconds.
668+async fn send(url: &str, hook_id: &str, delivery: &HookDelivery, secret: &str) -> Attempt {
669+ let started = now_ms();
670+ let elapsed = || (now_ms() - started) as u32;
671+ let request = (|| -> Result<Request> {
672+ let headers = Headers::new();
673+ headers.set("content-type", "application/json")?;
674+ headers.set("user-agent", "g1t-webhooks/1 (+https://docs.g1t.sh/guides/webhooks/)")?;
675+ headers.set("x-g1t-event", &delivery.event)?;
676+ headers.set("x-g1t-delivery", &delivery.id)?;
677+ headers.set("x-g1t-hook", hook_id)?;
678+ headers.set("x-g1t-signature-256", &deliver::signature(secret, &delivery.payload))?;
679+ let mut init = RequestInit::new();
680+ init.with_method(Method::Post).with_headers(headers).with_body(Some(delivery.payload.clone().into()));
681+ Request::new_with_init(url, &init)
682+ })();
683+ let request = match request {
684+ Ok(request) => request,
685+ Err(error) => {
686+ return Attempt {
687+ status: None,
688+ body: None,
689+ error: Some(format!("The request could not be made: {error}")),
690+ duration_ms: 0,
691+ };
692+ }
693+ };
694+ let fetcher = Fetch::Request(request);
695+ let fetch = Box::pin(fetcher.send());
696+ let timeout = Box::pin(Delay::from(TIMEOUT));
697+ match select(fetch, timeout).await {
698+ Either::Left((Ok(mut response), _)) => {
699+ let status = response.status_code();
700+ let body: String = response.text().await.unwrap_or_default().chars().take(RESPONSE_KEPT).collect();
701+ Attempt {
702+ status: Some(status),
703+ body: Some(body),
704+ error: None,
705+ duration_ms: elapsed(),
706+ }
707+ }
708+ Either::Left((Err(error), _)) => Attempt {
709+ status: None,
710+ body: None,
711+ error: Some(format!("The receiver could not be reached: {error}")),
712+ duration_ms: elapsed(),
713+ },
714+ Either::Right(_) => Attempt {
715+ status: None,
716+ body: None,
717+ error: Some(format!("The receiver did not answer within {} seconds.", TIMEOUT.as_secs())),
718+ duration_ms: elapsed(),
719+ },
720+ }
721+}
722+
723+#[event(fetch)]
724+async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
725+ let Some(method) = rpc_method(&request) else {
726+ return Response::error("Not found", 404);
727+ };
728+ let body: Value = request.json().await?;
729+ let service = Webhooks::new(&env)?;
730+ match method.as_str() {
731+ "list" => reply(&service.list(args(body)?).await?),
732+ "create" => reply(&service.create(args(body)?).await?),
733+ "update" => reply(&service.update(args(body)?).await?),
734+ "delete" => reply(&service.delete(args(body)?).await?),
735+ "ping" => reply(&service.ping(args(body)?).await?),
736+ "deliveries" => reply(&service.deliveries(args(body)?).await?),
737+ "redeliver" => reply(&service.redeliver(args(body)?).await?),
738+ _ => Response::error("Unknown method", 404),
739+ }
740+}
741+
742+/// Events from the bus, on this service's own queue.
743+#[event(queue)]
744+async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> {
745+ let service = Webhooks::new(&env)?;
746+ for message in batch.messages()? {
747+ service.on_event(message.body()).await?;
748+ message.ack();
749+ }
750+ Ok(())
751+}
752+
753+/// Every minute: retries that are due, and deliveries old enough to forget.
754+#[event(scheduled)]
755+async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
756+ match Webhooks::new(&env) {
757+ Ok(service) => {
758+ if let Err(error) = service.sweep().await {
759+ worker::console_error!("webhooks: the sweep failed: {error}");
760+ }
761+ }
762+ Err(error) => worker::console_error!("webhooks: could not start: {error}"),
763+ }
764+}
765+
+32−0
1+{
2+ "$schema": "../../node_modules/wrangler/config-schema.json",
3+ "name": "g1t-webhooks",
4+ "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
5+ "compatibility_date": "2026-09-26",
6+ "main": "build/index.js",
7+ "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
8+ // Reached only through service bindings; it reaches out to the
9+ // addresses webhooks are registered at.
10+ "workers_dev": false,
11+ "d1_databases": [
12+ {
13+ "binding": "DB",
14+ "database_name": "g1t-webhooks",
15+ "database_id": "7da97a7e-b4a7-40e3-a129-3b78d654e9bb",
16+ "migrations_dir": "migrations"
17+ }
18+ ],
19+ "services": [
20+ { "binding": "REPOS", "service": "g1t-repos" },
21+ { "binding": "IDENTITY", "service": "g1t-identity" }
22+ ],
23+ // Every event on the bus, to deliver to the webhooks that want it.
24+ "queues": {
25+ "consumers": [{ "queue": "g1t-events-webhooks", "max_batch_size": 20, "max_batch_timeout": 1 }]
26+ },
27+ // Retries that are due, and deliveries old enough to forget.
28+ "triggers": { "crons": ["* * * * *"] },
29+ // Secret: WEBHOOKS_KEY, 64 hex characters. Webhooks' signing secrets are
30+ // sealed with it; without it none can be made.
31+ "observability": { "enabled": true }
32+}