Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

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 }]