Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
38 files+217−22220/38 viewed
| 906 | 906 | ] | |
| 907 | 907 | ||
| 908 | 908 | [[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]] | |
| 922 | 909 | name = "g1t-billing" | |
| 923 | 910 | version = "0.1.0" | |
| 924 | 911 | dependencies = [ |
| 1 | 1 | [workspace] | |
| 2 | 2 | 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"] | |
| 4 | 4 | ||
| 5 | 5 | [workspace.package] | |
| 6 | 6 | edition = "2024" |
| 10 | 10 | let name = op.name(); | |
| 11 | 11 | if name.contains("webhook") { | |
| 12 | 12 | "Webhooks" | |
| 13 | − | } else if name.contains("automation") { | |
| 14 | − | "Automations" | |
| 15 | 13 | } else if name.contains("workflow") || name.contains("actions_") || name == "get_job_logs" { | |
| 16 | 14 | "Actions" | |
| 17 | 15 | } else if name.contains("integration") || name.contains("model_routes") || op == Op::GetContext { |
| 25 | 25 | pub billing: Fetcher, | |
| 26 | 26 | pub integrations: Fetcher, | |
| 27 | 27 | pub webhooks: Fetcher, | |
| 28 | − | pub automations: Fetcher, | |
| 29 | 28 | pub actions: Fetcher, | |
| 30 | 29 | /// Set for a request made with an agent's token: all it may do. | |
| 31 | 30 | pub scope: Option<AgentScope>, | |
| ⋯ | |||
| 42 | 41 | billing: env.service("BILLING")?, | |
| 43 | 42 | integrations: env.service("INTEGRATIONS")?, | |
| 44 | 43 | webhooks: env.service("WEBHOOKS")?, | |
| 45 | − | automations: env.service("AUTOMATIONS")?, | |
| 46 | 44 | actions: env.service("ACTIONS")?, | |
| 47 | 45 | scope: None, | |
| 48 | 46 | }) | |
| ⋯ | |||
| 101 | 99 | PingWebhook, | |
| 102 | 100 | ListWebhookDeliveries, | |
| 103 | 101 | RedeliverWebhook, | |
| 104 | − | ListAutomations, | |
| 105 | − | ListAutomationRuns, | |
| 106 | − | RunAutomation, | |
| 107 | − | UpdateAutomation, | |
| 108 | 102 | ListWorkflows, | |
| 109 | 103 | ListWorkflowRuns, | |
| 110 | 104 | GetWorkflowRun, | |
| ⋯ | |||
| 291 | 285 | } | |
| 292 | 286 | ||
| 293 | 287 | impl Op { | |
| 294 | − | pub const ALL: [Op; 68] = [ | |
| 288 | + | pub const ALL: [Op; 64] = [ | |
| 295 | 289 | Op::Whoami, | |
| 296 | 290 | Op::CreateWorkspace, | |
| 297 | 291 | Op::ListRepos, | |
| ⋯ | |||
| 342 | 336 | Op::PingWebhook, | |
| 343 | 337 | Op::ListWebhookDeliveries, | |
| 344 | 338 | Op::RedeliverWebhook, | |
| 345 | − | Op::ListAutomations, | |
| 346 | − | Op::ListAutomationRuns, | |
| 347 | − | Op::RunAutomation, | |
| 348 | − | Op::UpdateAutomation, | |
| 349 | 339 | Op::ListWorkflows, | |
| 350 | 340 | Op::ListWorkflowRuns, | |
| 351 | 341 | Op::GetWorkflowRun, | |
| ⋯ | |||
| 419 | 409 | Op::PingWebhook => "ping_webhook", | |
| 420 | 410 | Op::ListWebhookDeliveries => "list_webhook_deliveries", | |
| 421 | 411 | 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", | |
| 426 | 412 | Op::ListWorkflows => "list_workflows", | |
| 427 | 413 | Op::ListWorkflowRuns => "list_workflow_runs", | |
| 428 | 414 | Op::GetWorkflowRun => "get_workflow_run", | |
| ⋯ | |||
| 569 | 555 | "A webhook's latest deliveries, newest first: what was sent, how the receiver answered, and when it will be tried again." | |
| 570 | 556 | } | |
| 571 | 557 | 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.", | |
| 582 | 558 | Op::ListWorkflows => { | |
| 583 | 559 | "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." | |
| 584 | 560 | } | |
| ⋯ | |||
| 953 | 929 | ), | |
| 954 | 930 | Op::GetModelRoutes => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 955 | 931 | Op::ListWebhooks => object(hook_owner(json!({})), &[]), | |
| 956 | − | Op::ListAutomations => repo_only(), | |
| 957 | 932 | Op::ListWorkflows => repo_only(), | |
| 958 | 933 | Op::ListWorkflowRuns => object( | |
| 959 | 934 | json!({ | |
| ⋯ | |||
| 1019 | 994 | Op::DeleteActionsSecret | Op::DeleteActionsVariable => object( | |
| 1020 | 995 | settings_owner(json!({ "setting": { "type": "string", "description": "The name." } })), | |
| 1021 | 996 | &["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"], | |
| 1037 | 997 | ), | |
| 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 | − | ), | |
| 1046 | 998 | Op::CreateWebhook => object( | |
| 1047 | 999 | hook_owner(json!({ | |
| 1048 | 1000 | "url": { "type": "string", "description": "An HTTPS address on the public internet." }, | |
| ⋯ | |||
| 1246 | 1198 | runner, | |
| 1247 | 1199 | integrations, | |
| 1248 | 1200 | webhooks, | |
| 1249 | − | automations, | |
| 1250 | 1201 | actions, | |
| 1251 | 1202 | .. | |
| 1252 | 1203 | } = services; | |
| ⋯ | |||
| 1668 | 1619 | integrations, | |
| 1669 | 1620 | if self == Op::TestIntegration { "test" } else { "disconnect" }, | |
| 1670 | 1621 | &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) }), | |
| 1696 | 1622 | ) | |
| 1697 | 1623 | .await | |
| 1698 | 1624 | } | |
| 243 | 243 | ), | |
| 244 | 244 | route( | |
| 245 | 245 | "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", | |
| 270 | 246 | "/repos/:owner/:name/actions/workflows", | |
| 271 | 247 | Op::ListWorkflows, | |
| 272 | 248 | &[], |
| 21 | 21 | { "binding": "BILLING", "service": "g1t-billing" }, | |
| 22 | 22 | { "binding": "INTEGRATIONS", "service": "g1t-integrations" }, | |
| 23 | 23 | { "binding": "WEBHOOKS", "service": "g1t-webhooks" }, | |
| 24 | − | { "binding": "AUTOMATIONS", "service": "g1t-automations" }, | |
| 25 | 24 | { "binding": "ACTIONS", "service": "g1t-actions" } | |
| 26 | 25 | ], | |
| 27 | 26 | // GitHub Actions artifacts and cache, in chunks, with KV's own expiry. |
| 84 | 84 | { label: 'Model providers', slug: 'guides/models' }, | |
| 85 | 85 | { label: 'Webhooks', slug: 'guides/webhooks' }, | |
| 86 | 86 | { label: 'GitHub Actions', slug: 'guides/actions' }, | |
| 87 | − | { label: 'Automations', slug: 'guides/automations' }, | |
| 88 | 87 | ], | |
| 89 | 88 | }, | |
| 90 | 89 | { |
| 148 | 148 | -d '{"ref": "main", "inputs": {"environment": "staging"}}' | |
| 149 | 149 | ``` | |
| 150 | 150 | ||
| 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/`. |
| 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 | − | ``` |
| 153 | 153 | | `set_actions_variable` | `setting`, `value` | Add or replace a variable. | `POST /repos/{owner}/{name}/actions/variables` | | |
| 154 | 154 | | `delete_actions_variable` | `setting` | Remove a variable. | `DELETE /repos/{owner}/{name}/actions/variables/{name}` | | |
| 155 | 155 | ||
| 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 | − | ||
| 167 | 156 | ## Messages | |
| 168 | 157 | ||
| 169 | 158 | | Tool | Required | What it does | Route | |
| 27 | 27 | Users, | |
| 28 | 28 | Webhook, | |
| 29 | 29 | PlayCircle, | |
| 30 | − | Zap, | |
| 31 | 30 | X, | |
| 32 | 31 | } from "lucide-react"; | |
| 33 | 32 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| ⋯ | |||
| 387 | 386 | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> | |
| 388 | 387 | Actions | |
| 389 | 388 | </SidebarLink> | |
| 390 | − | <SidebarLink to={`${base}/automations`} icon={<Zap size={15} />}> | |
| 391 | − | Automations | |
| 392 | − | </SidebarLink> | |
| 393 | 389 | </SidebarGroup> | |
| 394 | 390 | {repo.member && ( | |
| 395 | 391 | <SidebarGroup title="Repository"> | |
| ⋯ | |||
| 609 | 605 | queue: "Merge queue", | |
| 610 | 606 | commits: "Commits", | |
| 611 | 607 | plans: "Plan", | |
| 612 | − | automations: "Automations", | |
| 613 | 608 | actions: "Actions", | |
| 614 | 609 | secrets: "Secrets and variables", | |
| 615 | 610 | settings: "Settings", | |
| 2 | 2 | ||
| 3 | 3 | import { | |
| 4 | 4 | actionsClient, | |
| 5 | − | automationsClient, | |
| 6 | 5 | billingClient, | |
| 7 | 6 | eventsClient, | |
| 8 | 7 | identityClient, | |
| ⋯ | |||
| 19 | 18 | export const events = eventsClient(env.EVENTS); | |
| 20 | 19 | export const integrations = integrationsClient(env.INTEGRATIONS); | |
| 21 | 20 | export const webhooks = webhooksClient(env.WEBHOOKS); | |
| 22 | − | export const automations = automationsClient(env.AUTOMATIONS); | |
| 23 | 21 | export const actions = actionsClient(env.ACTIONS); | |
| 46 | 46 | route("actions/runs/:id", "routes/repo/actions-run.tsx"), | |
| 47 | 47 | route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"), | |
| 48 | 48 | route("actions/jobs/:job/log", "routes/repo/actions-log.ts"), | |
| 49 | − | route("automations", "routes/repo/automations.tsx"), | |
| 50 | 49 | route("plans", "routes/repo/plans.tsx"), | |
| 51 | 50 | route("plans/:id", "routes/repo/plan.tsx"), | |
| 52 | 51 | route("settings", "routes/repo/settings.tsx"), |
| 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 | − | } |
| 192 | 192 | `disconnect_integration`, `get_model_routes`, `set_model_routes`, | |
| 193 | 193 | `get_context`, `import_issue`, `list_webhooks`, `create_webhook`, | |
| 194 | 194 | `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`, | |
| 197 | 196 | `list_workflows`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`, | |
| 198 | 197 | `dispatch_workflow`, `cancel_workflow_run`, `rerun_workflow_run`, | |
| 199 | 198 | `update_workflow`, `list_actions_secrets`, `set_actions_secret`, | |
| ⋯ | |||
| 242 | 241 | `{repo}/actions/...`. A run on a pull request's head is a check: pending | |
| 243 | 242 | holds the merge, failure refuses it and sends a g1t agent back to fix it. | |
| 244 | 243 | 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. | |
| 255 | 244 | ||
| 256 | 245 | ## Facts | |
| 257 | 246 | ||
| ⋯ | |||
| 291 | 280 | - [Model providers](https://docs.g1t.sh/guides/models/) | |
| 292 | 281 | - [Webhooks](https://docs.g1t.sh/guides/webhooks/) | |
| 293 | 282 | - [GitHub Actions](https://docs.g1t.sh/guides/actions/) | |
| 294 | − | - [Automations](https://docs.g1t.sh/guides/automations/) | |
| 295 | 283 | - [Usage and billing](https://docs.g1t.sh/guides/usage-and-billing/) | |
| 296 | 284 | - [Git](https://docs.g1t.sh/guides/git/) | |
| 297 | 285 | - [MCP tools](https://docs.g1t.sh/reference/mcp/) | |
| 12 | 12 | EVENTS: ServiceBinding; | |
| 13 | 13 | INTEGRATIONS: ServiceBinding; | |
| 14 | 14 | WEBHOOKS: ServiceBinding; | |
| 15 | − | AUTOMATIONS: ServiceBinding; | |
| 16 | 15 | ACTIONS: ServiceBinding; | |
| 17 | 16 | BLOBS: KVNamespace; | |
| 18 | 17 | } |
| 20 | 20 | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 21 | 21 | { "binding": "INTEGRATIONS", "service": "g1t-integrations" }, | |
| 22 | 22 | { "binding": "WEBHOOKS", "service": "g1t-webhooks" }, | |
| 23 | − | { "binding": "AUTOMATIONS", "service": "g1t-automations" }, | |
| 24 | 23 | { "binding": "ACTIONS", "service": "g1t-actions" } | |
| 25 | 24 | ], | |
| 26 | 25 | "observability": { "enabled": true }, |
| 1 | 1 | //! Five-field cron schedules, in UTC: minute, hour, day of month, month, | |
| 2 | 2 | //! day of week. Each field takes `*`, a number, a range `a-b`, a list | |
| 3 | 3 | //! `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`. | |
| 6 | 5 | ||
| 7 | 6 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 8 | 7 | pub struct Schedule { |
| 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 | − | } |
| 5 | 5 | //! this crate, never on each other's code. | |
| 6 | 6 | ||
| 7 | 7 | pub mod actions; | |
| 8 | − | pub mod automations; | |
| 9 | 8 | pub mod billing; | |
| 10 | 9 | pub mod events; | |
| 11 | 10 | pub mod identity; |
| 444 | 444 | ||
| 445 | 445 | ## Automations and integrations | |
| 446 | 446 | ||
| 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 | + | ||
| 447 | 452 | An automation is **when** an event happens, **if** conditions hold, **do** | |
| 448 | 453 | something. They are defined as files in the repo (`.g1t/automations/`), the | |
| 449 | 454 | way GitHub Actions workflows are, and can also be built in the UI. |
| 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 | − | } |
| 1 | 1 | import type { ActionsApi } from "./actions"; | |
| 2 | − | import type { AutomationsApi } from "./automations"; | |
| 3 | 2 | import type { BillingApi } from "./billing"; | |
| 4 | 3 | import type { EventsApi } from "./events"; | |
| 5 | 4 | import type { IdentityApi } from "./identity"; | |
| ⋯ | |||
| 250 | 249 | }; | |
| 251 | 250 | } | |
| 252 | 251 | ||
| 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 | /** | |
| 2 | 2 | * Every state change in g1t is published as an event. Services react to | |
| 3 | 3 | * each other through events rather than direct calls, and the same stream | |
| 4 | − | * feeds timelines, webhooks and automations. | |
| 4 | + | * feeds timelines, webhooks and workflows. | |
| 5 | 5 | * | |
| 6 | 6 | * The envelope follows CloudEvents: `type` says what happened, `subject` | |
| 7 | 7 | * says to what, `data` is the type-specific payload. |
| 1 | 1 | export * from "./actions"; | |
| 2 | − | export * from "./automations"; | |
| 3 | 2 | export * from "./billing"; | |
| 4 | 3 | export * from "./clients"; | |
| 5 | 4 | export * from "./events"; |
| 171 | 171 | "run" => reply(&service.run(args(body)?).await?), | |
| 172 | 172 | "logs" => reply(&service.logs(args(body)?).await?), | |
| 173 | 173 | "dispatch" => reply(&service.dispatch(args(body)?).await?), | |
| 174 | + | "merge_group" => reply(&service.merge_group(args(body)?).await?), | |
| 174 | 175 | "cancel" => reply(&service.cancel(args(body)?).await?), | |
| 175 | 176 | "rerun" => reply(&service.rerun(args(body)?).await?), | |
| 176 | 177 | "set_workflow_enabled" => reply(&service.set_workflow_enabled(args(body)?).await?), |
| 159 | 159 | let (Some(git_ref), Some(after)) = (data["ref"].as_str(), data["after"].as_str()) else { | |
| 160 | 160 | return Ok(None); | |
| 161 | 161 | }; | |
| 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 | + | } | |
| 162 | 166 | let before = data["before"].as_str(); | |
| 163 | 167 | let commits = self.commits(repo, ws, after, before).await?; | |
| 164 | 168 | let title = commits.last().map(|c| c.message.lines().next().unwrap_or_default().to_owned()).unwrap_or_default(); | |
| ⋯ | |||
| 637 | 641 | } | |
| 638 | 642 | } | |
| 639 | 643 | ||
| 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 | + | ||
| 640 | 738 | fn check_refusal<T>(refused: Outcome<()>) -> Outcome<T> { | |
| 641 | 739 | match refused { | |
| 642 | 740 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 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 |
| 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 | − | ); |
| 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 | − | } |
| 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 | − | } |
| 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 | − | } |
| 27 | 27 | { "binding": "SUBSCRIBER_RUNNER", "queue": "g1t-events-runner" }, | |
| 28 | 28 | { "binding": "SUBSCRIBER_INTEGRATIONS", "queue": "g1t-events-integrations" }, | |
| 29 | 29 | { "binding": "SUBSCRIBER_WEBHOOKS", "queue": "g1t-events-webhooks" }, | |
| 30 | − | { "binding": "SUBSCRIBER_AUTOMATIONS", "queue": "g1t-events-automations" }, | |
| 31 | 30 | { "binding": "SUBSCRIBER_ACTIONS", "queue": "g1t-events-actions" } | |
| 32 | 31 | ], | |
| 33 | 32 | "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1 }] |
| 102 | 102 | identity: Fetcher, | |
| 103 | 103 | repos: Fetcher, | |
| 104 | 104 | events: Fetcher, | |
| 105 | + | /// GitHub Actions: runs a merge queue's `merge_group` workflows. | |
| 106 | + | actions: Fetcher, | |
| 105 | 107 | } | |
| 106 | 108 | ||
| 107 | 109 | impl Work { | |
| ⋯ | |||
| 1756 | 1758 | identity: env.service("IDENTITY")?, | |
| 1757 | 1759 | repos: env.service("REPOS")?, | |
| 1758 | 1760 | events: env.service("EVENTS")?, | |
| 1761 | + | actions: env.service("ACTIONS")?, | |
| 1759 | 1762 | }) | |
| 1760 | 1763 | } | |
| 1761 | 1764 | ||
| 251 | 251 | ); | |
| 252 | 252 | } | |
| 253 | 253 | ||
| 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)) { | |
| 255 | 257 | match facts.check_status { | |
| 256 | 258 | Some(CheckStatus::Passed) => {} | |
| 257 | 259 | Some(CheckStatus::Failed) if exhausted => { | |
| ⋯ | |||
| 592 | 594 | async fn feedback(&self, pull: &Pull, feedback: &Feedback) -> Result<String> { | |
| 593 | 595 | match feedback { | |
| 594 | 596 | 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 | |
| 598 | 602 | .map(|run| run.results) | |
| 599 | 603 | .unwrap_or_default() | |
| 600 | 604 | .into_iter() | |
| ⋯ | |||
| 614 | 618 | format!("`{}` failed ({exit}):\n\n{}", result.command, output.trim()) | |
| 615 | 619 | }) | |
| 616 | 620 | .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 | + | } | |
| 617 | 626 | 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{}", | |
| 619 | 628 | failed.join("\n\n") | |
| 620 | 629 | )) | |
| 621 | 630 | } | |
| 36 | 36 | const BRANCH_PREFIX: &str = "g1t-queue/"; | |
| 37 | 37 | ||
| 38 | 38 | #[derive(Clone, Deserialize)] | |
| 39 | − | struct EntryRow { | |
| 39 | + | pub(crate) struct EntryRow { | |
| 40 | 40 | id: String, | |
| 41 | 41 | repo_id: String, | |
| 42 | 42 | pull_id: String, | |
| ⋯ | |||
| 483 | 483 | )); | |
| 484 | 484 | } | |
| 485 | 485 | 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 | + | }; | |
| 487 | 500 | self.db | |
| 488 | 501 | .prepare( | |
| 489 | 502 | "UPDATE queue_entries | |
| ⋯ | |||
| 510 | 523 | Ok(Outcome::Ok(state)) | |
| 511 | 524 | } | |
| 512 | 525 | ||
| 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 | + | ||
| 513 | 595 | /// An entry whose combined state failed: the entries tested on top of | |
| 514 | 596 | /// it are tested again without it, and the failure is recorded as a | |
| 515 | 597 | /// failed check run of its pull request, so a g1t agent is sent back. | |
| ⋯ | |||
| 537 | 619 | (_, Some(other)) if other != row.number => format!( | |
| 538 | 620 | "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." | |
| 539 | 621 | ), | |
| 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 | + | } | |
| 540 | 625 | (Some(error), _) => format!("Its combined state could not be built or checked: {error}"), | |
| 541 | 626 | (None, _) => format!( | |
| 542 | 627 | "The acceptance checks failed when it was combined with {state}, though it may pass on its own." | |
| 121 | 121 | if !facts.pending.is_empty() { | |
| 122 | 122 | return Ok(Outcome::Ok(true)); | |
| 123 | 123 | } | |
| 124 | + | // A merge queue state waiting on its merge_group workflows. | |
| 125 | + | self.merge_group_finished(&a.repo_id, &a.sha, &facts.failed).await?; | |
| 124 | 126 | let heads = self | |
| 125 | 127 | .db | |
| 126 | 128 | .prepare("SELECT id, number FROM pulls WHERE repo_id = ? AND head_commit = ? AND status IN ('draft', 'open')") |
| 20 | 20 | "services": [ | |
| 21 | 21 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 22 | 22 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 23 | − | { "binding": "EVENTS", "service": "g1t-events" } | |
| 23 | + | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 24 | + | { "binding": "ACTIONS", "service": "g1t-actions" } | |
| 24 | 25 | ], | |
| 25 | 26 | "queues": { | |
| 26 | 27 | "consumers": [{ "queue": "g1t-events-work", "max_batch_size": 100, "max_batch_timeout": 1 }] |