Commit

Sidebar: the panels really slide

Tailwind 4 moves translate-x-* with the CSS translate property, and the layers transitioned transform, so they jumped and only faded. They now transition translate over 380ms with the fade at 220ms, so the two menus never blur together.

syntaqxcommitted Parentc126d7aBrowse files
38 files+217−22220/38 viewed
+0−13
906906 ]
907907
908908 [[package]]
909−name = "g1t-automations"
910−version = "0.1.0"
911−dependencies = [
912− "g1t-actions",
913− "g1t-contracts",
914− "g1t-kit",
915− "serde",
916− "serde_json",
917− "serde_yaml",
918− "worker",
919−]
920−
921−[[package]]
922909 name = "g1t-billing"
923910 version = "0.1.0"
924911 dependencies = [
+1−1
11 [workspace]
22 resolver = "3"
3−members = ["apps/api", "crates/*", "services/actions", "services/automations", "services/billing", "services/events", "services/identity", "services/integrations", "services/webhooks", "services/repos", "services/work"]
3+members = ["apps/api", "crates/*", "services/actions", "services/billing", "services/events", "services/identity", "services/integrations", "services/webhooks", "services/repos", "services/work"]
44
55 [workspace.package]
66 edition = "2024"
+0−2
1010 let name = op.name();
1111 if name.contains("webhook") {
1212 "Webhooks"
13− } else if name.contains("automation") {
14− "Automations"
1513 } else if name.contains("workflow") || name.contains("actions_") || name == "get_job_logs" {
1614 "Actions"
1715 } else if name.contains("integration") || name.contains("model_routes") || op == Op::GetContext {
+1−75
2525 pub billing: Fetcher,
2626 pub integrations: Fetcher,
2727 pub webhooks: Fetcher,
28− pub automations: Fetcher,
2928 pub actions: Fetcher,
3029 /// Set for a request made with an agent's token: all it may do.
3130 pub scope: Option<AgentScope>,
4241 billing: env.service("BILLING")?,
4342 integrations: env.service("INTEGRATIONS")?,
4443 webhooks: env.service("WEBHOOKS")?,
45− automations: env.service("AUTOMATIONS")?,
4644 actions: env.service("ACTIONS")?,
4745 scope: None,
4846 })
10199 PingWebhook,
102100 ListWebhookDeliveries,
103101 RedeliverWebhook,
104− ListAutomations,
105− ListAutomationRuns,
106− RunAutomation,
107− UpdateAutomation,
108102 ListWorkflows,
109103 ListWorkflowRuns,
110104 GetWorkflowRun,
291285 }
292286
293287 impl Op {
294− pub const ALL: [Op; 68] = [
288+ pub const ALL: [Op; 64] = [
295289 Op::Whoami,
296290 Op::CreateWorkspace,
297291 Op::ListRepos,
342336 Op::PingWebhook,
343337 Op::ListWebhookDeliveries,
344338 Op::RedeliverWebhook,
345− Op::ListAutomations,
346− Op::ListAutomationRuns,
347− Op::RunAutomation,
348− Op::UpdateAutomation,
349339 Op::ListWorkflows,
350340 Op::ListWorkflowRuns,
351341 Op::GetWorkflowRun,
419409 Op::PingWebhook => "ping_webhook",
420410 Op::ListWebhookDeliveries => "list_webhook_deliveries",
421411 Op::RedeliverWebhook => "redeliver_webhook",
422− Op::ListAutomations => "list_automations",
423− Op::ListAutomationRuns => "list_automation_runs",
424− Op::RunAutomation => "run_automation",
425− Op::UpdateAutomation => "update_automation",
426412 Op::ListWorkflows => "list_workflows",
427413 Op::ListWorkflowRuns => "list_workflow_runs",
428414 Op::GetWorkflowRun => "get_workflow_run",
569555 "A webhook's latest deliveries, newest first: what was sent, how the receiver answered, and when it will be tried again."
570556 }
571557 Op::RedeliverWebhook => "Send a delivery's payload again, as a new delivery.",
572− Op::ListAutomations => {
573− "A repository's automations, read from .g1t/automations/*.yml on its default branch: what starts each, its conditions and steps, whether it is on, any problem with its file, and its last run. To add or change one, commit its file."
574− }
575− Op::ListAutomationRuns => {
576− "A repository's latest automation runs, newest first, of one automation or all: what started each, and how each step went or why it was skipped."
577− }
578− Op::RunAutomation => {
579− "Run an automation now, on an issue or pull request if number is given. Members only."
580− }
581− Op::UpdateAutomation => "Turn an automation on or off without changing its file. Members only.",
582558 Op::ListWorkflows => {
583559 "A repository's GitHub Actions workflows, read from .g1t/workflows (GitHub's format, so a repository moves by renaming .github to .g1t) on its default branch: the events that start each, whether it is on, any problem with its file, notes on anything that runs differently on g1t, its manual-run inputs, and its last run."
584560 }
953929 ),
954930 Op::GetModelRoutes => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
955931 Op::ListWebhooks => object(hook_owner(json!({})), &[]),
956− Op::ListAutomations => repo_only(),
957932 Op::ListWorkflows => repo_only(),
958933 Op::ListWorkflowRuns => object(
959934 json!({
1019994 Op::DeleteActionsSecret | Op::DeleteActionsVariable => object(
1020995 settings_owner(json!({ "setting": { "type": "string", "description": "The name." } })),
1021996 &["setting"],
1022− ),
1023− Op::ListAutomationRuns => object(
1024− json!({
1025− "repo": repo_schema(),
1026− "automation": { "type": "string", "description": "One automation's id, for its runs only." },
1027− }),
1028− &["repo"],
1029− ),
1030− Op::RunAutomation => object(
1031− json!({
1032− "repo": repo_schema(),
1033− "id": { "type": "string", "description": "The automation's id." },
1034− "number": { "type": "integer", "description": "The issue or pull request to run it on." },
1035− }),
1036− &["repo", "id"],
1037997 ),
1038− Op::UpdateAutomation => object(
1039− json!({
1040− "repo": repo_schema(),
1041− "id": { "type": "string", "description": "The automation's id." },
1042− "enabled": { "type": "boolean" },
1043− }),
1044− &["repo", "id", "enabled"],
1045− ),
1046998 Op::CreateWebhook => object(
1047999 hook_owner(json!({
10481000 "url": { "type": "string", "description": "An HTTPS address on the public internet." },
12461198 runner,
12471199 integrations,
12481200 webhooks,
1249− automations,
12501201 actions,
12511202 ..
12521203 } = services;
16681619 integrations,
16691620 if self == Op::TestIntegration { "test" } else { "disconnect" },
16701621 &json!({ "actor": actor(), "workspace": workspace(), "id": text(input, "id") }),
1671− )
1672− .await
1673− }
1674− Op::ListAutomations => pass(automations, "list", &json!({ "repo": repo, "viewer": viewer })).await,
1675− Op::ListAutomationRuns => {
1676− pass(
1677− automations,
1678− "runs",
1679− &json!({ "repo": repo, "viewer": viewer, "automation": optional_text(input, "automation") }),
1680− )
1681− .await
1682− }
1683− Op::RunAutomation => {
1684− pass(
1685− automations,
1686− "run",
1687− &json!({ "actor": actor(), "repo": repo, "id": text(input, "id"), "number": integer(input, "number") }),
1688− )
1689− .await
1690− }
1691− Op::UpdateAutomation => {
1692− pass(
1693− automations,
1694− "set_enabled",
1695− &json!({ "actor": actor(), "repo": repo, "id": text(input, "id"), "enabled": input["enabled"].as_bool() == Some(true) }),
16961622 )
16971623 .await
16981624 }
+0−24
243243 ),
244244 route(
245245 "GET",
246− "/repos/:owner/:name/automations/runs",
247− Op::ListAutomationRuns,
248− &[("automation", "automation")],
249− ),
250− route(
251− "GET",
252− "/repos/:owner/:name/automations",
253− Op::ListAutomations,
254− &[],
255− ),
256− route(
257− "POST",
258− "/repos/:owner/:name/automations/:id/runs",
259− Op::RunAutomation,
260− &[],
261− ),
262− route(
263− "PATCH",
264− "/repos/:owner/:name/automations/:id",
265− Op::UpdateAutomation,
266− &[],
267− ),
268− route(
269− "GET",
270246 "/repos/:owner/:name/actions/workflows",
271247 Op::ListWorkflows,
272248 &[],
+0−1
2121 { "binding": "BILLING", "service": "g1t-billing" },
2222 { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
2323 { "binding": "WEBHOOKS", "service": "g1t-webhooks" },
24− { "binding": "AUTOMATIONS", "service": "g1t-automations" },
2524 { "binding": "ACTIONS", "service": "g1t-actions" }
2625 ],
2726 // GitHub Actions artifacts and cache, in chunks, with KV's own expiry.
+0−1
8484 { label: 'Model providers', slug: 'guides/models' },
8585 { label: 'Webhooks', slug: 'guides/webhooks' },
8686 { label: 'GitHub Actions', slug: 'guides/actions' },
87− { label: 'Automations', slug: 'guides/automations' },
8887 ],
8988 },
9089 {
+0−6
148148 -d '{"ref": "main", "inputs": {"environment": "staging"}}'
149149 ```
150150
151−## Automations
152−
153−Workflows run code. For rules that act on g1t itself, such as labelling an
154−issue, putting an agent on it, or posting to chat, without a runner, see
155−[Automations](/guides/automations/), which live beside workflows in
156−`.g1t/automations/`.
+0−239
1−---
2−title: Automations
3−description: Rules committed to a repository that act when something happens, such as commenting, labelling, putting an agent on an issue or posting to chat.
4−---
5−
6−An automation is a rule kept in the repository, in
7−`.g1t/automations/`. It says what starts it, what must be true, and the
8−steps to take:
9−
10−```yaml
11−# .g1t/automations/bugs.yml
12−name: Put an agent on every bug
13−on: issue.opened
14−if:
15− labels: bug
16−do:
17− - comment: "Thanks, {{actor}}. A g1t agent is on it."
18− - assign_agent
19−```
20−
21−Commit that file to the default branch and, from the next issue opened
22−with the label `bug`, g1t answers it and starts an agent on it. Change or
23−delete the file and the automation changes with it.
24−
25−Because automations are files, they are reviewed in pull requests like
26−any other change. Each repository can have up to 50.
27−
28−## See them run
29−
30−Open the repository's **Automations** page, in its sidebar. Each automation
31−is listed in words, such as *On issue.opened, if labelled bug: comment,
32−then put a g1t agent on it*, with its last run. A file g1t cannot read is
33−listed too, with what is wrong with it.
34−
35−**Recent runs** lists the last 50 runs. Open a run to see what started it,
36−and either how each step went or why the run was skipped.
37−
38−Members of the workspace can also:
39−
40−- **Run now**: run it straight away, on an issue or pull request if you
41− give its number. This works for any automation, whatever starts it.
42−- **Turn off** and **Turn on**: stop it without changing its file. It
43− stays off when the file changes.
44−
45−## What starts it: `on`
46−
47−| `on` | Starts it |
48−| --- | --- |
49−| An event, such as `issue.opened` | Whenever that happens in the repository. |
50−| A list, such as `[pull.merged, pull.closed]` | Whenever any of them happens. |
51−| `schedule: "0 9 * * mon"` | On a cron schedule, in UTC. |
52−| `manual` | Only by **Run now** or the API. |
53−
54−The events are the same as [webhooks'](/guides/webhooks/#events):
55−
56−| Event | When |
57−| --- | --- |
58−| `git.push` | A branch moved. |
59−| `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. |
60−| `comment.created` | A comment or review on an issue or pull request. |
61−| `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. |
62−| `checks.completed` | An issue's acceptance checks finished on a pull request. |
63−| `review.completed` | A g1t agent reviewed a pull request. |
64−| `workflow.completed` | A GitHub Actions run finished: `if: { conclusion: failure }` to act on failures. |
65−| `queue.changed` | The merge queue changed. |
66−
67−A schedule has five fields: minute, hour, day of the month, month and day
68−of the week. Each field takes `*`, a number, a list (`1,15`), a range
69−(`1-5`) or a step (`*/15`, `0-30/10`). Days of the week also take names,
70−`sun` to `sat`.
71−
72−| Schedule | Runs |
73−| --- | --- |
74−| `"0 9 * * mon"` | Mondays at 09:00 UTC |
75−| `"*/30 * * * *"` | Every half hour |
76−| `"0 0 1 * *"` | At midnight on the first of each month |
77−| `"0 17 * * mon-fri"` | Weekdays at 17:00 UTC |
78−
79−A scheduled run is not about any issue or pull request, so it can only use
80−`open_issue` and `notify`.
81−
82−## Conditions: `if`
83−
84−`if` maps fields to the values they must have. Give one value or a list:
85−any value in the list matches. Every field must match. Matching ignores
86−case.
87−
88−```yaml
89−if:
90− labels: [bug, regression] # has either label
91− status: failed # checks.completed's data.status
92− actor: ada # caused by ada
93−```
94−
95−| Field | Matches |
96−| --- | --- |
97−| `labels` | The issue has any of these labels. For a pull request, the labels of its issue. |
98−| `actor` | The username of who caused the event. A g1t agent is `g1t-agent`. |
99−| `branch` | The branch pushed to, for `git.push`. |
100−| Any field of the event's `data` | Such as `status` or `verdict`. Write `data.status` to be explicit. |
101−
102−When the conditions do not match, the run is recorded as skipped, with the
103−reason, such as *not labelled bug*.
104−
105−## Steps: `do`
106−
107−Steps run in order. When a step fails, the steps after it do not run, and
108−the run shows which step failed and why.
109−
110−| Step | Does |
111−| --- | --- |
112−| `comment: text` | Comments on the issue or pull request. |
113−| `label: name` | Adds a label to the issue. |
114−| `unlabel: name` | Removes a label from the issue. |
115−| `assign_agent` | Puts a g1t agent on the issue, which opens a pull request. |
116−| `message_agent: text` | Tells the agent working on the pull request. It reads the message at its next step. |
117−| `close_issue` | Closes the issue as completed. `close_issue: not_planned` closes it as not planned. |
118−| `reopen_issue` | Reopens the issue. |
119−| `open_issue:` | Opens a new issue: `title`, and optionally `body`, `labels` and `assign_agent: true`. |
120−| `notify:` | Posts `text` to an HTTPS `url`, such as a Slack or Discord incoming webhook. |
121−
122−A step without arguments is written as its name alone, such as
123−`- assign_agent`. A step that takes text is written as the name and the
124−text, such as `- label: triaged`. A step that takes several arguments takes
125−a mapping:
126−
127−```yaml
128−do:
129− - open_issue:
130− title: Weekly tidy-up
131− body: Update dependencies that have new patch releases.
132− labels: [chore]
133− assign_agent: true
134− - notify:
135− url: https://hooks.slack.com/services/T000/B000/XXXX
136− text: "Opened #{{opened}} in {{repo}}"
137−```
138−
139−`notify` sends `{"text": …, "content": …}`, the shape that Slack's,
140−Discord's and most chat tools' incoming webhooks take. It posts only to
141−public addresses.
142−
143−## Filling in text: `{{ }}`
144−
145−Text in steps can use these, written in double braces:
146−
147−| Name | Is |
148−| --- | --- |
149−| `{{repo}}` | The repository, such as `acme/web`. |
150−| `{{event}}` | What started the run, such as `issue.opened`, `schedule` or `manual`. |
151−| `{{automation}}` | The automation's name. |
152−| `{{actor}}` | The username of who caused the event. |
153−| `{{number}}`, `{{title}}`, `{{url}}` | The issue or pull request the event is about. |
154−| `{{branch}}` | The branch pushed to, for `git.push`. |
155−| `{{opened}}` | The number of the issue that `open_issue` just opened. |
156−| `{{data.field}}` | Any field of the event's data, such as `{{data.status}}`. |
157−
158−A name with no value is left empty.
159−
160−## Who it acts as
161−
162−An automation acts as its workspace. A comment from one shows the
163−workspace as its author and ends with the automation's name. An agent
164−it starts is billed to the workspace like any other run, through the
165−workspace's [model providers](/guides/models/).
166−
167−## The rules every run keeps
168−
169−- **Once per event.** An event runs each automation at most once, even if
170− it is delivered again.
171−- **No loops.** An automation that changed an issue or pull request in
172− the last 10 minutes does not answer a change to it that an automation
173− made, whether itself or another. An automation that labels issues on
174− `issue.updated` does not keep running because it labelled one, and two
175− automations cannot set each other off. Changes people and agents make
176− are answered as usual.
177−- **A limit per hour.** An automation makes at most 30 runs an hour.
178− Runs past the limit are skipped and recorded. Change the limit, up to
179− 200, with:
180−
181− ```yaml
182− limits:
183− per_hour: 100
184− ```
185−
186−## More examples
187−
188−Tell the agent when checks fail:
189−
190−```yaml
191−name: Tell the agent when checks fail
192−on: checks.completed
193−if:
194− status: failed
195−do:
196− - message_agent: "The acceptance checks failed. Read their output on #{{number}} and fix the cause."
197−```
198−
199−Announce merges in chat:
200−
201−```yaml
202−name: Announce merges
203−on: pull.merged
204−do:
205− - notify:
206− url: https://hooks.slack.com/services/T000/B000/XXXX
207− text: "Merged into {{repo}}: {{title}} {{url}}"
208−```
209−
210−Close questions nobody followed up:
211−
212−```yaml
213−name: Close answered questions
214−on: manual
215−if:
216− labels: question
217−do:
218− - comment: "Closing this as answered. Reopen it if there is more to ask."
219− - close_issue: not_planned
220−```
221−
222−## From the API
223−
224−| Tool | Route |
225−| --- | --- |
226−| `list_automations` | `GET /repos/{owner}/{name}/automations` |
227−| `list_automation_runs` | `GET /repos/{owner}/{name}/automations/runs`, optionally `?automation={id}` |
228−| `run_automation` | `POST /repos/{owner}/{name}/automations/{id}/runs`, optionally with `number` |
229−| `update_automation` | `PATCH /repos/{owner}/{name}/automations/{id}` with `enabled` |
230−
231−Running an automation or turning one on or off needs a member of the
232−workspace. Agents cannot run or change automations. To add or change one,
233−commit its file.
234−
235−```sh
236−curl -X POST https://api.g1t.sh/repos/acme/web/automations/aut_01m4…/runs \
237− -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
238− -d '{"number": 42}'
239−```
+0−11
153153 | `set_actions_variable` | `setting`, `value` | Add or replace a variable. | `POST /repos/{owner}/{name}/actions/variables` |
154154 | `delete_actions_variable` | `setting` | Remove a variable. | `DELETE /repos/{owner}/{name}/actions/variables/{name}` |
155155
156−## Automations
157−
158−See [Automations](/guides/automations/). An automation is a file in `.g1t/automations/` on the default branch: to add or change one, commit it.
159−
160−| Tool | Required | What it does | Route |
161−| --- | --- | --- | --- |
162−| `list_automations` | `repo` | The automations: what starts each, its conditions and steps, whether it is on, any problem with its file, and its last run. | `GET /repos/{owner}/{name}/automations` |
163−| `list_automation_runs` | `repo` | The latest runs, newest first, with each step's result or why the run was skipped. `automation` for one automation's. | `GET /repos/{owner}/{name}/automations/runs` |
164−| `run_automation` | `repo`, `id` | Run it now, on issue or pull request `number` if given. Members only. | `POST /repos/{owner}/{name}/automations/{id}/runs` |
165−| `update_automation` | `repo`, `id`, `enabled` | Turn it on or off without changing its file. Members only. | `PATCH /repos/{owner}/{name}/automations/{id}` |
166−
167156 ## Messages
168157
169158 | Tool | Required | What it does | Route |
+0−5
2727 Users,
2828 Webhook,
2929 PlayCircle,
30− Zap,
3130 X,
3231 } from "lucide-react";
3332 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
387386 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
388387 Actions
389388 </SidebarLink>
390− <SidebarLink to={`${base}/automations`} icon={<Zap size={15} />}>
391− Automations
392− </SidebarLink>
393389 </SidebarGroup>
394390 {repo.member && (
395391 <SidebarGroup title="Repository">
609605 queue: "Merge queue",
610606 commits: "Commits",
611607 plans: "Plan",
612− automations: "Automations",
613608 actions: "Actions",
614609 secrets: "Secrets and variables",
615610 settings: "Settings",
+0−2
22
33 import {
44 actionsClient,
5− automationsClient,
65 billingClient,
76 eventsClient,
87 identityClient,
1918 export const events = eventsClient(env.EVENTS);
2019 export const integrations = integrationsClient(env.INTEGRATIONS);
2120 export const webhooks = webhooksClient(env.WEBHOOKS);
22−export const automations = automationsClient(env.AUTOMATIONS);
2321 export const actions = actionsClient(env.ACTIONS);
+0−1
4646 route("actions/runs/:id", "routes/repo/actions-run.tsx"),
4747 route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"),
4848 route("actions/jobs/:job/log", "routes/repo/actions-log.ts"),
49− route("automations", "routes/repo/automations.tsx"),
5049 route("plans", "routes/repo/plans.tsx"),
5150 route("plans/:id", "routes/repo/plan.tsx"),
5251 route("settings", "routes/repo/settings.tsx"),
+0−303
1−import { CheckCircle2, ChevronRight, CircleSlash, FileCode2, Play, XCircle, Zap } from "lucide-react";
2−import { Form, Link, useNavigation } from "react-router";
3−
4−import type { Automation, AutomationRun } from "@g1t/contracts";
5−
6−import type { Route } from "./+types/automations";
7−import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8−import { automations } from "../../lib/services.server";
9−import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
10−
11−export function meta({ params }: Route.MetaArgs) {
12− return [{ title: `Automations · ${params.owner}/${params.repo} · g1t` }];
13−}
14−
15−export async function loader({ params, context }: Route.LoaderArgs) {
16− const viewer = getViewer(context);
17− const repo = { namespace: params.owner, name: params.repo };
18− const [list, runs] = await Promise.all([automations.list(repo, viewer), automations.runs(repo, viewer)]);
19− return {
20− automations: unwrap(list),
21− runs: runs.ok ? runs.value : [],
22− member: roleIn(viewer, params.owner) != null,
23− };
24−}
25−
26−export async function action({ request, params, context }: Route.ActionArgs) {
27− assertSameOrigin(request);
28− const user = requireUser(context, request);
29− const repo = { namespace: params.owner, name: params.repo };
30− const form = await request.formData();
31− const id = String(form.get("id") ?? "");
32− if (form.get("intent") === "toggle") {
33− const changed = await automations.setEnabled(user, repo, id, form.get("enabled") === "true");
34− return changed.ok ? {} : { error: changed.error.message };
35− }
36− const number = Number(form.get("number"));
37− const ran = await automations.run(user, repo, id, Number.isInteger(number) && number > 0 ? number : undefined);
38− return ran.ok ? { ran: ran.value } : { error: ran.error.message };
39−}
40−
41−/** Ready-made automations to start from. */
42−const TEMPLATES: { file: string; title: string; about: string; yaml: string }[] = [
43− {
44− file: "bugs.yml",
45− title: "Put an agent on every bug",
46− about: "A new issue labelled bug gets a g1t agent at once, and a note saying so.",
47− yaml: `name: Put an agent on every bug
48−on: issue.opened
49−if:
50− labels: bug
51−do:
52− - comment: "Thanks, {{actor}}. A g1t agent is on it."
53− - assign_agent`,
54− },
55− {
56− file: "failed-checks.yml",
57− title: "Tell the agent when checks fail",
58− about: "When a pull request's checks fail, the agent working on it hears at once.",
59− yaml: `name: Tell the agent when checks fail
60−on: checks.completed
61−if:
62− status: failed
63−do:
64− - message_agent: "The acceptance checks failed. Read their output on #{{number}} and fix the cause."`,
65− },
66− {
67− file: "announce-merges.yml",
68− title: "Announce merges in chat",
69− about: "Every merge posts to a Slack or Discord channel through its incoming webhook.",
70− yaml: `name: Announce merges
71−on: pull.merged
72−do:
73− - notify:
74− url: https://hooks.slack.com/services/...
75− text: "Merged into {{repo}}: {{title}} {{url}}"`,
76− },
77− {
78− file: "weekly-tidy.yml",
79− title: "A weekly tidy-up",
80− about: "Every Monday morning an issue opens and an agent takes it.",
81− yaml: `name: Weekly tidy-up
82−on:
83− schedule: "0 9 * * mon"
84−do:
85− - open_issue:
86− title: Weekly tidy-up
87− body: Update dependencies that have new patch releases, and fix any new warnings.
88− labels: chore
89− assign_agent: true`,
90− },
91−];
92−
93−function RunStatus({ run }: { run: AutomationRun }) {
94− if (run.status === "succeeded") return <CheckCircle2 size={15} className="shrink-0 text-accent" />;
95− if (run.status === "failed") return <XCircle size={15} className="shrink-0 text-danger" />;
96− return <CircleSlash size={15} className="shrink-0 text-faint" />;
97−}
98−
99−function RunRow({ run, base }: { run: AutomationRun; base: string }) {
100− return (
101− <details className="group border-t border-line first:border-t-0">
102− <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40">
103− <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
104− <RunStatus run={run} />
105− <span className="min-w-0 truncate font-medium">{run.name}</span>
106− <span className="shrink-0 font-mono text-xs text-muted">{run.event}</span>
107− {run.number != null && (
108− <Link to={`${base}/issues/${run.number}`} className="shrink-0 font-mono text-xs text-muted hover:text-fg">
109− #{run.number}
110− </Link>
111− )}
112− <span className="ml-auto shrink-0 text-xs text-faint">
113− {run.actor && `${run.actor} · `}
114− <TimeAgo at={run.startedAt} />
115− </span>
116− </summary>
117− <div className="border-t border-line bg-bg/40 px-4 py-3 text-sm">
118− {run.reason && <p className="text-muted">{run.reason}</p>}
119− {run.steps.length > 0 && (
120− <ol className="space-y-1.5">
121− {run.steps.map((step, index) => (
122− <li key={index} className="flex items-start gap-2">
123− {step.ok ? (
124− <CheckCircle2 size={14} className="mt-0.5 shrink-0 text-accent" />
125− ) : (
126− <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />
127− )}
128− <span>
129− <span className="font-medium">{step.step}</span>
130− <span className="text-muted"> · {step.detail}</span>
131− </span>
132− </li>
133− ))}
134− </ol>
135− )}
136− </div>
137− </details>
138− );
139−}
140−
141−function AutomationCard({ automation, base, member }: { automation: Automation; base: string; member: boolean }) {
142− const busy = useNavigation().state === "submitting";
143− return (
144− <li className="border-t border-line p-4 first:border-t-0">
145− <div className="flex flex-wrap items-start gap-3">
146− <span
147− className={`mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-lg ring-1 ${
148− automation.error
149− ? "bg-danger/10 text-danger ring-danger/30"
150− : automation.enabled
151− ? "bg-accent/10 text-accent ring-accent/30"
152− : "bg-surface text-faint ring-line"
153− }`}
154− >
155− <Zap size={15} />
156− </span>
157− <div className="min-w-0 grow">
158− <p className="flex flex-wrap items-center gap-2">
159− <span className="font-medium">{automation.name}</span>
160− {!automation.enabled && !automation.error && (
161− <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Off</span>
162− )}
163− </p>
164− <Link
165− to={`${base}/blob/HEAD/${automation.path}`}
166− className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg"
167− >
168− <FileCode2 size={12} />
169− {automation.path}
170− </Link>
171− {automation.error ? (
172− <p className="mt-2 text-sm text-danger">{automation.error}</p>
173− ) : (
174− <p className="mt-2 text-sm text-muted">
175− <span className="text-fg">On</span> {automation.trigger}
176− {automation.conditions.length > 0 && (
177− <>
178− , <span className="text-fg">if</span> {automation.conditions.join(" and ")}
179− </>
180− )}
181− : {automation.steps.join(", then ")}.
182− </p>
183− )}
184− {automation.lastRun && (
185− <p className="mt-2 flex items-center gap-1.5 text-xs text-faint">
186− <RunStatus run={automation.lastRun} />
187− Last run {automation.lastRun.status} <TimeAgo at={automation.lastRun.startedAt} />
188− {automation.lastRun.reason && ` · ${automation.lastRun.reason}`}
189− </p>
190− )}
191− </div>
192− {member && !automation.error && (
193− <div className="flex shrink-0 items-center gap-2">
194− <Form method="post" className="flex items-center gap-1.5">
195− <input type="hidden" name="id" value={automation.id} />
196− <input
197− name="number"
198− inputMode="numeric"
199− placeholder="#"
200− aria-label="Issue or pull request to run it on"
201− className="w-14 rounded-md border border-line bg-bg px-2 py-1.5 text-center font-mono text-xs outline-none focus:border-accent-dim"
202− />
203− <Button type="submit" variant="quiet" disabled={busy} name="intent" value="run">
204− <Play size={13} />
205− Run now
206− </Button>
207− </Form>
208− <Form method="post">
209− <input type="hidden" name="intent" value="toggle" />
210− <input type="hidden" name="id" value={automation.id} />
211− <input type="hidden" name="enabled" value={automation.enabled ? "false" : "true"} />
212− <Button type="submit" variant="quiet" disabled={busy}>
213− {automation.enabled ? "Turn off" : "Turn on"}
214− </Button>
215− </Form>
216− </div>
217− )}
218− </div>
219− </li>
220− );
221−}
222−
223−function Templates({ repo }: { repo: string }) {
224− return (
225− <section>
226− <h3 className="text-sm font-medium">Start from one of these</h3>
227− <p className="mt-1 text-sm text-muted">
228− Add the file to <code className="text-fg">.g1t/automations/</code> on {repo}'s default branch. It is read the moment
229− you push.
230− </p>
231− <div className="mt-4 grid gap-3 lg:grid-cols-2">
232− {TEMPLATES.map((template) => (
233− <div key={template.file} className="rounded-xl border border-line bg-surface p-4">
234− <p className="font-medium">{template.title}</p>
235− <p className="mt-1 text-xs text-muted">{template.about}</p>
236− <p className="mt-3 font-mono text-xs text-faint">.g1t/automations/{template.file}</p>
237− <pre className="mt-1.5 rounded-lg bg-bg p-3 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap ring-1 ring-line">
238− <code>{template.yaml}</code>
239− </pre>
240− </div>
241− ))}
242− </div>
243− </section>
244− );
245−}
246−
247−export default function Automations({ loaderData, actionData, params }: Route.ComponentProps) {
248− const { automations: list, runs, member } = loaderData;
249− const base = `/${params.owner}/${params.repo}`;
250− const ran = actionData && "ran" in actionData ? actionData.ran : null;
251− return (
252− <div className="max-w-5xl space-y-10">
253− <header className="flex flex-wrap items-start justify-between gap-4">
254− <div>
255− <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
256− <Zap size={18} className="text-accent" />
257− Automations
258− </h2>
259− <p className="mt-1 max-w-2xl text-sm text-muted">
260− Rules in <code className="text-fg">.g1t/automations</code> that act when something happens: comment, label,
261− put an agent on it, tell the agent working on it, open or close issues, post to chat.
262− </p>
263− </div>
264− <a href="https://docs.g1t.sh/guides/automations/" className="text-sm text-muted underline underline-offset-4 hover:text-fg">
265− How automations work
266− </a>
267− </header>
268−
269− {ran && (
270− <p className="text-sm text-muted">
271− Ran <span className="text-fg">{ran.name}</span>: {ran.status}
272− {ran.reason ? `. ${ran.reason}` : "."}
273− </p>
274− )}
275− <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
276−
277− {list.length === 0 ? (
278− <EmptyState title="No automations yet">
279− Commit a file to <code>.g1t/automations/</code> and it shows up here.
280− </EmptyState>
281− ) : (
282− <ul className="overflow-hidden rounded-xl border border-line bg-surface">
283− {list.map((automation) => (
284− <AutomationCard key={automation.id} automation={automation} base={base} member={member} />
285− ))}
286− </ul>
287− )}
288−
289− {runs.length > 0 && (
290− <section>
291− <h3 className="mb-3 text-sm font-medium">Recent runs</h3>
292− <div className="overflow-hidden rounded-xl border border-line bg-surface">
293− {runs.map((run) => (
294− <RunRow key={run.id} run={run} base={base} />
295− ))}
296− </div>
297− </section>
298− )}
299−
300− <Templates repo={`${params.owner}/${params.repo}`} />
301− </div>
302− );
303−}
+1−13
192192 `disconnect_integration`, `get_model_routes`, `set_model_routes`,
193193 `get_context`, `import_issue`, `list_webhooks`, `create_webhook`,
194194 `update_webhook`, `delete_webhook`, `ping_webhook`,
195−`list_webhook_deliveries`, `redeliver_webhook`, `list_automations`,
196−`list_automation_runs`, `run_automation`, `update_automation`,
195+`list_webhook_deliveries`, `redeliver_webhook`,
197196 `list_workflows`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`,
198197 `dispatch_workflow`, `cancel_workflow_run`, `rerun_workflow_run`,
199198 `update_workflow`, `list_actions_secrets`, `set_actions_secret`,
242241 `{repo}/actions/...`. A run on a pull request's head is a check: pending
243242 holds the merge, failure refuses it and sends a g1t agent back to fix it.
244243 Secrets and variables: `{repo}/actions/secrets`, `{repo}/actions/variables`.
245−
246−## Automations
247−
248−A YAML file in `.g1t/automations/` on the default branch is a rule: `on`
249−(an event such as `issue.opened`, `schedule: "0 9 * * mon"`, or `manual`),
250−optional `if` (`labels`, `actor`, `branch`, or an event data field), and
251−`do` steps: `comment`, `label`, `unlabel`, `assign_agent`, `message_agent`,
252−`open_issue`, `close_issue`, `reopen_issue`, `notify`. Text takes
253−`{{repo}}`, `{{number}}`, `{{title}}`, `{{url}}`, `{{actor}}`. Runs are at
254−`GET {repo}/automations/runs`; `POST {repo}/automations/{id}/runs` runs one.
255244
256245 ## Facts
257246
291280 - [Model providers](https://docs.g1t.sh/guides/models/)
292281 - [Webhooks](https://docs.g1t.sh/guides/webhooks/)
293282 - [GitHub Actions](https://docs.g1t.sh/guides/actions/)
294−- [Automations](https://docs.g1t.sh/guides/automations/)
295283 - [Usage and billing](https://docs.g1t.sh/guides/usage-and-billing/)
296284 - [Git](https://docs.g1t.sh/guides/git/)
297285 - [MCP tools](https://docs.g1t.sh/reference/mcp/)
+0−1
1212 EVENTS: ServiceBinding;
1313 INTEGRATIONS: ServiceBinding;
1414 WEBHOOKS: ServiceBinding;
15− AUTOMATIONS: ServiceBinding;
1615 ACTIONS: ServiceBinding;
1716 BLOBS: KVNamespace;
1817 }
+0−1
2020 { "binding": "EVENTS", "service": "g1t-events" },
2121 { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
2222 { "binding": "WEBHOOKS", "service": "g1t-webhooks" },
23− { "binding": "AUTOMATIONS", "service": "g1t-automations" },
2423 { "binding": "ACTIONS", "service": "g1t-actions" }
2524 ],
2625 "observability": { "enabled": true },
+1−2
11 //! Five-field cron schedules, in UTC: minute, hour, day of month, month,
22 //! day of week. Each field takes `*`, a number, a range `a-b`, a list
33 //! `a,b`, and a step `*/n` or `a-b/n`; days of the week also take `mon` to
4−//! `sun`, and months `jan` to `dec`. Shared by automations' schedules and
5−//! workflows' `on.schedule`.
4+//! `sun`, and months `jan` to `dec`. For workflows' `on.schedule`.
65
76 #[derive(Clone, Debug, PartialEq, Eq)]
87 pub struct Schedule {
+0−109
1−//! The automations service: rules in a repository's `.g1t/automations/`
2−//! that act when something happens, in the way GitHub Actions' workflows
3−//! do, but on g1t's events and with g1t's own steps.
4−//!
5−//! An automation says *on* which event (or a schedule, or by hand), *if*
6−//! which conditions hold, *do* which steps: comment, label, put an agent
7−//! on it, message the agent working on it, open, close or reopen an issue,
8−//! post to a URL. Files on the default branch are the source of truth; each
9−//! push reloads them. Every run is kept, step by step.
10−//!
11−//! Mirrors `packages/contracts/src/automations.ts`.
12−
13−use serde::{Deserialize, Serialize};
14−
15−use crate::repos::RepoPath;
16−use crate::{User, Viewer};
17−
18−/// One automation, as read from its file.
19−#[derive(Clone, Debug, Serialize, Deserialize)]
20−#[serde(rename_all = "camelCase")]
21−pub struct Automation {
22− pub id: String,
23− /// `owner/name`.
24− pub repo: String,
25− /// The file it comes from, such as `.g1t/automations/bugs.yml`.
26− pub path: String,
27− pub name: String,
28− /// What starts it, in words: `issue.opened`, `every Monday at 09:00`.
29− pub trigger: String,
30− /// Its conditions and steps, in words, one each.
31− pub conditions: Vec<String>,
32− pub steps: Vec<String>,
33− /// Whether it runs. Members can turn one off without changing its file.
34− pub enabled: bool,
35− /// Why the file could not be used, if it could not.
36− pub error: Option<String>,
37− /// Whether it can be run by hand.
38− pub manual: bool,
39− pub last_run: Option<AutomationRun>,
40−}
41−
42−/// One step of a run, and how it went.
43−#[derive(Clone, Debug, Serialize, Deserialize)]
44−#[serde(rename_all = "camelCase")]
45−pub struct StepResult {
46− pub step: String,
47− pub ok: bool,
48− pub detail: String,
49−}
50−
51−#[derive(Clone, Debug, Serialize, Deserialize)]
52−#[serde(rename_all = "camelCase")]
53−pub struct AutomationRun {
54− pub id: String,
55− pub automation_id: String,
56− pub name: String,
57− /// What started it: an event type, `schedule` or `manual`.
58− pub event: String,
59− /// The issue or pull request it acted on.
60− pub number: Option<u32>,
61− /// `succeeded`, `failed` (a step failed) or `skipped` (and why, in
62− /// `reason`).
63− pub status: String,
64− pub reason: Option<String>,
65− pub steps: Vec<StepResult>,
66− /// Who started it by hand, or who caused the event.
67− pub actor: Option<String>,
68− /// RFC 3339.
69− pub started_at: String,
70−}
71−
72−/// `list`: a repository's automations. Returns `Outcome<Vec<Automation>>`.
73−/// Anyone who can see the repository.
74−#[derive(Debug, Serialize, Deserialize)]
75−pub struct ListArgs {
76− pub repo: RepoPath,
77− pub viewer: Viewer,
78−}
79−
80−/// `runs`: a repository's latest runs, newest first, of one automation or
81−/// all. Returns `Outcome<Vec<AutomationRun>>`.
82−#[derive(Debug, Serialize, Deserialize)]
83−pub struct RunsArgs {
84− pub repo: RepoPath,
85− pub viewer: Viewer,
86− #[serde(default)]
87− pub automation: Option<String>,
88−}
89−
90−/// `run`: runs an automation now, on an issue or pull request if given.
91−/// Returns `Outcome<AutomationRun>`. Members only.
92−#[derive(Debug, Serialize, Deserialize)]
93−pub struct RunArgs {
94− pub actor: User,
95− pub repo: RepoPath,
96− pub id: String,
97− #[serde(default)]
98− pub number: Option<u32>,
99−}
100−
101−/// `set_enabled`: turns an automation on or off. Returns
102−/// `Outcome<Automation>`. Members only.
103−#[derive(Debug, Serialize, Deserialize)]
104−pub struct SetEnabledArgs {
105− pub actor: User,
106− pub repo: RepoPath,
107− pub id: String,
108− pub enabled: bool,
109−}
+0−1
55 //! this crate, never on each other's code.
66
77 pub mod actions;
8−pub mod automations;
98 pub mod billing;
109 pub mod events;
1110 pub mod identity;
+5−0
444444
445445 ## Automations and integrations
446446
447+> **2026-10-03:** g1t's own `.g1t/automations` format was built and then set
448+> aside at the user's request ("let's just copy GitHub Actions on that for the
449+> time being"). Automation on g1t is GitHub Actions workflows in
450+> `.g1t/workflows/`; the format below is kept for later.
451+
447452 An automation is **when** an event happens, **if** conditions hold, **do**
448453 something. They are defined as files in the repo (`.g1t/automations/`), the
449454 way GitHub Actions workflows are, and can also be built in the UI.
+0−47
1−import type { User, Viewer } from "./identity";
2−import type { RepoPath } from "./repos";
3−import type { Result } from "./result";
4−
5−/**
6− * Rules in a repository's `.g1t/automations/` that act when something
7− * happens. Mirrors `crates/contracts/src/automations.rs`.
8− */
9−
10−export type StepResult = { step: string; ok: boolean; detail: string };
11−
12−export type AutomationRun = {
13− id: string;
14− automationId: string;
15− name: string;
16− /** An event type, `schedule` or `manual`. */
17− event: string;
18− number: number | null;
19− status: "running" | "succeeded" | "failed" | "skipped";
20− reason: string | null;
21− steps: StepResult[];
22− actor: string | null;
23− startedAt: string;
24−};
25−
26−export type Automation = {
27− id: string;
28− repo: string;
29− path: string;
30− name: string;
31− /** What starts it, in words. */
32− trigger: string;
33− conditions: string[];
34− steps: string[];
35− enabled: boolean;
36− /** Why its file cannot be used. */
37− error: string | null;
38− manual: boolean;
39− lastRun: AutomationRun | null;
40−};
41−
42−export interface AutomationsApi {
43− list(repo: RepoPath, viewer: Viewer): Promise<Result<Automation[]>>;
44− runs(repo: RepoPath, viewer: Viewer, automation?: string): Promise<Result<AutomationRun[]>>;
45− run(actor: User, repo: RepoPath, id: string, number?: number): Promise<Result<AutomationRun>>;
46− setEnabled(actor: User, repo: RepoPath, id: string, enabled: boolean): Promise<Result<Automation>>;
47−}
+0−10
11 import type { ActionsApi } from "./actions";
2−import type { AutomationsApi } from "./automations";
32 import type { BillingApi } from "./billing";
43 import type { EventsApi } from "./events";
54 import type { IdentityApi } from "./identity";
250249 };
251250 }
252251
253−export function automationsClient(service: ServiceBinding): AutomationsApi {
254− const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
255− return {
256− list: (repo, viewer) => call("list", { repo, viewer }),
257− runs: (repo, viewer, automation) => call("runs", { repo, viewer, automation }),
258− run: (actor, repo, id, number) => call("run", { actor, repo, id, number }),
259− setEnabled: (actor, repo, id, enabled) => call("set_enabled", { actor, repo, id, enabled }),
260− };
261−}
+1−1
11 /**
22 * Every state change in g1t is published as an event. Services react to
33 * each other through events rather than direct calls, and the same stream
4− * feeds timelines, webhooks and automations.
4+ * feeds timelines, webhooks and workflows.
55 *
66 * The envelope follows CloudEvents: `type` says what happened, `subject`
77 * says to what, `data` is the type-specific payload.
+0−1
11 export * from "./actions";
2−export * from "./automations";
32 export * from "./billing";
43 export * from "./clients";
54 export * from "./events";
+1−0
171171 "run" => reply(&service.run(args(body)?).await?),
172172 "logs" => reply(&service.logs(args(body)?).await?),
173173 "dispatch" => reply(&service.dispatch(args(body)?).await?),
174+ "merge_group" => reply(&service.merge_group(args(body)?).await?),
174175 "cancel" => reply(&service.cancel(args(body)?).await?),
175176 "rerun" => reply(&service.rerun(args(body)?).await?),
176177 "set_workflow_enabled" => reply(&service.set_workflow_enabled(args(body)?).await?),
+98−0
159159 let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else {
160160 return Ok(None);
161161 };
162+ // The merge queue's states run merge_group workflows, not push ones.
163+ if git_ref.starts_with("refs/heads/g1t-queue/") {
164+ return Ok(None);
165+ }
162166 let before = data["before"].as_str();
163167 let commits = self.commits(repo, ws, after, before).await?;
164168 let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default();
637641 }
638642 }
639643
644+#[derive(serde::Deserialize)]
645+#[serde(rename_all = "camelCase")]
646+pub struct MergeGroupArgs {
647+ pub repo_id: String,
648+ pub entry: String,
649+ pub sha: String,
650+ pub head_ref: String,
651+ #[serde(default)]
652+ pub base_sha: Option<String>,
653+ pub number: u32,
654+ #[serde(default)]
655+ pub ahead: Vec<u32>,
656+}
657+
658+impl Actions {
659+ /// `merge_group`: the merge queue built a state and its checks passed.
660+ /// Starts the workflows that run `on: merge_group` on it, as GitHub's
661+ /// queue does, and says how many started; the queue waits for their
662+ /// statuses on that commit.
663+ pub async fn merge_group(&self, a: MergeGroupArgs) -> Result<Outcome<Value>> {
664+ let Some((repo, ws)) = self.repo_by_id(&a.repo_id).await? else {
665+ return Ok(Outcome::Ok(json!({ "runs": 0 })));
666+ };
667+ let read = self.read_workflows(&Self::repo_path(&repo), &ws, Some(&a.sha)).await?;
668+ let head_commit = self.commits(&repo, &ws, &a.sha, None).await?.pop();
669+ let payload = json!({
670+ "action": "checks_requested",
671+ "merge_group": {
672+ "head_sha": a.sha,
673+ "head_ref": a.head_ref,
674+ "base_sha": a.base_sha,
675+ "base_ref": format!("refs/heads/{}", repo.default_branch),
676+ "head_commit": head_commit.as_ref().map(|c| payload::commit(&repo, c)),
677+ },
678+ "repository": payload::repository(&repo),
679+ "sender": payload::user(&repo.namespace),
680+ });
681+ let mut started = 0u32;
682+ for file in read.files {
683+ let Ok(workflow) = workflow::parse(&file.source) else { continue };
684+ let Some(trigger) = workflow.trigger("merge_group") else { continue };
685+ if !trigger.wants_type(Some("checks_requested")) || self.disabled(&repo.id, &file.path).await? {
686+ continue;
687+ }
688+ // Branch filters on merge_group name the branch it merges into.
689+ if trigger.branches.is_set() && !trigger.branches.allows(&repo.default_branch) {
690+ continue;
691+ }
692+ let ahead = if a.ahead.is_empty() {
693+ String::new()
694+ } else {
695+ format!(" after {}", a.ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))
696+ };
697+ let subject = Subject {
698+ source: Self::repo_path(&repo),
699+ source_ref: Some(a.sha.clone()),
700+ git_ref: a.head_ref.clone(),
701+ sha: a.sha.clone(),
702+ head_ref: None,
703+ base_ref: Some(repo.default_branch.clone()),
704+ pull: Some(a.number),
705+ filter_ref: format!("refs/heads/{}", repo.default_branch),
706+ paths: None,
707+ compare: None,
708+ payload: payload.clone(),
709+ title: format!("Merge queue: #{}{ahead}", a.number),
710+ trusted: true,
711+ };
712+ let info = self.run_info(&repo, &workflow, "merge_group", &subject, &repo.namespace, None);
713+ let created = self
714+ .create_run(NewRun {
715+ repo: repo.clone(),
716+ path: file.path.clone(),
717+ source: file.source.clone(),
718+ workflow,
719+ info,
720+ action: Some("checks_requested".to_owned()),
721+ pull: Some(a.number),
722+ title: subject.title.clone(),
723+ inputs: Map::new(),
724+ event_key: format!("merge_group:{}:{}", a.entry, a.sha),
725+ actor_id: None,
726+ actor: None,
727+ trusted: true,
728+ })
729+ .await?;
730+ if created.is_some() {
731+ started += 1;
732+ }
733+ }
734+ Ok(Outcome::Ok(json!({ "runs": started })))
735+ }
736+}
737+
640738 fn check_refusal<T>(refused: Outcome<()>) -> Outcome<T> {
641739 match refused {
642740 Outcome::Fail(failure) => Outcome::Fail(failure),
+0−18
1−[package]
2−name = "g1t-automations"
3−version = "0.1.0"
4−edition.workspace = true
5−license.workspace = true
6−description = "Rules in a repository's .g1t/automations that act when something happens."
7−
8−[lib]
9−crate-type = ["cdylib"]
10−
11−[dependencies]
12−g1t-actions.workspace = true
13−g1t-contracts.workspace = true
14−g1t-kit.workspace = true
15−serde.workspace = true
16−serde_json.workspace = true
17−serde_yaml = "0.9"
18−worker.workspace = true
+0−63
1−-- Automations, read from repositories' .g1t/automations, and their runs.
2−-- Every timestamp is RFC 3339 UTC.
3−
4−CREATE TABLE automations (
5− id TEXT PRIMARY KEY,
6− repo_id TEXT NOT NULL,
7− -- owner/name.
8− repo TEXT NOT NULL,
9− -- The file it comes from.
10− path TEXT NOT NULL,
11− name TEXT NOT NULL,
12− -- The file as it is on the default branch.
13− source TEXT NOT NULL,
14− -- events, schedule, manual, or invalid.
15− trigger_kind TEXT NOT NULL,
16− -- For events: the types that start it, as a JSON array.
17− events TEXT NOT NULL,
18− -- Why the file cannot be used, if it cannot.
19− error TEXT,
20− -- Kept across reloads of the file: a member can turn one off.
21− enabled INTEGER NOT NULL DEFAULT 1,
22− updated_at TEXT NOT NULL,
23− UNIQUE (repo_id, path)
24−);
25−CREATE INDEX automations_by_trigger ON automations (repo_id, enabled, trigger_kind);
26−CREATE INDEX automations_scheduled ON automations (trigger_kind, enabled);
27−
28−CREATE TABLE runs (
29− id TEXT PRIMARY KEY,
30− automation_id TEXT NOT NULL,
31− repo_id TEXT NOT NULL,
32− -- What started it, unique per automation: an event's id, the minute of a
33− -- schedule, or a manual run's own key. An event runs an automation once.
34− event_key TEXT NOT NULL,
35− name TEXT NOT NULL,
36− event TEXT NOT NULL,
37− number INTEGER,
38− -- running, succeeded, failed or skipped.
39− status TEXT NOT NULL,
40− reason TEXT,
41− -- Each step's result, as JSON.
42− steps TEXT NOT NULL,
43− actor TEXT,
44− started_at TEXT NOT NULL,
45− UNIQUE (automation_id, event_key)
46−);
47−CREATE INDEX runs_by_repo ON runs (repo_id, id);
48−CREATE INDEX runs_by_automation ON runs (automation_id, id);
49−
50−-- What an automation just touched, so it does not answer its own doing.
51−CREATE TABLE effects (
52− automation_id TEXT NOT NULL,
53− repo_id TEXT NOT NULL,
54− number INTEGER NOT NULL,
55− at TEXT NOT NULL
56−);
57−CREATE INDEX effects_recent ON effects (automation_id, repo_id, number, at);
58−
59−-- Repositories whose files have been read at least once.
60−CREATE TABLE synced (
61− repo_id TEXT PRIMARY KEY,
62− at TEXT NOT NULL
63−);
+0−376
1−//! Reading an automation's file, and the rules it gives: what starts it,
2−//! the conditions, the steps, and the words in `{{ }}` that steps fill in.
3−
4−use std::collections::BTreeMap;
5−
6−use g1t_contracts::webhooks::EVENT_TYPES;
7−use serde_yaml::Value;
8−
9−use g1t_actions::cron::Schedule;
10−
11−/// What starts an automation.
12−#[derive(Clone, Debug)]
13−pub enum Trigger {
14− Events(Vec<String>),
15− Schedule { text: String, schedule: Schedule },
16− Manual,
17−}
18−
19−impl Trigger {
20− pub fn describe(&self) -> String {
21− match self {
22− Trigger::Events(events) => events.join(", "),
23− Trigger::Schedule { text, .. } => format!("on the schedule {text} (UTC)"),
24− Trigger::Manual => "by hand".to_owned(),
25− }
26− }
27−}
28−
29−/// One condition: a field, and the values any of which it may have.
30−#[derive(Clone, Debug, PartialEq, Eq)]
31−pub struct Condition {
32− pub field: String,
33− pub values: Vec<String>,
34−}
35−
36−impl Condition {
37− pub fn describe(&self) -> String {
38− let values = self.values.join(" or ");
39− match self.field.as_str() {
40− "labels" => format!("labelled {values}"),
41− "actor" => format!("caused by {values}"),
42− "branch" => format!("on {values}"),
43− field => format!("{field} is {values}"),
44− }
45− }
46−}
47−
48−#[derive(Clone, Debug, PartialEq, Eq)]
49−pub enum Step {
50− Comment(String),
51− Label(String),
52− Unlabel(String),
53− AssignAgent,
54− MessageAgent(String),
55− OpenIssue { title: String, body: String, labels: Vec<String>, assign_agent: bool },
56− CloseIssue { not_planned: bool },
57− ReopenIssue,
58− Notify { url: String, text: String },
59−}
60−
61−impl Step {
62− pub fn describe(&self) -> String {
63− match self {
64− Step::Comment(_) => "comment".to_owned(),
65− Step::Label(label) => format!("label {label}"),
66− Step::Unlabel(label) => format!("remove the label {label}"),
67− Step::AssignAgent => "put a g1t agent on it".to_owned(),
68− Step::MessageAgent(_) => "message the agent working on it".to_owned(),
69− Step::OpenIssue { title, assign_agent, .. } => {
70− format!("open the issue \u{201c}{title}\u{201d}{}", if *assign_agent { " and put an agent on it" } else { "" })
71− }
72− Step::CloseIssue { not_planned } => {
73− if *not_planned { "close it as not planned".to_owned() } else { "close it".to_owned() }
74− }
75− Step::ReopenIssue => "reopen it".to_owned(),
76− Step::Notify { url, .. } => format!("post to {}", host(url)),
77− }
78− }
79−
80− /// Whether the step acts on the issue or pull request the event is about.
81− pub fn needs_target(&self) -> bool {
82− !matches!(self, Step::OpenIssue { .. } | Step::Notify { .. })
83− }
84−}
85−
86−fn host(url: &str) -> &str {
87− url.trim_start_matches("https://").split('/').next().unwrap_or(url)
88−}
89−
90−#[derive(Clone, Debug)]
91−pub struct Definition {
92− pub name: String,
93− pub trigger: Trigger,
94− pub conditions: Vec<Condition>,
95− pub steps: Vec<Step>,
96− pub per_hour: u32,
97−}
98−
99−/// The default and the most runs an automation makes in an hour.
100−pub const DEFAULT_PER_HOUR: u32 = 30;
101−pub const MAX_PER_HOUR: u32 = 200;
102−
103−fn text(value: &Value) -> Option<String> {
104− match value {
105− Value::String(text) => Some(text.clone()),
106− Value::Number(number) => Some(number.to_string()),
107− Value::Bool(flag) => Some(flag.to_string()),
108− _ => None,
109− }
110−}
111−
112−fn texts(value: &Value) -> Result<Vec<String>, String> {
113− match value {
114− Value::Sequence(items) => items.iter().map(|item| text(item).ok_or_else(|| "a list of words".to_owned())).collect(),
115− other => text(other).map(|one| vec![one]).ok_or_else(|| "a word or a list of words".to_owned()),
116− }
117−}
118−
119−fn step(value: &Value) -> Result<Step, String> {
120− let (name, argument) = match value {
121− Value::String(name) => (name.as_str(), &Value::Null),
122− Value::Mapping(map) if map.len() == 1 => {
123− let (key, argument) = map.iter().next().expect("one entry");
124− (key.as_str().ok_or("a step's name is a word")?, argument)
125− }
126− _ => return Err("each step is a name, such as `assign_agent`, or a name and what it takes, such as `comment: Thanks!`".to_owned()),
127− };
128− let field = |key: &str| match argument {
129− Value::Mapping(map) => map.get(key).and_then(text),
130− _ => None,
131− };
132− let needs_text = |what: &str| text(argument).filter(|t| !t.trim().is_empty()).ok_or(format!("`{name}` needs {what}"));
133− Ok(match name {
134− "comment" => Step::Comment(needs_text("the comment's text")?),
135− "label" => Step::Label(needs_text("a label")?),
136− "unlabel" => Step::Unlabel(needs_text("a label")?),
137− "assign_agent" => Step::AssignAgent,
138− "message_agent" => Step::MessageAgent(needs_text("the message")?),
139− "close_issue" => Step::CloseIssue {
140− not_planned: matches!(text(argument).as_deref(), Some("not_planned")),
141− },
142− "reopen_issue" => Step::ReopenIssue,
143− "open_issue" => Step::OpenIssue {
144− title: field("title").filter(|t| !t.trim().is_empty()).ok_or("`open_issue` needs a title")?,
145− body: field("body").unwrap_or_default(),
146− labels: match argument {
147− Value::Mapping(map) => map.get("labels").map(texts).transpose()?.unwrap_or_default(),
148− _ => Vec::new(),
149− },
150− assign_agent: matches!(field("assign_agent").as_deref(), Some("true")),
151− },
152− "notify" => {
153− let url = field("url").ok_or("`notify` needs a url")?;
154− if !url.starts_with("https://") {
155− return Err("`notify` posts only to https:// addresses".to_owned());
156− }
157− Step::Notify {
158− url,
159− text: field("text").ok_or("`notify` needs text")?,
160− }
161− }
162− other => {
163− return Err(format!(
164− "there is no step called `{other}`; steps are comment, label, unlabel, assign_agent, message_agent, open_issue, close_issue, reopen_issue and notify"
165− ));
166− }
167− })
168−}
169−
170−/// Reads an automation's file. `Err` says what is wrong with it, for the
171−/// person who wrote it.
172−pub fn parse(yaml: &str, file_name: &str) -> Result<Definition, String> {
173− let root: Value = serde_yaml::from_str(yaml).map_err(|error| format!("It is not valid YAML: {error}"))?;
174− let Value::Mapping(map) = &root else {
175− return Err("An automation is a mapping with `on` and `do`.".to_owned());
176− };
177− let name = map
178− .get("name")
179− .and_then(text)
180− .unwrap_or_else(|| file_name.trim_end_matches(".yml").trim_end_matches(".yaml").replace(['-', '_'], " "));
181− let trigger = match map.get("on") {
182− None => return Err("`on` is missing: say which event starts it, a schedule, or manual.".to_owned()),
183− Some(Value::String(manual)) if manual == "manual" => Trigger::Manual,
184− Some(Value::Mapping(on)) if on.contains_key("schedule") => {
185− let text = on.get("schedule").and_then(text).ok_or("`schedule` takes a cron line, such as \"0 9 * * mon\".")?;
186− let schedule = Schedule::parse(&text).map_err(|problem| format!("The schedule does not read: {problem}."))?;
187− Trigger::Schedule { text, schedule }
188− }
189− Some(events) => {
190− let events = texts(events).map_err(|_| "`on` takes an event, a list of events, `manual`, or `schedule:`.".to_owned())?;
191− if let Some(unknown) = events.iter().find(|event| !EVENT_TYPES.contains(&event.as_str())) {
192− return Err(format!("There is no event called {unknown}. Events are {}.", EVENT_TYPES.join(", ")));
193− }
194− Trigger::Events(events)
195− }
196− };
197− let mut conditions = Vec::new();
198− if let Some(when) = map.get("if") {
199− let Value::Mapping(when) = when else {
200− return Err("`if` is a mapping of fields to the values they must have.".to_owned());
201− };
202− for (field, values) in when {
203− let field = field.as_str().ok_or("`if` keys are field names")?.to_owned();
204− let values = texts(values).map_err(|problem| format!("`if` {field}: give {problem}."))?;
205− conditions.push(Condition { field, values });
206− }
207− }
208− let steps = match map.get("do") {
209− Some(Value::Sequence(steps)) if !steps.is_empty() => steps.iter().map(step).collect::<Result<Vec<_>, _>>()?,
210− Some(single @ (Value::String(_) | Value::Mapping(_))) => vec![step(single)?],
211− _ => return Err("`do` is missing: give the steps to take, as a list.".to_owned()),
212− };
213− if matches!(trigger, Trigger::Schedule { .. })
214− && let Some(step) = steps.iter().find(|step| step.needs_target()) {
215− return Err(format!(
216− "A scheduled automation has no issue or pull request to act on, so it cannot {}. It can open_issue or notify.",
217− step.describe()
218− ));
219− }
220− let per_hour = match map.get("limits").and_then(|limits| limits.get("per_hour")) {
221− Some(value) => value
222− .as_u64()
223− .filter(|n| (1..=u64::from(MAX_PER_HOUR)).contains(n))
224− .ok_or(format!("`limits.per_hour` is a number from 1 to {MAX_PER_HOUR}."))? as u32,
225− None => DEFAULT_PER_HOUR,
226− };
227− Ok(Definition {
228− name,
229− trigger,
230− conditions,
231− steps,
232− per_hour,
233− })
234−}
235−
236−/// What a run knows, by name: for conditions and for `{{ }}` in steps.
237−#[derive(Clone, Debug, Default)]
238−pub struct Context {
239− pub vars: BTreeMap<String, String>,
240− pub labels: Vec<String>,
241−}
242−
243−impl Context {
244− pub fn set(&mut self, key: &str, value: impl Into<String>) {
245− self.vars.insert(key.to_owned(), value.into());
246− }
247−
248− /// An event's data, as `data.<field>` and, where nothing else claims
249− /// the name, as `<field>`.
250− pub fn add_data(&mut self, data: &serde_json::Value) {
251− let Some(fields) = data.as_object() else { return };
252− for (key, value) in fields {
253− let value = match value {
254− serde_json::Value::String(text) => text.clone(),
255− serde_json::Value::Number(number) => number.to_string(),
256− serde_json::Value::Bool(flag) => flag.to_string(),
257− _ => continue,
258− };
259− self.vars.entry(key.clone()).or_insert_with(|| value.clone());
260− self.vars.insert(format!("data.{key}"), value);
261− }
262− }
263−}
264−
265−/// Whether every condition holds. `labels` matches when the issue or pull
266−/// request has any of the labels; other fields compare with the context.
267−pub fn holds(conditions: &[Condition], context: &Context) -> Result<(), String> {
268− for condition in conditions {
269− let ok = if condition.field == "labels" {
270− condition.values.iter().any(|value| context.labels.iter().any(|label| label.eq_ignore_ascii_case(value)))
271− } else {
272− let actual = context.vars.get(&condition.field).or_else(|| context.vars.get(&format!("data.{}", condition.field)));
273− actual.is_some_and(|actual| condition.values.iter().any(|value| value.eq_ignore_ascii_case(actual)))
274− };
275− if !ok {
276− return Err(format!("not {}", condition.describe()));
277− }
278− }
279− Ok(())
280−}
281−
282−/// Fills `{{ name }}` with what the context knows; an unknown name is left
283−/// empty.
284−pub fn render(template: &str, context: &Context) -> String {
285− let mut out = String::with_capacity(template.len());
286− let mut rest = template;
287− while let Some(start) = rest.find("{{") {
288− out.push_str(&rest[..start]);
289− let after = &rest[start + 2..];
290− let Some(end) = after.find("}}") else {
291− out.push_str(&rest[start..]);
292− return out;
293− };
294− let name = after[..end].trim();
295− out.push_str(context.vars.get(name).map(String::as_str).unwrap_or_default());
296− rest = &after[end + 2..];
297− }
298− out.push_str(rest);
299− out
300−}
301−
302−#[cfg(test)]
303−mod tests {
304− use super::*;
305−
306− const BUGS: &str = r#"
307−name: Put an agent on new bugs
308−on: issue.opened
309−if:
310− labels: [bug, regression]
311−do:
312− - comment: "Thanks, {{actor}}. An agent is on it."
313− - assign_agent
314− - notify: { url: "https://hooks.slack.com/x", text: "{{repo}}#{{number}}: {{title}}" }
315−limits:
316− per_hour: 5
317−"#;
318−
319− #[test]
320− fn a_whole_automation_reads() {
321− let definition = parse(BUGS, "bugs.yml").unwrap();
322− assert_eq!(definition.name, "Put an agent on new bugs");
323− assert!(matches!(&definition.trigger, Trigger::Events(events) if events == &vec!["issue.opened".to_owned()]));
324− assert_eq!(definition.conditions, vec![Condition { field: "labels".into(), values: vec!["bug".into(), "regression".into()] }]);
325− assert_eq!(definition.steps.len(), 3);
326− assert_eq!(definition.steps[1], Step::AssignAgent);
327− assert_eq!(definition.per_hour, 5);
328− assert_eq!(definition.steps[2].describe(), "post to hooks.slack.com");
329− }
330−
331− #[test]
332− fn mistakes_are_explained() {
333− let problem = |yaml: &str| parse(yaml, "x.yml").unwrap_err();
334− assert!(problem("on: issue.exploded\ndo: [assign_agent]").contains("no event called issue.exploded"));
335− assert!(problem("on: issue.opened").contains("`do` is missing"));
336− assert!(problem("do: [assign_agent]").contains("`on` is missing"));
337− assert!(problem("on: issue.opened\ndo: [dance]").contains("no step called `dance`"));
338− assert!(problem("on: issue.opened\ndo: [{notify: {url: 'http://x', text: hi}}]").contains("https://"));
339− assert!(problem("on: { schedule: '0 9 * * mon' }\ndo: [assign_agent]").contains("cannot put a g1t agent on it"));
340− assert!(problem("on: { schedule: 'often' }\ndo: [{open_issue: {title: x}}]").contains("schedule does not read"));
341− assert!(problem("on: issue.opened\ndo: [assign_agent]\nlimits: { per_hour: 0 }").contains("per_hour"));
342− assert!(problem(": : :").contains("not valid YAML"));
343− }
344−
345− #[test]
346− fn schedules_and_manual_runs_read() {
347− let weekly = parse("on: { schedule: '0 9 * * mon' }\ndo:\n - open_issue: { title: Weekly tidy, labels: chore, assign_agent: true }", "weekly.yml").unwrap();
348− assert!(matches!(weekly.trigger, Trigger::Schedule { .. }));
349− assert_eq!(weekly.name, "weekly");
350− assert!(matches!(&weekly.steps[0], Step::OpenIssue { labels, assign_agent: true, .. } if labels == &vec!["chore".to_owned()]));
351− assert!(matches!(parse("on: manual\ndo:\n comment: hi", "m.yml").unwrap().trigger, Trigger::Manual));
352− }
353−
354− #[test]
355− fn conditions_check_labels_fields_and_data() {
356− let mut context = Context::default();
357− context.labels = vec!["Bug".into()];
358− context.set("actor", "ada");
359− context.add_data(&serde_json::json!({ "status": "failed", "number": 7 }));
360− let condition = |field: &str, values: &[&str]| Condition { field: field.into(), values: values.iter().map(|v| v.to_string()).collect() };
361− assert!(holds(&[condition("labels", &["bug"]), condition("status", &["failed", "errored"])], &context).is_ok());
362− assert!(holds(&[condition("data.status", &["failed"])], &context).is_ok());
363− assert_eq!(holds(&[condition("actor", &["grace"])], &context).unwrap_err(), "not caused by grace");
364− assert!(holds(&[condition("verdict", &["approve"])], &context).is_err());
365− }
366−
367− #[test]
368− fn templates_fill_in_what_is_known() {
369− let mut context = Context::default();
370− context.set("repo", "acme/web");
371− context.set("number", "12");
372− assert_eq!(render("{{repo}}#{{ number }} by {{actor}}", &context), "acme/web#12 by ");
373− assert_eq!(render("no braces", &context), "no braces");
374− assert_eq!(render("half {{open", &context), "half {{open");
375− }
376−}
+0−852
1−//! The automations service: rules in a repository's `.g1t/automations/`
2−//! that act when something happens. See `g1t_contracts::automations` for
3−//! the methods, and `definition` for what a file may say.
4−//!
5−//! The files on a repository's default branch are read again on every push
6−//! to it. An event from the bus runs each enabled automation that wants it;
7−//! the minute's sweep runs scheduled ones; a member can run any by hand.
8−//! Every run is recorded with each step's result, and every run obeys three
9−//! rules: an event is handled once per automation, an automation makes at
10−//! most so many runs an hour, and an automation does not answer what it
11−//! itself just did.
12−//!
13−//! Automations act as their workspace: what they write is the workspace's,
14−//! and says which automation wrote it.
15−
16−mod definition;
17−
18−use g1t_contracts::automations::*;
19−use g1t_contracts::events::Event;
20−use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, SlugArgs, UsernamesArgs, Workspace};
21−use g1t_contracts::repos::{BlobArgs, BlobView, EntryKind, GetArgs, PathByIdArgs, Repo, RepoPath, TreeArgs, TreeView};
22−use g1t_contracts::time::rfc3339;
23−use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs};
24−use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, Viewer, new_id};
25−use g1t_kit::{args, now_ms, reply, rpc_method};
26−use serde::Deserialize;
27−use serde_json::{Value, json};
28−use worker::wasm_bindgen::JsValue;
29−use worker::{
30− Context as WorkerContext, D1Database, Env, Fetch, Fetcher, Headers, MessageBatch, MessageExt, Method, Request, RequestInit,
31− Response, Result, ScheduleContext, ScheduledEvent, event,
32−};
33−
34−use definition::{Context, Definition, Step, Trigger};
35−
36−/// Where automations live in a repository.
37−const FOLDER: &str = ".g1t/automations";
38−/// The most automation files read from a repository.
39−const MAX_FILES: usize = 50;
40−const RUNS_SHOWN: u32 = 50;
41−/// How long an automation's own effect is remembered, so it does not answer it.
42−const OWN_EFFECT_MS: u64 = 10 * 60 * 1000;
43−const SITE: &str = "https://g1t.sh";
44−
45−#[derive(Deserialize)]
46−struct AutomationRow {
47− id: String,
48− repo_id: String,
49− repo: String,
50− path: String,
51− name: String,
52− source: String,
53− error: Option<String>,
54− enabled: u32,
55−}
56−
57−impl AutomationRow {
58− fn definition(&self) -> std::result::Result<Definition, String> {
59− if let Some(error) = &self.error {
60− return Err(error.clone());
61− }
62− definition::parse(&self.source, self.path.rsplit('/').next().unwrap_or(&self.path))
63− }
64−
65− fn repo_path(&self) -> RepoPath {
66− let (namespace, name) = self.repo.split_once('/').unwrap_or((&self.repo, ""));
67− RepoPath {
68− namespace: namespace.to_owned(),
69− name: name.to_owned(),
70− }
71− }
72−}
73−
74−#[derive(Deserialize)]
75−struct RunRow {
76− id: String,
77− automation_id: String,
78− name: String,
79− event: String,
80− number: Option<u32>,
81− status: String,
82− reason: Option<String>,
83− steps: String,
84− actor: Option<String>,
85− started_at: String,
86−}
87−
88−impl From<RunRow> for AutomationRun {
89− fn from(row: RunRow) -> Self {
90− AutomationRun {
91− id: row.id,
92− automation_id: row.automation_id,
93− name: row.name,
94− event: row.event,
95− number: row.number,
96− status: row.status,
97− reason: row.reason,
98− steps: serde_json::from_str(&row.steps).unwrap_or_default(),
99− actor: row.actor,
100− started_at: row.started_at,
101− }
102− }
103−}
104−
105−#[derive(Deserialize)]
106−struct Count {
107− n: u32,
108−}
109−
110−/// What started a run.
111−struct Source {
112− /// Unique per automation: an event's id, the minute, or a manual run.
113− key: String,
114− event: String,
115− number: Option<u32>,
116− /// The id of whoever caused it.
117− actor_id: Option<String>,
118− /// The event's data, for conditions and `{{data.*}}`.
119− data: Value,
120−}
121−
122−fn optional(value: Option<&str>) -> JsValue {
123− value.map_or(JsValue::NULL, JsValue::from)
124−}
125−
126−fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
127− Outcome::fail(code, message)
128−}
129−
130−fn summary(row: &AutomationRow, last_run: Option<AutomationRun>) -> Automation {
131− let parsed = row.definition();
132− Automation {
133− id: row.id.clone(),
134− repo: row.repo.clone(),
135− path: row.path.clone(),
136− name: row.name.clone(),
137− trigger: parsed.as_ref().map(|d| d.trigger.describe()).unwrap_or_default(),
138− conditions: parsed.as_ref().map(|d| d.conditions.iter().map(|c| c.describe()).collect()).unwrap_or_default(),
139− steps: parsed.as_ref().map(|d| d.steps.iter().map(Step::describe).collect()).unwrap_or_default(),
140− enabled: row.enabled != 0,
141− error: parsed.as_ref().err().cloned(),
142− manual: parsed.is_ok(),
143− last_run,
144− }
145−}
146−
147−struct Automations {
148− db: D1Database,
149− repos: Fetcher,
150− work: Fetcher,
151− identity: Fetcher,
152− runner: Fetcher,
153−}
154−
155−impl Automations {
156− fn new(env: &Env) -> Result<Self> {
157− Ok(Automations {
158− db: env.d1("DB")?,
159− repos: env.service("REPOS")?,
160− work: env.service("WORK")?,
161− identity: env.service("IDENTITY")?,
162− runner: env.service("RUNNER")?,
163− })
164− }
165−
166− /// The workspace itself, as automations act.
167− async fn workspace_actor(&self, slug: &str) -> Result<Option<User>> {
168− let workspace: Option<Workspace> = g1t_kit::call(&self.identity, "get_workspace", &SlugArgs { slug: slug.to_owned() }).await?;
169− Ok(workspace.map(|workspace| User {
170− id: workspace.id,
171− username: workspace.slug.clone(),
172− kind: PrincipalKind::Workspace,
173− verified: true,
174− workspaces: vec![Membership {
175− slug: workspace.slug,
176− role: Role::Member,
177− }],
178− }))
179− }
180−
181− async fn visible_repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Option<Repo>> {
182− let found: Outcome<Repo> = g1t_kit::call(
183− &self.repos,
184− "get",
185− &GetArgs {
186− path: path.clone(),
187− viewer: viewer.clone(),
188− },
189− )
190− .await?;
191− Ok(found.into_result().ok().filter(|repo| repo.fork_of.is_none()))
192− }
193−
194− // --- Reading the files ----------------------------------------------------
195−
196− /// Reads a repository's automation files from its default branch, and
197− /// keeps the database in step with them.
198− async fn sync(&self, path: &RepoPath) -> Result<()> {
199− let Some(actor) = self.workspace_actor(&path.namespace).await? else {
200− return Ok(());
201− };
202− let viewer = Some(actor);
203− let tree: Outcome<TreeView> = g1t_kit::call(
204− &self.repos,
205− "tree",
206− &TreeArgs {
207− path: path.clone(),
208− viewer: viewer.clone(),
209− git_ref: None,
210− tree_path: FOLDER.to_owned(),
211− },
212− )
213− .await?;
214− let Some(repo) = self.visible_repo(path, &viewer).await? else {
215− return Ok(());
216− };
217− let entries = match tree {
218− Outcome::Ok(tree) => tree.entries,
219− // No folder: no automations.
220− Outcome::Fail(_) => Vec::new(),
221− };
222− let files: Vec<String> = entries
223− .into_iter()
224− .filter(|entry| matches!(entry.kind, EntryKind::Blob | EntryKind::Exec))
225− .map(|entry| entry.name)
226− .filter(|name| name.ends_with(".yml") || name.ends_with(".yaml"))
227− .take(MAX_FILES)
228− .collect();
229− let full_name = format!("{}/{}", repo.namespace, repo.name);
230− let now = rfc3339(now_ms());
231− let mut kept = Vec::new();
232− for file in &files {
233− let file_path = format!("{FOLDER}/{file}");
234− let blob: Outcome<BlobView> = g1t_kit::call(
235− &self.repos,
236− "blob",
237− &BlobArgs {
238− path: path.clone(),
239− viewer: viewer.clone(),
240− git_ref: repo.default_branch.clone(),
241− file_path: file_path.clone(),
242− },
243− )
244− .await?;
245− let source = match blob {
246− Outcome::Ok(BlobView { text: Some(text), .. }) => text,
247− _ => continue,
248− };
249− let parsed = definition::parse(&source, file);
250− let (name, error, kind, events) = match &parsed {
251− Ok(definition) => (
252− definition.name.clone(),
253− None,
254− match &definition.trigger {
255− Trigger::Events(_) => "events",
256− Trigger::Schedule { .. } => "schedule",
257− Trigger::Manual => "manual",
258− },
259− match &definition.trigger {
260− Trigger::Events(events) => events.clone(),
261− _ => Vec::new(),
262− },
263− ),
264− Err(problem) => (file.clone(), Some(problem.clone()), "invalid", Vec::new()),
265− };
266− self.db
267− .prepare(
268− "INSERT INTO automations (id, repo_id, repo, path, name, source, trigger_kind, events, error, updated_at)
269− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
270− ON CONFLICT (repo_id, path) DO UPDATE SET
271− repo = excluded.repo, name = excluded.name, source = excluded.source,
272− trigger_kind = excluded.trigger_kind, events = excluded.events,
273− error = excluded.error, updated_at = excluded.updated_at",
274− )
275− .bind(&[
276− new_id("aut", now_ms()).into(),
277− repo.id.as_str().into(),
278− full_name.as_str().into(),
279− file_path.as_str().into(),
280− name.into(),
281− source.into(),
282− kind.into(),
283− serde_json::to_string(&events)?.into(),
284− optional(error.as_deref()),
285− now.as_str().into(),
286− ])?
287− .run()
288− .await?;
289− kept.push(file_path);
290− }
291− // Files that are gone take their automations with them.
292− let mut statements = Vec::new();
293− let existing = self
294− .db
295− .prepare("SELECT * FROM automations WHERE repo_id = ?")
296− .bind(&[repo.id.as_str().into()])?
297− .all()
298− .await?
299− .results::<AutomationRow>()?;
300− for row in existing.iter().filter(|row| !kept.contains(&row.path)) {
301− statements.push(self.db.prepare("DELETE FROM automations WHERE id = ?").bind(&[row.id.as_str().into()])?);
302− }
303− statements.push(
304− self.db
305− .prepare("INSERT OR REPLACE INTO synced (repo_id, at) VALUES (?, ?)")
306− .bind(&[repo.id.as_str().into(), now.into()])?,
307− );
308− self.db.batch(statements).await?;
309− Ok(())
310− }
311−
312− async fn synced(&self, repo_id: &str) -> Result<bool> {
313− Ok(self
314− .db
315− .prepare("SELECT COUNT(*) AS n FROM synced WHERE repo_id = ?")
316− .bind(&[repo_id.into()])?
317− .first::<Count>(None)
318− .await?
319− .is_some_and(|count| count.n > 0))
320− }
321−
322− // --- Reading and managing ---------------------------------------------------
323−
324− async fn last_run(&self, automation_id: &str) -> Result<Option<AutomationRun>> {
325− Ok(self
326− .db
327− .prepare("SELECT * FROM runs WHERE automation_id = ? ORDER BY id DESC LIMIT 1")
328− .bind(&[automation_id.into()])?
329− .first::<RunRow>(None)
330− .await?
331− .map(AutomationRun::from))
332− }
333−
334− async fn list(&self, a: ListArgs) -> Result<Outcome<Vec<Automation>>> {
335− let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
336− return Ok(fail(FailureCode::NotFound, "There is no such repository."));
337− };
338− if !self.synced(&repo.id).await? {
339− self.sync(&RepoPath {
340− namespace: repo.namespace.clone(),
341− name: repo.name.clone(),
342− })
343− .await?;
344− }
345− let rows = self
346− .db
347− .prepare("SELECT * FROM automations WHERE repo_id = ? ORDER BY path")
348− .bind(&[repo.id.as_str().into()])?
349− .all()
350− .await?
351− .results::<AutomationRow>()?;
352− let mut out = Vec::with_capacity(rows.len());
353− for row in &rows {
354− out.push(summary(row, self.last_run(&row.id).await?));
355− }
356− Ok(Outcome::Ok(out))
357− }
358−
359− async fn runs(&self, a: RunsArgs) -> Result<Outcome<Vec<AutomationRun>>> {
360− let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
361− return Ok(fail(FailureCode::NotFound, "There is no such repository."));
362− };
363− let rows = match &a.automation {
364− Some(id) => self
365− .db
366− .prepare("SELECT * FROM runs WHERE repo_id = ? AND automation_id = ? ORDER BY id DESC LIMIT ?")
367− .bind(&[repo.id.as_str().into(), id.as_str().into(), RUNS_SHOWN.into()])?,
368− None => self
369− .db
370− .prepare("SELECT * FROM runs WHERE repo_id = ? ORDER BY id DESC LIMIT ?")
371− .bind(&[repo.id.as_str().into(), RUNS_SHOWN.into()])?,
372− }
373− .all()
374− .await?
375− .results::<RunRow>()?;
376− Ok(Outcome::Ok(rows.into_iter().map(AutomationRun::from).collect()))
377− }
378−
379− /// The automation, if the actor is a member of its repository's workspace.
380− async fn manageable(&self, actor: &User, repo: &RepoPath, id: &str) -> Result<Outcome<AutomationRow>> {
381− if actor.kind == PrincipalKind::Agent || !actor.is_member(&repo.namespace.to_lowercase()) {
382− return Ok(fail(FailureCode::Forbidden, format!("Only members of {} can run or change its automations.", repo.namespace)));
383− }
384− let row = self
385− .db
386− .prepare("SELECT * FROM automations WHERE id = ? AND lower(repo) = lower(?)")
387− .bind(&[id.into(), format!("{}/{}", repo.namespace, repo.name).into()])?
388− .first::<AutomationRow>(None)
389− .await?;
390− Ok(row.map_or_else(|| fail(FailureCode::NotFound, "No such automation."), Outcome::Ok))
391− }
392−
393− async fn set_enabled(&self, a: SetEnabledArgs) -> Result<Outcome<Automation>> {
394− let row = match self.manageable(&a.actor, &a.repo, &a.id).await? {
395− Outcome::Ok(row) => row,
396− Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)),
397− };
398− self.db
399− .prepare("UPDATE automations SET enabled = ? WHERE id = ?")
400− .bind(&[(a.enabled as u32).into(), row.id.as_str().into()])?
401− .run()
402− .await?;
403− let row = AutomationRow {
404− enabled: a.enabled as u32,
405− ..row
406− };
407− Ok(Outcome::Ok(summary(&row, self.last_run(&row.id).await?)))
408− }
409−
410− async fn run_now(&self, a: RunArgs) -> Result<Outcome<AutomationRun>> {
411− let row = match self.manageable(&a.actor, &a.repo, &a.id).await? {
412− Outcome::Ok(row) => row,
413− Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)),
414− };
415− if let Err(problem) = row.definition() {
416− return Ok(fail(FailureCode::Invalid, format!("Its file has a problem: {problem}")));
417− }
418− let source = Source {
419− key: format!("manual:{}", new_id("run", now_ms())),
420− event: "manual".to_owned(),
421− number: a.number,
422− actor_id: Some(a.actor.id.clone()),
423− data: json!({}),
424− };
425− let id = self.run(&row, source).await?;
426− Ok(match id {
427− Some(id) => self
428− .db
429− .prepare("SELECT * FROM runs WHERE id = ?")
430− .bind(&[id.as_str().into()])?
431− .first::<RunRow>(None)
432− .await?
433− .map_or_else(|| fail(FailureCode::NotFound, "The run was not recorded."), |row| Outcome::Ok(row.into())),
434− None => fail(FailureCode::Conflict, "It did not run."),
435− })
436− }
437−
438− // --- Running ------------------------------------------------------------------
439−
440− async fn on_event(&self, event: &Event) -> Result<()> {
441− let Some(repo_id) = event.repo_id.as_deref() else {
442− return Ok(());
443− };
444− // A push to the default branch may have changed the files.
445− if event.kind == "git.push" && event.data["defaultBranch"].as_bool() == Some(true) {
446− let path: Option<RepoPath> = g1t_kit::call(&self.repos, "path_by_id", &PathByIdArgs { id: repo_id.to_owned() }).await?;
447− if let Some(path) = path {
448− self.sync(&path).await?;
449− }
450− }
451− let rows = self
452− .db
453− .prepare("SELECT * FROM automations WHERE repo_id = ? AND enabled = 1 AND trigger_kind = 'events'")
454− .bind(&[repo_id.into()])?
455− .all()
456− .await?
457− .results::<AutomationRow>()?;
458− for row in rows {
459− let Ok(definition) = row.definition() else { continue };
460− let Trigger::Events(events) = &definition.trigger else { continue };
461− if !events.contains(&event.kind) {
462− continue;
463− }
464− let source = Source {
465− key: event.id.clone(),
466− event: event.kind.clone(),
467− number: event.data["number"].as_u64().map(|n| n as u32),
468− actor_id: event.actor.clone(),
469− data: event.data.clone(),
470− };
471− self.run(&row, source).await?;
472− }
473− Ok(())
474− }
475−
476− /// Runs scheduled automations whose schedule fires this minute.
477− async fn on_minute(&self, now: u64) -> Result<()> {
478− let minute = now / 60_000 * 60_000;
479− let rows = self
480− .db
481− .prepare("SELECT * FROM automations WHERE enabled = 1 AND trigger_kind = 'schedule'")
482− .all()
483− .await?
484− .results::<AutomationRow>()?;
485− for row in rows {
486− let Ok(definition) = row.definition() else { continue };
487− let Trigger::Schedule { schedule, .. } = &definition.trigger else { continue };
488− if schedule.fires_at(minute) {
489− let source = Source {
490− key: format!("schedule:{minute}"),
491− event: "schedule".to_owned(),
492− number: None,
493− actor_id: None,
494− data: json!({}),
495− };
496− self.run(&row, source).await?;
497− }
498− }
499− Ok(())
500− }
501−
502− /// One run of an automation: recorded once, checked against its rules
503− /// and conditions, then its steps in order until one fails.
504− async fn run(&self, row: &AutomationRow, source: Source) -> Result<Option<String>> {
505− let Ok(definition) = row.definition() else {
506− return Ok(None);
507− };
508− let now = now_ms();
509− let run_id = new_id("arn", now);
510− let claimed = self
511− .db
512− .prepare(
513− "INSERT OR IGNORE INTO runs (id, automation_id, repo_id, event_key, name, event, number, status, steps, started_at)
514− VALUES (?, ?, ?, ?, ?, ?, ?, 'running', '[]', ?) RETURNING id",
515− )
516− .bind(&[
517− run_id.as_str().into(),
518− row.id.as_str().into(),
519− row.repo_id.as_str().into(),
520− source.key.as_str().into(),
521− definition.name.as_str().into(),
522− source.event.as_str().into(),
523− source.number.map_or(JsValue::NULL, JsValue::from),
524− rfc3339(now).into(),
525− ])?
526− .first::<Value>(None)
527− .await?;
528− if claimed.is_none() {
529− return Ok(None);
530− }
531− let repo = row.repo_path();
532− let Some(actor) = self.workspace_actor(&repo.namespace).await? else {
533− self.finish(&run_id, "skipped", Some("The workspace no longer exists."), &[], None).await?;
534− return Ok(Some(run_id));
535− };
536−
537− // Who caused it, by name.
538− let actor_name = match &source.actor_id {
539− Some(id) if id == AGENT_ID => Some(AGENT_NAME.to_owned()),
540− Some(id) => {
541− let names: std::collections::HashMap<String, String> =
542− g1t_kit::call(&self.identity, "usernames", &UsernamesArgs { ids: vec![id.clone()] }).await?;
543− names.get(id).cloned()
544− }
545− None => None,
546− };
547−
548− // Not in answer to its own doing.
549− if source.actor_id.as_deref() == Some(actor.id.as_str())
550− && let Some(number) = source.number
551− {
552− let recent = self
553− .db
554− .prepare("SELECT COUNT(*) AS n FROM effects WHERE automation_id = ? AND repo_id = ? AND number = ? AND at > ?")
555− .bind(&[
556− row.id.as_str().into(),
557− row.repo_id.as_str().into(),
558− number.into(),
559− rfc3339(now.saturating_sub(OWN_EFFECT_MS)).into(),
560− ])?
561− .first::<Count>(None)
562− .await?
563− .is_some_and(|count| count.n > 0);
564− if recent {
565− let reason = format!(
566− "An automation made this change, and this one changed #{number} in the last 10 minutes, so it did not answer: that could loop."
567− );
568− self.finish(&run_id, "skipped", Some(&reason), &[], actor_name.as_deref()).await?;
569− return Ok(Some(run_id));
570− }
571− }
572−
573− // At most so many runs an hour.
574− let this_hour = self
575− .db
576− .prepare("SELECT COUNT(*) AS n FROM runs WHERE automation_id = ? AND status IN ('succeeded', 'failed') AND started_at > ?")
577− .bind(&[row.id.as_str().into(), rfc3339(now.saturating_sub(60 * 60 * 1000)).into()])?
578− .first::<Count>(None)
579− .await?
580− .map_or(0, |count| count.n);
581− if this_hour >= definition.per_hour {
582− let reason = format!("It has run {} times in the last hour, its limit.", definition.per_hour);
583− self.finish(&run_id, "skipped", Some(&reason), &[], actor_name.as_deref()).await?;
584− return Ok(Some(run_id));
585− }
586−
587− // What the run knows.
588− let mut context = Context::default();
589− context.set("repo", &row.repo);
590− context.set("event", &source.event);
591− context.set("automation", &definition.name);
592− if let Some(name) = &actor_name {
593− context.set("actor", name);
594− }
595− if let Some(branch) = source.data["ref"].as_str().and_then(|r| r.strip_prefix("refs/heads/")) {
596− context.set("branch", branch);
597− }
598− context.add_data(&source.data);
599− // The issue or pull request it is about.
600− let mut target_issue: Option<u32> = None;
601− let mut target_pull: Option<u32> = None;
602− if let Some(number) = source.number {
603− context.set("number", number.to_string());
604− let view = ViewArgs {
605− repo: repo.clone(),
606− number,
607− viewer: Some(actor.clone()),
608− after_seq: 0,
609− };
610− let issue: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view).await?;
611− if let Outcome::Ok(detail) = issue {
612− context.set("title", &detail.issue.title);
613− context.set("url", format!("{SITE}/{}/issues/{number}", row.repo));
614− context.labels = detail.issue.labels.clone();
615− target_issue = Some(number);
616− } else {
617− let pull: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view).await?;
618− if let Outcome::Ok(detail) = pull {
619− context.set("title", &detail.pull.title);
620− context.set("url", format!("{SITE}/{}/pull/{number}", row.repo));
621− if let Some(issue) = &detail.issue {
622− context.labels = issue.labels.clone();
623− }
624− target_pull = Some(number);
625− target_issue = detail.pull.issue;
626− }
627− }
628− }
629− if let Err(reason) = definition::holds(&definition.conditions, &context) {
630− let reason = format!("Its conditions did not hold: {reason}.");
631− self.finish(&run_id, "skipped", Some(&reason), &[], actor_name.as_deref()).await?;
632− return Ok(Some(run_id));
633− }
634−
635− let mut results: Vec<StepResult> = Vec::new();
636− let mut failed = false;
637− for step in &definition.steps {
638− if failed {
639− results.push(StepResult {
640− step: step.describe(),
641− ok: false,
642− detail: "Not run: an earlier step failed.".to_owned(),
643− });
644− continue;
645− }
646− let outcome = self
647− .step(step, &definition, &repo, &actor, &mut context, target_issue, target_pull)
648− .await
649− .unwrap_or_else(|error| Err(format!("g1t could not do it: {error}")));
650− failed = outcome.is_err();
651− results.push(StepResult {
652− step: step.describe(),
653− ok: outcome.is_ok(),
654− detail: outcome.unwrap_or_else(|problem| problem),
655− });
656− }
657− // Remember what it touched, so it does not answer itself.
658− if let Some(number) = target_pull.or(target_issue) {
659− self.db
660− .prepare("INSERT INTO effects (automation_id, repo_id, number, at) VALUES (?, ?, ?, ?)")
661− .bind(&[row.id.as_str().into(), row.repo_id.as_str().into(), number.into(), rfc3339(now_ms()).into()])?
662− .run()
663− .await?;
664− }
665− self.finish(&run_id, if failed { "failed" } else { "succeeded" }, None, &results, actor_name.as_deref())
666− .await?;
667− Ok(Some(run_id))
668− }
669−
670− async fn finish(&self, run_id: &str, status: &str, reason: Option<&str>, steps: &[StepResult], actor: Option<&str>) -> Result<()> {
671− self.db
672− .prepare("UPDATE runs SET status = ?, reason = ?, steps = ?, actor = ? WHERE id = ?")
673− .bind(&[status.into(), optional(reason), serde_json::to_string(steps)?.into(), optional(actor), run_id.into()])?
674− .run()
675− .await?;
676− Ok(())
677− }
678−
679− /// One step. `Ok` with what it did, `Err` with why it could not.
680− #[allow(clippy::too_many_arguments)]
681− async fn step(
682− &self,
683− step: &Step,
684− definition: &Definition,
685− repo: &RepoPath,
686− actor: &User,
687− context: &mut Context,
688− issue: Option<u32>,
689− pull: Option<u32>,
690− ) -> Result<std::result::Result<String, String>> {
691− let render = |text: &str| definition::render(text, context);
692− let signed = |text: &str| format!("{}\n\n<sub>From the automation **{}**.</sub>", render(text), definition.name);
693− let target = pull.or(issue);
694− let outcome = |result: Outcome<Value>, done: String| match result {
695− Outcome::Ok(_) => Ok(done),
696− Outcome::Fail(refused) => Err(refused.message),
697− };
698− Ok(match step {
699− Step::Comment(text) => {
700− let Some(number) = target else { return Ok(Err("There is no issue or pull request to comment on.".to_owned())) };
701− let result = g1t_kit::call(
702− &self.work,
703− "add_comment",
704− &json!({ "actor": actor, "repo": repo, "number": number, "body": signed(text) }),
705− )
706− .await?;
707− outcome(result, format!("Commented on #{number}."))
708− }
709− Step::Label(label) | Step::Unlabel(label) => {
710− let Some(number) = issue else {
711− return Ok(Err("Labels belong to issues, and this is not about one.".to_owned()));
712− };
713− let mut labels = context.labels.clone();
714− let adding = matches!(step, Step::Label(_));
715− labels.retain(|existing| !existing.eq_ignore_ascii_case(label));
716− if adding {
717− labels.push(label.clone());
718− }
719− let result = g1t_kit::call(&self.work, "update_issue", &json!({ "actor": actor, "repo": repo, "number": number, "labels": labels }))
720− .await?;
721− context.labels = labels;
722− outcome(result, format!("{} {label} on #{number}.", if adding { "Labelled" } else { "Removed the label" }))
723− }
724− Step::AssignAgent => {
725− let Some(number) = issue else {
726− return Ok(Err("An agent is put on an issue, and this is not about one.".to_owned()));
727− };
728− let result: Outcome<Value> = g1t_kit::call(&self.runner, "run", &json!({ "actor": actor, "repo": repo, "issue": number })).await?;
729− match result {
730− Outcome::Ok(pull) => Ok(format!("Put a g1t agent on #{number}: pull request #{}.", pull["number"])),
731− Outcome::Fail(refused) => Err(refused.message),
732− }
733− }
734− Step::MessageAgent(text) => {
735− let Some(number) = pull else {
736− return Ok(Err("Agents are messaged on a pull request, and this is not about one.".to_owned()));
737− };
738− let result = g1t_kit::call(
739− &self.work,
740− "message_agent",
741− &json!({ "actor": actor, "repo": repo, "number": number, "body": render(text) }),
742− )
743− .await?;
744− outcome(result, format!("Messaged the agent on #{number}."))
745− }
746− Step::OpenIssue { title, body, labels, assign_agent } => {
747− let opened: Outcome<Value> = g1t_kit::call(
748− &self.work,
749− "open_issue",
750− &json!({
751− "actor": actor, "repo": repo, "title": render(title), "body": signed(body),
752− "labels": labels, "checks": [],
753− }),
754− )
755− .await?;
756− let number = match opened {
757− Outcome::Ok(issue) => issue["number"].as_u64().unwrap_or_default() as u32,
758− Outcome::Fail(refused) => return Ok(Err(refused.message)),
759− };
760− context.set("opened", number.to_string());
761− if *assign_agent {
762− let started: Outcome<Value> =
763− g1t_kit::call(&self.runner, "run", &json!({ "actor": actor, "repo": repo, "issue": number })).await?;
764− if let Outcome::Fail(refused) = started {
765− return Ok(Err(format!("Opened #{number}, but no agent could start: {}", refused.message)));
766− }
767− Ok(format!("Opened #{number} and put a g1t agent on it."))
768− } else {
769− Ok(format!("Opened #{number}."))
770− }
771− }
772− Step::CloseIssue { not_planned } => {
773− let Some(number) = issue else { return Ok(Err("There is no issue to close.".to_owned())) };
774− let reason = if *not_planned { "not_planned" } else { "completed" };
775− let result = g1t_kit::call(&self.work, "close_issue", &json!({ "actor": actor, "repo": repo, "number": number, "reason": reason }))
776− .await?;
777− outcome(result, format!("Closed #{number}."))
778− }
779− Step::ReopenIssue => {
780− let Some(number) = issue else { return Ok(Err("There is no issue to reopen.".to_owned())) };
781− let result = g1t_kit::call(&self.work, "reopen_issue", &json!({ "actor": actor, "repo": repo, "number": number })).await?;
782− outcome(result, format!("Reopened #{number}."))
783− }
784− Step::Notify { url, text } => notify(url, &render(text)).await,
785− })
786− }
787−}
788−
789−/// Posts a message, in the shape Slack's, Discord's and most chat tools'
790−/// incoming webhooks take.
791−async fn notify(url: &str, text: &str) -> std::result::Result<String, String> {
792− let host = url.trim_start_matches("https://").split(['/', ':']).next().unwrap_or_default().to_ascii_lowercase();
793− if host == "localhost" || host.ends_with(".local") || host.ends_with(".internal") || host.parse::<std::net::IpAddr>().is_ok() {
794− return Err("notify posts only to public addresses by name.".to_owned());
795− }
796− let send = async {
797− let headers = Headers::new();
798− headers.set("content-type", "application/json")?;
799− headers.set("user-agent", "g1t-automations/1")?;
800− let mut init = RequestInit::new();
801− init.with_method(Method::Post)
802− .with_headers(headers)
803− .with_body(Some(json!({ "text": text, "content": text }).to_string().into()));
804− let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?;
805− Ok::<(u16, String), worker::Error>((response.status_code(), response.text().await.unwrap_or_default()))
806− };
807− match send.await {
808− Ok((status, _)) if (200..300).contains(&status) => Ok(format!("Posted to {host}.")),
809− Ok((status, body)) => Err(format!("{host} answered {status}: {}", body.chars().take(200).collect::<String>())),
810− Err(error) => Err(format!("{host} could not be reached: {error}")),
811− }
812−}
813−
814−#[event(fetch)]
815−async fn fetch(mut request: Request, env: Env, _ctx: WorkerContext) -> Result<Response> {
816− let Some(method) = rpc_method(&request) else {
817− return Response::error("Not found", 404);
818− };
819− let body: Value = request.json().await?;
820− let service = Automations::new(&env)?;
821− match method.as_str() {
822− "list" => reply(&service.list(args(body)?).await?),
823− "runs" => reply(&service.runs(args(body)?).await?),
824− "run" => reply(&service.run_now(args(body)?).await?),
825− "set_enabled" => reply(&service.set_enabled(args(body)?).await?),
826− _ => Response::error("Unknown method", 404),
827− }
828−}
829−
830−/// Events from the bus, on this service's own queue.
831−#[event(queue)]
832−async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: WorkerContext) -> Result<()> {
833− let service = Automations::new(&env)?;
834− for message in batch.messages()? {
835− service.on_event(message.body()).await?;
836− message.ack();
837− }
838− Ok(())
839−}
840−
841−/// Every minute: scheduled automations whose time has come.
842−#[event(scheduled)]
843−async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
844− match Automations::new(&env) {
845− Ok(service) => {
846− if let Err(error) = service.on_minute(now_ms()).await {
847− worker::console_error!("automations: the minute's sweep failed: {error}");
848− }
849− }
850− Err(error) => worker::console_error!("automations: could not start: {error}"),
851− }
852−}
+0−34
1−{
2− "$schema": "../../node_modules/wrangler/config-schema.json",
3− "name": "g1t-automations",
4− "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
5− "compatibility_date": "2026-09-26",
6− // Runs next to its database: a run makes several queries in turn.
7− "placement": { "mode": "smart" },
8− "main": "build/index.js",
9− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10− // Reached only through service bindings.
11− "workers_dev": false,
12− "d1_databases": [
13− {
14− "binding": "DB",
15− "database_name": "g1t-automations",
16− "database_id": "39411186-d3e0-4b8c-8c7f-d6416df64e7a",
17− "migrations_dir": "migrations"
18− }
19− ],
20− "services": [
21− { "binding": "REPOS", "service": "g1t-repos" },
22− { "binding": "WORK", "service": "g1t-work" },
23− { "binding": "IDENTITY", "service": "g1t-identity" },
24− { "binding": "RUNNER", "service": "g1t-runner" }
25− ],
26− // Every event on the bus: what starts automations, and pushes that
27− // change their files.
28− "queues": {
29− "consumers": [{ "queue": "g1t-events-automations", "max_batch_size": 20, "max_batch_timeout": 1 }]
30− },
31− // Scheduled automations.
32− "triggers": { "crons": ["* * * * *"] },
33− "observability": { "enabled": true }
34−}
+0−1
2727 { "binding": "SUBSCRIBER_RUNNER", "queue": "g1t-events-runner" },
2828 { "binding": "SUBSCRIBER_INTEGRATIONS", "queue": "g1t-events-integrations" },
2929 { "binding": "SUBSCRIBER_WEBHOOKS", "queue": "g1t-events-webhooks" },
30− { "binding": "SUBSCRIBER_AUTOMATIONS", "queue": "g1t-events-automations" },
3130 { "binding": "SUBSCRIBER_ACTIONS", "queue": "g1t-events-actions" }
3231 ],
3332 "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1 }]
+3−0
102102 identity: Fetcher,
103103 repos: Fetcher,
104104 events: Fetcher,
105+ /// GitHub Actions: runs a merge queue's `merge_group` workflows.
106+ actions: Fetcher,
105107 }
106108
107109 impl Work {
17561758 identity: env.service("IDENTITY")?,
17571759 repos: env.service("REPOS")?,
17581760 events: env.service("EVENTS")?,
1761+ actions: env.service("ACTIONS")?,
17591762 })
17601763 }
17611764
+14−5
251251 );
252252 }
253253
254− if facts.has_checks {
254+ // An issue's checks, and any failure recorded as one, such as the merge
255+ // queue taking the pull request out.
256+ if facts.has_checks || matches!(facts.check_status, Some(CheckStatus::Failed | CheckStatus::Errored)) {
255257 match facts.check_status {
256258 Some(CheckStatus::Passed) => {}
257259 Some(CheckStatus::Failed) if exhausted => {
592594 async fn feedback(&self, pull: &Pull, feedback: &Feedback) -> Result<String> {
593595 match feedback {
594596 Feedback::FailedChecks => {
595− let failed: Vec<String> = self
596− .latest_checks(&pull.id)
597− .await?
597+ let run = self.latest_checks(&pull.id).await?;
598+ // Why it failed, when that is more than a list of commands:
599+ // the merge queue saying what broke in the combined state.
600+ let why = run.as_ref().and_then(|run| run.error.clone()).map(|error| format!("{error}\n\n")).unwrap_or_default();
601+ let failed: Vec<String> = run
598602 .map(|run| run.results)
599603 .unwrap_or_default()
600604 .into_iter()
614618 format!("`{}` failed ({exit}):\n\n{}", result.command, output.trim())
615619 })
616620 .collect();
621+ if failed.is_empty() {
622+ return Ok(format!(
623+ "{why}Find the cause, fix it in your change, and push. Use get_workflow_run and get_job_logs for any workflow named above."
624+ ));
625+ }
617626 Ok(format!(
618− "These acceptance checks were run against your change in a clean sandbox and failed.\n\n{}",
627+ "{why}These acceptance checks were run against your change in a clean sandbox and failed.\n\n{}",
619628 failed.join("\n\n")
620629 ))
621630 }
+87−2
3636 const BRANCH_PREFIX: &str = "g1t-queue/";
3737
3838 #[derive(Clone, Deserialize)]
39−struct EntryRow {
39+pub(crate) struct EntryRow {
4040 id: String,
4141 repo_id: String,
4242 pull_id: String,
483483 ));
484484 }
485485 let passed = a.error.is_none() && a.results.iter().all(|result| result.passed);
486− let state = if passed { QueueState::Passed } else { QueueState::Failed };
486+ // A state that passed its checks still runs the repository's
487+ // `merge_group` workflows, as GitHub's merge queue does; it stays in
488+ // testing until they finish (see `statuses`).
489+ let workflows = match (&a.combined_commit, passed) {
490+ (Some(commit), true) => self.start_merge_group(&row, commit).await.unwrap_or(0),
491+ _ => 0,
492+ };
493+ let state = if !passed {
494+ QueueState::Failed
495+ } else if workflows > 0 {
496+ QueueState::Testing
497+ } else {
498+ QueueState::Passed
499+ };
487500 self.db
488501 .prepare(
489502 "UPDATE queue_entries
510523 Ok(Outcome::Ok(state))
511524 }
512525
526+ /// Asks the actions service to run the repository's `merge_group`
527+ /// workflows on a combined state. Returns how many runs started.
528+ async fn start_merge_group(&self, row: &EntryRow, commit: &str) -> Result<u32> {
529+ #[derive(serde::Deserialize)]
530+ struct Started {
531+ runs: u32,
532+ }
533+ let started: Outcome<Started> = g1t_kit::call(
534+ &self.actions,
535+ "merge_group",
536+ &serde_json::json!({
537+ "repoId": row.repo_id,
538+ "entry": row.id,
539+ "sha": commit,
540+ "headRef": format!("refs/heads/{}", row.branch()),
541+ "baseSha": row.base_commit,
542+ "number": row.number,
543+ "ahead": row.ahead(),
544+ }),
545+ )
546+ .await?;
547+ Ok(match started {
548+ Outcome::Ok(started) => started.runs,
549+ Outcome::Fail(_) => 0,
550+ })
551+ }
552+
553+ /// Workflows on a combined state finished: it passes and lands in turn,
554+ /// or fails and leaves the queue as for failed checks.
555+ pub(crate) async fn merge_group_finished(&self, repo_id: &str, commit: &str, failed: &[String]) -> Result<()> {
556+ let row = self
557+ .db
558+ .prepare(
559+ "SELECT * FROM queue_entries WHERE repo_id = ? AND combined_commit = ? AND state = 'testing' AND token_hash IS NULL",
560+ )
561+ .bind(&[repo_id.into(), commit.into()])?
562+ .first::<EntryRow>(None)
563+ .await?;
564+ let Some(row) = row else { return Ok(()) };
565+ let passed = failed.is_empty();
566+ self.db
567+ .prepare("UPDATE queue_entries SET state = ?, finished_at = CASE WHEN ? = 'failed' THEN ? ELSE NULL END WHERE id = ?")
568+ .bind(&[
569+ (if passed { "passed" } else { "failed" }).into(),
570+ (if passed { "passed" } else { "failed" }).into(),
571+ rfc3339(now_ms()).into(),
572+ row.id.as_str().into(),
573+ ])?
574+ .run()
575+ .await?;
576+ if !passed {
577+ let report = ReportQueueArgs {
578+ entry_id: row.id.clone(),
579+ token: String::new(),
580+ combined_commit: Some(commit.to_owned()),
581+ results: Vec::new(),
582+ error: Some(format!(
583+ "the workflow {} failed on it",
584+ crate::statuses::list(failed)
585+ )),
586+ conflict_with: None,
587+ };
588+ self.eject(&row, &report).await?;
589+ }
590+ self.settle(repo_id).await?;
591+ self.changed(repo_id).await?;
592+ Ok(())
593+ }
594+
513595 /// An entry whose combined state failed: the entries tested on top of
514596 /// it are tested again without it, and the failure is recorded as a
515597 /// failed check run of its pull request, so a g1t agent is sent back.
537619 (_, Some(other)) if other != row.number => format!(
538620 "Its change conflicts with #{other}, which is ahead of it in the merge queue. Bring it up to date with the default branch once #{other} lands, and merge it again."
539621 ),
622+ (Some(error), _) if error.starts_with("the workflow ") => {
623+ format!("{} when it was combined with {state}.", error.replacen("the workflow", "The workflow", 1).trim_end_matches(" on it"))
624+ }
540625 (Some(error), _) => format!("Its combined state could not be built or checked: {error}"),
541626 (None, _) => format!(
542627 "The acceptance checks failed when it was combined with {state}, though it may pass on its own."
+2−0
121121 if !facts.pending.is_empty() {
122122 return Ok(Outcome::Ok(true));
123123 }
124+ // A merge queue state waiting on its merge_group workflows.
125+ self.merge_group_finished(&a.repo_id, &a.sha, &facts.failed).await?;
124126 let heads = self
125127 .db
126128 .prepare("SELECT id, number FROM pulls WHERE repo_id = ? AND head_commit = ? AND status IN ('draft', 'open')")
+2−1
2020 "services": [
2121 { "binding": "IDENTITY", "service": "g1t-identity" },
2222 { "binding": "REPOS", "service": "g1t-repos" },
23− { "binding": "EVENTS", "service": "g1t-events" }
23+ { "binding": "EVENTS", "service": "g1t-events" },
24+ { "binding": "ACTIONS", "service": "g1t-actions" }
2425 ],
2526 "queues": {
2627 "consumers": [{ "queue": "g1t-events-work", "max_batch_size": 100, "max_batch_timeout": 1 }]