Merge branch 'worktree-agent-a049c374e6f1c6be4' into claude/cool-newton-7nm8r9
19 files+384−100/19 viewed
| 83 | 83 | ||
| 84 | 84 | pub fn description(self) -> &'static str { | |
| 85 | 85 | match self { | |
| 86 | − | MirrorsOp::GetMirror => "A repository's links to other hosts. `remotes` lists each with its `role` (`leader`: the remote leads and this repository is its mirror; `follower`: g1t leads and the remote is kept in step), `provider` (`github`, `g1t` or `git`), `name` (`github.com/acme/web`), `state` (a mirror is `standby`, `ci`, `takeover` or `handing_back`; a follower is `following` or `stuck`), `reachable` and `unreachable_since`, `synced_at`, `last_error` and its `settings`. `can_manage` says whether you may change them. A mirror standing by is read-only on g1t and runs nothing. Needs read access.", | |
| 86 | + | MirrorsOp::GetMirror => "A repository's links to other hosts. `remotes` lists each with its `role` (`leader`: the remote leads and this repository is its mirror; `follower`: g1t leads and the remote is kept in step), `provider` (`github`, `g1t` or `git`), `name` (`github.com/acme/web`), `state` (a mirror is `standby`, `ci`, `takeover` or `handing_back`; a follower is `following` or `stuck`), `reachable` and `unreachable_since`, `synced_at`, `last_error`, `checks_problem` (why g1t could not report checks to the remote, such as the GitHub App lacking the Commit statuses permission; null once a report goes through) and its `settings`. `can_manage` says whether you may change them. A mirror standing by is read-only on g1t and runs nothing. Needs read access.", | |
| 87 | 87 | MirrorsOp::GetHandBackPlan => "What handing a takeover back would do, ref by ref, in `plan.refs`: each with `base` (where it was when the takeover began), `ours`, `theirs` and `action`: `push` (only g1t moved), `fetch` (only the remote moved), `pull_request` (the remote protects the branch: g1t's commits go to `g1t/handback/<branch>` there as a pull request, and g1t follows the remote's branch) or `diverged` (both moved: it needs a decision). `plan.reachable` is false while the remote does not answer; `plan.ready` once it answers and every diverged ref has a decision. Asks the remote, so it takes a moment. Needs the Admin role.", | |
| 88 | 88 | MirrorsOp::TakeOver => "Take over a mirror: g1t leads it for now, so pushes, pull requests, issues, agents and workflows work on g1t, whether or not the remote is answering. Each ref's commit is recorded as where the takeover began. Workflows that deploy wait for approval, unless the link's settings say otherwise. End it with hand_back_mirror, or keep it with move_mirror_to_g1t. Needs the Admin role.", | |
| 89 | 89 | MirrorsOp::SetCiFailover => "Start (`on: true`) or end (`on: false`) CI failover on a mirror standing by: the remote keeps the code, and g1t runs its workflows, `.github/workflows` as well as `.g1t/workflows`, on each push copied in. Workflows that deploy wait for approval unless the link's settings say otherwise. Needs the Admin role.", | |
| ⋯ | |||
| 91 | 91 | MirrorsOp::MoveToG1t => "Move a mirror to g1t for good: it stops being a mirror and g1t no longer tracks the remote, so pushes made there no longer come here. Allowed while it stands by, in CI failover, or during a takeover, whose work stays as it is. With `keep_remote_updated: true` the remote becomes a follower instead of being unlinked, and g1t pushes to it from then on. Needs the Admin role; agents' tokens are refused.", | |
| 92 | 92 | MirrorsOp::SyncMirror => "Bring a repository's remotes in step now: a mirror standing by or in CI failover copies the remote's branches and tags in; each follower is pushed g1t's (which also clears a follower that was stuck by pushing over what changed there). Needs push access.", | |
| 93 | 93 | MirrorsOp::AddRemote => "Link a repository to a remote on another g1t or any git host over HTTPS (GitHub repositories are linked by importing them through g1t's GitHub App). `role` `follower`: g1t leads and pushes to it. `role` `leader`: this repository becomes its mirror; only an empty repository can, and it is filled from the remote. `url` is its https clone address, `token` a token that can read and push (kept sealed, never returned), `username` the user it is sent as. Needs the Admin role; agents' tokens are refused.", | |
| 94 | − | MirrorsOp::UpdateRemote => "Change a remote's `settings`, all optional: `notify` (`banner`, or `inbox` to also tell the workspace's owners), `take_over_after` (minutes, 5 to 1440, after which g1t takes over on its own while the remote does not answer; null for never), `hand_back` (`when_clean`: an automatic takeover goes back on its own once every ref goes back without a decision; `ask`), `keep_ci_warm` (run `.g1t/workflows` on pushes copied in while standing by), `github_workflows` (run `.github/workflows` in CI failover and takeovers), `hold_deploys`, and for a follower `remote_pushes` (`adopt` fast-forwards made there, or `overwrite` them). Needs the Admin role; agents' tokens are refused.", | |
| 94 | + | MirrorsOp::UpdateRemote => "Change a remote's `settings`, all optional: `notify` (`banner`, or `inbox` to also tell the workspace's owners), `take_over_after` (minutes, 5 to 1440, after which g1t takes over on its own while the remote does not answer; null for never), `hand_back` (`when_clean`: an automatic takeover goes back on its own once every ref goes back without a decision; `ask`), `keep_ci_warm` (run `.g1t/workflows` on pushes copied in while standing by), `github_workflows` (run `.github/workflows` in CI failover and takeovers), `hold_deploys`, `report_checks` (a GitHub mirror: report the statuses g1t sets on a commit, such as workflow runs and deploys, to the commit on GitHub as `g1t / <context>` while g1t runs its workflows; on by default), and for a follower `remote_pushes` (`adopt` fast-forwards made there, or `overwrite` them). Needs the Admin role; agents' tokens are refused.", | |
| 95 | 95 | MirrorsOp::RemoveRemote => "Unlink a remote. A mirror becomes an ordinary repository with what it has; a follower is no longer pushed to. Refused during a takeover: hand it back or move it to g1t first. Needs the Admin role; agents' tokens are refused.", | |
| 96 | 96 | } | |
| 97 | 97 | } | |
| ⋯ | |||
| 146 | 146 | "keep_ci_warm": { "type": "boolean" }, | |
| 147 | 147 | "github_workflows": { "type": "boolean" }, | |
| 148 | 148 | "hold_deploys": { "type": "boolean" }, | |
| 149 | + | "report_checks": { "type": "boolean" }, | |
| 149 | 150 | "remote_pushes": { "type": "string", "enum": ["adopt", "overwrite"] }, | |
| 150 | 151 | }), | |
| 151 | 152 | &["repo", "id"], | |
| ⋯ | |||
| 195 | 196 | ("keep_ci_warm", &mut settings.keep_ci_warm), | |
| 196 | 197 | ("github_workflows", &mut settings.github_workflows), | |
| 197 | 198 | ("hold_deploys", &mut settings.hold_deploys), | |
| 199 | + | ("report_checks", &mut settings.report_checks), | |
| 198 | 200 | ] { | |
| 199 | 201 | if let Some(value) = input.get(key).filter(|value| !value.is_null()) { | |
| 200 | 202 | *field = flag(value).ok_or_else(|| format!("{key} is true or false."))?; | |
| ⋯ | |||
| 355 | 357 | assert!(changed.keep_ci_warm); | |
| 356 | 358 | assert_eq!(changed.hand_back, HandBack::WhenClean); | |
| 357 | 359 | assert_eq!(changed.remote_pushes, RemotePushes::Adopt); | |
| 360 | + | assert!(changed.report_checks, "checks go to GitHub unless turned off"); | |
| 361 | + | assert!(!settings_from(base.clone(), &json!({ "report_checks": false })).unwrap().report_checks); | |
| 358 | 362 | let cleared = settings_from(changed, &json!({ "take_over_after": null })).unwrap(); | |
| 359 | 363 | assert_eq!(cleared.take_over_after, None); | |
| 360 | 364 | assert!(settings_from(base.clone(), &json!({ "notify": "pager" })).is_err()); | |
| 12422 | 12422 | "unreachable_since": null, | |
| 12423 | 12423 | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12424 | 12424 | "last_error": null, | |
| 12425 | + | "checks_problem": null, | |
| 12425 | 12426 | "settings": { | |
| 12426 | 12427 | "notify": "banner", | |
| 12427 | 12428 | "take_over_after": null, | |
| ⋯ | |||
| 12429 | 12430 | "keep_ci_warm": false, | |
| 12430 | 12431 | "github_workflows": true, | |
| 12431 | 12432 | "hold_deploys": true, | |
| 12433 | + | "report_checks": true, | |
| 12432 | 12434 | "remote_pushes": "adopt" | |
| 12433 | 12435 | }, | |
| 12434 | 12436 | "created_at": "2026-10-05T10:00:00.000Z" | |
| ⋯ | |||
| 12461 | 12463 | "unreachable_since": null, | |
| 12462 | 12464 | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12463 | 12465 | "last_error": null, | |
| 12466 | + | "checks_problem": null, | |
| 12464 | 12467 | "settings": { | |
| 12465 | 12468 | "notify": "banner", | |
| 12466 | 12469 | "take_over_after": null, | |
| ⋯ | |||
| 12468 | 12471 | "keep_ci_warm": false, | |
| 12469 | 12472 | "github_workflows": true, | |
| 12470 | 12473 | "hold_deploys": true, | |
| 12474 | + | "report_checks": true, | |
| 12471 | 12475 | "remote_pushes": "adopt" | |
| 12472 | 12476 | }, | |
| 12473 | 12477 | "created_at": "2026-10-05T10:00:00.000Z" | |
| ⋯ | |||
| 12529 | 12533 | "unreachable_since": "2026-10-08T14:04:00.000Z", | |
| 12530 | 12534 | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12531 | 12535 | "last_error": null, | |
| 12536 | + | "checks_problem": null, | |
| 12532 | 12537 | "settings": { | |
| 12533 | 12538 | "notify": "banner", | |
| 12534 | 12539 | "take_over_after": null, | |
| ⋯ | |||
| 12536 | 12541 | "keep_ci_warm": false, | |
| 12537 | 12542 | "github_workflows": true, | |
| 12538 | 12543 | "hold_deploys": true, | |
| 12544 | + | "report_checks": true, | |
| 12539 | 12545 | "remote_pushes": "adopt" | |
| 12540 | 12546 | }, | |
| 12541 | 12547 | "created_at": "2026-10-05T10:00:00.000Z" | |
| ⋯ | |||
| 12570 | 12576 | "unreachable_since": null, | |
| 12571 | 12577 | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12572 | 12578 | "last_error": null, | |
| 12579 | + | "checks_problem": null, | |
| 12573 | 12580 | "settings": { | |
| 12574 | 12581 | "notify": "banner", | |
| 12575 | 12582 | "take_over_after": null, | |
| ⋯ | |||
| 12577 | 12584 | "keep_ci_warm": false, | |
| 12578 | 12585 | "github_workflows": true, | |
| 12579 | 12586 | "hold_deploys": true, | |
| 12587 | + | "report_checks": true, | |
| 12580 | 12588 | "remote_pushes": "adopt" | |
| 12581 | 12589 | }, | |
| 12582 | 12590 | "created_at": "2026-10-05T10:00:00.000Z" | |
| ⋯ | |||
| 12613 | 12621 | "unreachable_since": null, | |
| 12614 | 12622 | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12615 | 12623 | "last_error": null, | |
| 12624 | + | "checks_problem": null, | |
| 12616 | 12625 | "settings": { | |
| 12617 | 12626 | "notify": "banner", | |
| 12618 | 12627 | "take_over_after": null, | |
| ⋯ | |||
| 12620 | 12629 | "keep_ci_warm": false, | |
| 12621 | 12630 | "github_workflows": true, | |
| 12622 | 12631 | "hold_deploys": true, | |
| 12632 | + | "report_checks": true, | |
| 12623 | 12633 | "remote_pushes": "adopt" | |
| 12624 | 12634 | }, | |
| 12625 | 12635 | "created_at": "2026-10-05T10:00:00.000Z" | |
| ⋯ | |||
| 12671 | 12681 | "unreachable_since": null, | |
| 12672 | 12682 | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12673 | 12683 | "last_error": null, | |
| 12684 | + | "checks_problem": null, | |
| 12674 | 12685 | "settings": { | |
| 12675 | 12686 | "notify": "banner", | |
| 12676 | 12687 | "take_over_after": null, | |
| ⋯ | |||
| 12678 | 12689 | "keep_ci_warm": false, | |
| 12679 | 12690 | "github_workflows": true, | |
| 12680 | 12691 | "hold_deploys": true, | |
| 12692 | + | "report_checks": true, | |
| 12681 | 12693 | "remote_pushes": "adopt" | |
| 12682 | 12694 | }, | |
| 12683 | 12695 | "created_at": "2026-10-05T10:00:00.000Z" | |
| ⋯ | |||
| 12714 | 12726 | "unreachable_since": null, | |
| 12715 | 12727 | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12716 | 12728 | "last_error": null, | |
| 12729 | + | "checks_problem": null, | |
| 12717 | 12730 | "settings": { | |
| 12718 | 12731 | "notify": "banner", | |
| 12719 | 12732 | "take_over_after": null, | |
| ⋯ | |||
| 12721 | 12734 | "keep_ci_warm": false, | |
| 12722 | 12735 | "github_workflows": true, | |
| 12723 | 12736 | "hold_deploys": true, | |
| 12737 | + | "report_checks": true, | |
| 12724 | 12738 | "remote_pushes": "adopt" | |
| 12725 | 12739 | }, | |
| 12726 | 12740 | "created_at": "2026-10-05T10:00:00.000Z" | |
| ⋯ | |||
| 12752 | 12766 | "unreachable_since": null, | |
| 12753 | 12767 | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12754 | 12768 | "last_error": null, | |
| 12769 | + | "checks_problem": null, | |
| 12755 | 12770 | "settings": { | |
| 12756 | 12771 | "notify": "inbox", | |
| 12757 | 12772 | "take_over_after": 30, | |
| ⋯ | |||
| 12759 | 12774 | "keep_ci_warm": false, | |
| 12760 | 12775 | "github_workflows": true, | |
| 12761 | 12776 | "hold_deploys": true, | |
| 12777 | + | "report_checks": true, | |
| 12762 | 12778 | "remote_pushes": "adopt" | |
| 12763 | 12779 | }, | |
| 12764 | 12780 | "created_at": "2026-10-05T10:00:00.000Z" | |
| 42 | 42 | GitHub that changes workflows or local actions waits for approval before | |
| 43 | 43 | it can use the repository's secrets. | |
| 44 | 44 | ||
| 45 | + | Whenever g1t runs a mirror's workflows, their results show on the commit on | |
| 46 | + | GitHub too, as commit statuses named `g1t / <workflow> / <event>`, such as | |
| 47 | + | `g1t / CI / push`. This needs the Commit statuses permission on g1t's GitHub | |
| 48 | + | App; turn it off with **Report checks to GitHub** in the mirror's settings. | |
| 49 | + | See [Checks on GitHub](/guides/mirroring/#checks-on-github). | |
| 50 | + | ||
| 45 | 51 | Then add your [secrets and variables](#secrets-and-variables): GitHub never | |
| 46 | 52 | gives their values out, so they cannot be copied across. | |
| 47 | 53 |
| 250 | 250 | ||
| 251 | 251 | | Event | When | | |
| 252 | 252 | | --- | --- | | |
| 253 | − | | `status.created` | A status was set on a commit through the API. | | |
| 253 | + | | `status.created` | A status was set on a commit, through the API or by g1t: a workflow run, a deploy. | | |
| 254 | 254 | | `check_run.created` | A check run was reported. | | |
| 255 | 255 | | `check_run.completed` | A check run completed. | | |
| 256 | 256 | | `check_run.rerequested` | Someone asked for a check run to run again. | |
| 162 | 162 | | Metadata | Read | Required by GitHub for every app: names, visibility, and whether a branch is protected. | | |
| 163 | 163 | | Issues | Read | Copy issues when you ask. | | |
| 164 | 164 | | Pull requests | Read and write | When a takeover is handed back, open a pull request for a branch GitHub protects. Without it, g1t pushes the commits to `g1t/handback/<branch>` and tells you to open the pull request yourself. | | |
| 165 | + | | Commit statuses | Read and write | While g1t runs a mirror's workflows, report each run and deploy on the commit on GitHub. Without it, the mirror's settings say so and nothing is reported. See [Checks on GitHub](/guides/mirroring/#checks-on-github). | | |
| 165 | 166 | | Email addresses (account) | Read | Find your verified email when you sign in. | | |
| 166 | 167 | ||
| 167 | 168 | The app only ever sees the repositories you or your organization's owners |
| 120 | 120 | | Keep CI warm | Off | Run `.g1t/workflows` on each push copied in | | |
| 121 | 121 | | Run the remote's `.github` workflows | On | Off | | |
| 122 | 122 | | Hold workflows that deploy for approval | On | Off | | |
| 123 | + | | Report checks to GitHub | On | Off. Only for a mirror of a GitHub repository; see [Checks on GitHub](#checks-on-github). | | |
| 123 | 124 | ||
| 124 | 125 | **Hand back on its own** only applies to takeovers g1t started itself. A | |
| 125 | 126 | takeover a person started waits for a person to hand it back. | |
| ⋯ | |||
| 143 | 144 | **End CI failover** when the remote runs its workflows again. Runs already | |
| 144 | 145 | started finish. | |
| 145 | 146 | ||
| 147 | + | ## Checks on GitHub | |
| 148 | + | ||
| 149 | + | While g1t runs a GitHub mirror's workflows, in CI failover, during a | |
| 150 | + | takeover, or with **Keep CI warm**, it reports their results to the commit | |
| 151 | + | on GitHub. You can keep GitHub as the place you work and still see, on | |
| 152 | + | each commit and pull request there, what g1t ran. | |
| 153 | + | ||
| 154 | + | What g1t reports is each status it sets on a commit: a workflow run, and a | |
| 155 | + | deploy. Each becomes a commit status on GitHub: | |
| 156 | + | ||
| 157 | + | | On GitHub | What it is | | |
| 158 | + | | --- | --- | | |
| 159 | + | | Context | The status's name on g1t under `g1t / `: a workflow run's `CI / push` shows as `g1t / CI / push`. A name that already starts with `g1t / `, such as a deploy's `g1t / deploy`, stays as it is. | | |
| 160 | + | | State | `pending` while it runs, then `success`, `failure` or `error`, as on g1t. A cancelled run is `error`. | | |
| 161 | + | | Description | The status's description on g1t, cut to GitHub's 140 characters. | | |
| 162 | + | | Details | A link to the run, or the deploy, on g1t. | | |
| 163 | + | ||
| 164 | + | A status is sent once each time it changes. Only commits that came from | |
| 165 | + | GitHub are reported: a commit made on g1t during a takeover isn't on | |
| 166 | + | GitHub until it's handed back, so it is skipped. A mirror standing by | |
| 167 | + | without **Keep CI warm** runs nothing, so it reports nothing. | |
| 168 | + | ||
| 169 | + | g1t reports through its GitHub App, which needs the **Commit statuses** | |
| 170 | + | permission, read and write. When the app's installation doesn't have it, | |
| 171 | + | GitHub refuses, and the mirror's **Settings → Mirroring** page says | |
| 172 | + | **g1t can't report checks to GitHub: the GitHub App needs the Commit | |
| 173 | + | statuses permission**. g1t doesn't retry; it tries again with the next | |
| 174 | + | status, and the line goes once a status gets through. To grant it, an | |
| 175 | + | owner of the GitHub account or organization accepts the app's new | |
| 176 | + | permissions on GitHub, under **Settings → Applications → Installed GitHub | |
| 177 | + | Apps → g1t-sh**. | |
| 178 | + | ||
| 179 | + | To turn it off, clear **Report checks to GitHub** in the mirror's | |
| 180 | + | **Settings → Mirroring** and save. Through the API, send | |
| 181 | + | `report_checks: false` to | |
| 182 | + | `PATCH /repos/{owner}/{name}/mirror/remotes/{id}`. | |
| 183 | + | ||
| 146 | 184 | ## Take over | |
| 147 | 185 | ||
| 148 | 186 | **Take over** makes g1t lead the repository for now, whether or not the | |
| ⋯ | |||
| 257 | 295 | ||
| 258 | 296 | ## Not supported yet | |
| 259 | 297 | ||
| 260 | − | - Workflow results from CI failover stay on g1t; they are not reported | |
| 261 | − | back to the remote as checks. | |
| 298 | + | - Checks go back to GitHub as commit statuses, one per workflow run; a | |
| 299 | + | run's jobs aren't shown there as separate check runs. Remotes other than | |
| 300 | + | GitHub aren't told about checks. | |
| 262 | 301 | - CI failover starts only when someone starts it. | |
| 263 | 302 | - A takeover runs under the repository's own [rules](/guides/rules/) on | |
| 264 | 303 | g1t, not the remote's branch protection. | |
| 243 | 243 | | Setup URL | `PUBLIC_URL/integrations/github/setup` | | |
| 244 | 244 | | Redirect on update | On | | |
| 245 | 245 | | Webhook | On, with the URL `API_URL/hooks/github` and a secret you choose, once GitHub can reach your API. Otherwise off: mirrors then sync with **Sync now**. | | |
| 246 | − | | Repository permissions | Contents: Read and write; Metadata: Read; Issues: Read | | |
| 246 | + | | Repository permissions | Contents: Read and write; Metadata: Read; Issues: Read; Pull requests: Read and write; Commit statuses: Read and write | | |
| 247 | 247 | | Account permissions | Email addresses: Read | | |
| 248 | 248 | ||
| 249 | 249 | 3. Create it, then on its page note the **App ID**, the **Client ID** and |
| 99 | 99 | | `code_scanning_alert.created`, `.fixed`, `.dismissed`, `.reopened` | A [code scanning alert](/guides/security/code-scanning/) changed. `data.alert_id`, `data.alert_number`, `data.title`, `data.severity`, `data.path`, `data.line`, `data.state`, `data.link`. | | |
| 100 | 100 | | `vulnerability_alert.created`, `.fixed`, `.dismissed`, `.reopened` | A [vulnerability alert](/guides/security/supply-chain/) changed. `data.alert_id`, `data.title` (package, version, lockfile and advisory), `data.severity`, `data.state`, `data.link`. | | |
| 101 | 101 | | `checks.completed` | A pull request's checks finished: every status on its head has reported and none is still pending, or the merge queue took it out. `data.number`, `data.commit`, and `data.status`, `passed` or `failed`. | | |
| 102 | − | | `status.created` | A status was set on a commit through the API. `data.sha`, `data.context`, `data.state`, `data.description`, `data.target_url`. See [Checks](/guides/checks/). | | |
| 102 | + | | `status.created` | A status was set on a commit: through the API, by a workflow run, by a deploy, or by another part of g1t. `data.sha`, `data.context`, `data.state`, `data.description`, `data.target_url`. See [Checks](/guides/checks/). | | |
| 103 | 103 | | `check_run.created`, `check_run.completed` | A check run was reported on a commit, or completed. `data.check_run`: `id`, `name`, `head_sha`, `status`, `conclusion`, `details_url`, `external_id`, `html_url`, `output`, `actions`, `check_suite` and `app`. | | |
| 104 | 104 | | `check_run.rerequested`, `check_run.requested_action` | Someone asked for a check run to run again, or pressed one of its buttons: `data.requested_action` is the button's `identifier`. Report a new run, or do what the button says. | | |
| 105 | 105 | | `check_suite.completed`, `check_suite.rerequested` | Every latest check run of a reporter's suite on a commit completed, or someone asked for it to run again. `data.check_suite`: `id`, `head_sha`, `status`, `conclusion`, `app`. | |
Binary or large file; its contents are not shown.
| 308 | 308 | <span className="min-w-0 break-words">{remote.lastError}</span> | |
| 309 | 309 | </p> | |
| 310 | 310 | )} | |
| 311 | + | {remote.checksProblem && remote.settings.reportChecks && ( | |
| 312 | + | <p className="mx-4 mb-3 flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-xs text-fg-soft"> | |
| 313 | + | <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" /> | |
| 314 | + | <span className="min-w-0 break-words">{remote.checksProblem}</span> | |
| 315 | + | </p> | |
| 316 | + | )} | |
| 311 | 317 | {canManage && state !== "handing_back" && ( | |
| 312 | 318 | <div className="flex flex-wrap items-center gap-2 border-t border-line px-4 py-3"> | |
| 313 | 319 | {(state === "standby" || state === "ci") && ( | |
| ⋯ | |||
| 516 | 522 | <Form method="post" className="space-y-10"> | |
| 517 | 523 | <input type="hidden" name="intent" value="settings" /> | |
| 518 | 524 | <input type="hidden" name="kind" value="leader" /> | |
| 525 | + | <input type="hidden" name="provider" value={remote.provider} /> | |
| 519 | 526 | <input type="hidden" name="remoteId" value={remote.id} /> | |
| 520 | 527 | <fieldset disabled={!canManage} className="space-y-10"> | |
| 521 | 528 | <Section | |
| ⋯ | |||
| 567 | 574 | <SettingToggle name="holdDeploys" on={settings.holdDeploys} title="Hold workflows that deploy for approval"> | |
| 568 | 575 | A job that deploys waits for someone to approve it, so taking over never ships anything by surprise. | |
| 569 | 576 | </SettingToggle> | |
| 577 | + | {remote.provider === "github" && ( | |
| 578 | + | <SettingToggle name="reportChecks" on={settings.reportChecks} title="Report checks to GitHub"> | |
| 579 | + | While g1t runs workflows here, each run and deploy shows on its commit on {remote.name} as a status such as g1t / CI / | |
| 580 | + | push, linking back to g1t. Needs the GitHub App's Commit statuses permission. | |
| 581 | + | </SettingToggle> | |
| 582 | + | )} | |
| 570 | 583 | </Section> | |
| 571 | 584 | </fieldset> | |
| 572 | 585 | {canManage && ( | |
| 153 | 153 | keepCiWarm: false, | |
| 154 | 154 | githubWorkflows: true, | |
| 155 | 155 | holdDeploys: false, | |
| 156 | + | reportChecks: true, | |
| 156 | 157 | remotePushes: "overwrite", | |
| 157 | 158 | }); | |
| 159 | + | const githubForm = new FormData(); | |
| 160 | + | githubForm.set("kind", "leader"); | |
| 161 | + | githubForm.set("provider", "github"); | |
| 162 | + | assert.equal(mirrorSettingsFrom(githubForm, DEFAULT_MIRROR_SETTINGS).reportChecks, false, "a GitHub mirror's box left clear turns it off"); | |
| 163 | + | githubForm.set("reportChecks", "on"); | |
| 164 | + | assert.equal(mirrorSettingsFrom(githubForm, { ...DEFAULT_MIRROR_SETTINGS, reportChecks: false }).reportChecks, true); | |
| 158 | 165 | const followerForm = new FormData(); | |
| 159 | 166 | followerForm.set("kind", "follower"); | |
| 160 | 167 | followerForm.set("remotePushes", "overwrite"); |
| 219 | 219 | keepCiWarm: on("keepCiWarm"), | |
| 220 | 220 | githubWorkflows: on("githubWorkflows"), | |
| 221 | 221 | holdDeploys: on("holdDeploys"), | |
| 222 | + | // Only a GitHub mirror's form carries it. | |
| 223 | + | reportChecks: form.get("provider") === "github" ? on("reportChecks") : current.reportChecks, | |
| 222 | 224 | }; | |
| 223 | 225 | } | |
| 224 | 226 |
| 337 | 337 | pub github_workflows: bool, | |
| 338 | 338 | /// Hold deploy jobs for approval in CI failover and during a takeover. | |
| 339 | 339 | pub hold_deploys: bool, | |
| 340 | + | /// Report the statuses g1t sets on a commit (workflow runs, deploys) | |
| 341 | + | /// to the GitHub commit, while g1t runs workflows for the mirror: CI | |
| 342 | + | /// failover, a takeover, or CI kept warm. GitHub links only. | |
| 343 | + | pub report_checks: bool, | |
| 340 | 344 | /// For followers. | |
| 341 | 345 | pub remote_pushes: RemotePushes, | |
| 342 | 346 | } | |
| ⋯ | |||
| 350 | 354 | keep_ci_warm: false, | |
| 351 | 355 | github_workflows: true, | |
| 352 | 356 | hold_deploys: true, | |
| 357 | + | report_checks: true, | |
| 353 | 358 | remote_pushes: RemotePushes::Adopt, | |
| 354 | 359 | } | |
| 355 | 360 | } | |
| ⋯ | |||
| 383 | 388 | pub unreachable_since: Option<String>, | |
| 384 | 389 | pub synced_at: Option<String>, | |
| 385 | 390 | pub last_error: Option<String>, | |
| 391 | + | /// Why g1t could not report checks to the remote, until it next can: | |
| 392 | + | /// the GitHub App lacks the Commit statuses permission, say. | |
| 393 | + | #[serde(default)] | |
| 394 | + | pub checks_problem: Option<String>, | |
| 386 | 395 | pub settings: MirrorSettings, | |
| 387 | 396 | pub created_at: String, | |
| 388 | 397 | } | |
| ⋯ | |||
| 727 | 736 | assert_eq!(settings.notify, Notify::Banner); | |
| 728 | 737 | assert_eq!(settings.take_over_after, None); | |
| 729 | 738 | assert!(!settings.keep_ci_warm); | |
| 739 | + | assert!(settings.report_checks, "checks are reported whenever g1t runs workflows, unless turned off"); | |
| 730 | 740 | } | |
| 731 | 741 | } | |
| 65 | 65 | "repo.archived", | |
| 66 | 66 | ], | |
| 67 | 67 | ), | |
| 68 | − | // Push mirrors and write-back to linked trackers (integrations/src). | |
| 69 | − | ("SUBSCRIBER_INTEGRATIONS", &["git.push", "issue.closed", "pull.opened"]), | |
| 68 | + | // Push mirrors, write-back to linked trackers, and commit statuses | |
| 69 | + | // reported to a GitHub mirror's commits (integrations/src lib.rs, | |
| 70 | + | // remotes.rs on_status). | |
| 71 | + | ("SUBSCRIBER_INTEGRATIONS", &["git.push", "issue.closed", "pull.opened", "status.created"]), | |
| 70 | 72 | // A hook may ask for any type, or for every one. | |
| 71 | 73 | ("SUBSCRIBER_WEBHOOKS", &["*"]), | |
| 72 | 74 | // Every event a workflow can run on (g1t_actions::events::github_events), | |
| ⋯ | |||
| 313 | 315 | assert!(!routed("SUBSCRIBER_ACTIONS", "check_run.completed")); | |
| 314 | 316 | assert!(routed("SUBSCRIBER_RUNNER", "comment.created")); | |
| 315 | 317 | assert!(!routed("SUBSCRIBER_RUNNER", "status.created")); | |
| 318 | + | assert!(routed("SUBSCRIBER_INTEGRATIONS", "status.created")); | |
| 319 | + | assert!(!routed("SUBSCRIBER_INTEGRATIONS", "check_run.completed")); | |
| 316 | 320 | assert!(routed("SUBSCRIBER_BILLING", "workspace.renamed")); | |
| 317 | 321 | assert!(!routed("SUBSCRIBER_BILLING", "git.push")); | |
| 318 | 322 | assert!(routed("SUBSCRIBER_SEARCH", "user.updated")); | |
| 105 | 105 | keepCiWarm: boolean; | |
| 106 | 106 | githubWorkflows: boolean; | |
| 107 | 107 | holdDeploys: boolean; | |
| 108 | + | /** A GitHub mirror: report g1t's statuses to the commit on GitHub while g1t runs its workflows. */ | |
| 109 | + | reportChecks: boolean; | |
| 108 | 110 | /** For followers: pushes made on the remote itself. */ | |
| 109 | 111 | remotePushes: "adopt" | "overwrite"; | |
| 110 | 112 | }; | |
| ⋯ | |||
| 116 | 118 | keepCiWarm: false, | |
| 117 | 119 | githubWorkflows: true, | |
| 118 | 120 | holdDeploys: true, | |
| 121 | + | reportChecks: true, | |
| 119 | 122 | remotePushes: "adopt", | |
| 120 | 123 | }; | |
| 121 | 124 | ||
| ⋯ | |||
| 139 | 142 | unreachableSince: string | null; | |
| 140 | 143 | syncedAt: string | null; | |
| 141 | 144 | lastError: string | null; | |
| 145 | + | /** Why g1t could not report checks to the remote, until it next can. */ | |
| 146 | + | checksProblem?: string | null; | |
| 142 | 147 | settings: MirrorSettings; | |
| 143 | 148 | createdAt: string; | |
| 144 | 149 | }; | |
| 1 | + | -- Reporting checks to a mirror's remote (src/remotes.rs, on_status). | |
| 2 | + | -- checks_error: why g1t could not report the last check to it, such as | |
| 3 | + | -- the GitHub App lacking the Commit statuses permission; cleared once a | |
| 4 | + | -- report goes through. Kept apart from last_error, which every sync | |
| 5 | + | -- clears. | |
| 6 | + | ALTER TABLE remotes ADD COLUMN checks_error TEXT; |
| 1711 | 1711 | if message.body().kind == "git.push" && github::first_push_in_batch(&mut pushed, message.body()) { | |
| 1712 | 1712 | remotes::Mirrors::new(&env)?.on_event(message.body()).await?; | |
| 1713 | 1713 | } | |
| 1714 | + | // A status on a commit: reported to a GitHub mirror's commit there. | |
| 1715 | + | if message.body().kind == "status.created" { | |
| 1716 | + | remotes::Mirrors::new(&env)?.on_status(message.body()).await?; | |
| 1717 | + | message.ack(); | |
| 1718 | + | continue; | |
| 1719 | + | } | |
| 1714 | 1720 | service.on_event(message.body()).await?; | |
| 1715 | 1721 | message.ack(); | |
| 1716 | 1722 | } |
| 27 | 27 | //! Nothing happens on its own unless someone asked for it in the link's | |
| 28 | 28 | //! settings: by default an unreachable remote is only shown on the | |
| 29 | 29 | //! repository, and a takeover starts when someone starts it. | |
| 30 | + | //! | |
| 31 | + | //! While g1t runs a GitHub mirror's workflows (CI failover, a takeover, CI | |
| 32 | + | //! kept warm), the statuses set on its commits go back to the commit on | |
| 33 | + | //! GitHub as `g1t / <context>` (`on_status`), unless the link's | |
| 34 | + | //! `report_checks` is off. GitHub's refusals that last, such as the App | |
| 35 | + | //! lacking the Commit statuses permission, are kept on the link | |
| 36 | + | //! (`checks_error`) for its page. | |
| 30 | 37 | ||
| 31 | 38 | use std::collections::{BTreeMap, BTreeSet, HashMap}; | |
| 32 | 39 | ||
| 33 | 40 | use g1t_contracts::access::{self, Capability}; | |
| 41 | + | use g1t_contracts::checks::StatusEvent; | |
| 34 | 42 | use g1t_contracts::events::{NewEvent, Publish}; | |
| 35 | 43 | use g1t_contracts::identity::{ListMembersArgs, Member}; | |
| 36 | 44 | use g1t_contracts::mirrors::*; | |
| ⋯ | |||
| 118 | 126 | clone_url.strip_suffix(".git").unwrap_or(clone_url).to_owned() | |
| 119 | 127 | } | |
| 120 | 128 | ||
| 129 | + | const CHECKS_PREFIX: &str = "g1t / "; | |
| 130 | + | /// The longest description GitHub keeps on a status. | |
| 131 | + | const GITHUB_DESCRIPTION_CHARS: usize = 140; | |
| 132 | + | /// What the mirror's page says when the GitHub App cannot set statuses. | |
| 133 | + | pub const CHECKS_PERMISSION: &str = "g1t can't report checks to GitHub: the GitHub App needs the Commit statuses permission (read and write). An owner of the GitHub account can accept it in the App's settings on GitHub."; | |
| 134 | + | ||
| 135 | + | /// What g1t's checks are called on GitHub: under `g1t / `, so they read | |
| 136 | + | /// apart from GitHub's own (`g1t / CI / push`). A context already named | |
| 137 | + | /// for g1t (`g1t / deploy`) keeps its name. | |
| 138 | + | pub fn github_context(context: &str) -> String { | |
| 139 | + | let context = context.trim(); | |
| 140 | + | if context.starts_with(CHECKS_PREFIX) { context.to_owned() } else { format!("{CHECKS_PREFIX}{context}") } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// A g1t commit status's state as GitHub takes it. The two name the same | |
| 144 | + | /// four; anything else is not sent. | |
| 145 | + | pub fn github_state(state: &str) -> Option<&'static str> { | |
| 146 | + | Some(match state { | |
| 147 | + | "pending" => "pending", | |
| 148 | + | "success" => "success", | |
| 149 | + | "failure" => "failure", | |
| 150 | + | "error" => "error", | |
| 151 | + | _ => return None, | |
| 152 | + | }) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// Whether a link reports g1t's checks to its remote now: a mirror of a | |
| 156 | + | /// GitHub repository, with the setting on, while g1t runs its workflows | |
| 157 | + | /// (CI failover, a takeover or its hand-back, or CI kept warm). A mirror | |
| 158 | + | /// standing by quietly runs nothing, so it has nothing to report. | |
| 159 | + | pub fn reports_checks(provider: RemoteProvider, leads: bool, state: RemoteState, settings: &MirrorSettings) -> bool { | |
| 160 | + | if provider != RemoteProvider::Github || !leads || !settings.report_checks { | |
| 161 | + | return false; | |
| 162 | + | } | |
| 163 | + | match state { | |
| 164 | + | RemoteState::Standby => settings.keep_ci_warm, | |
| 165 | + | RemoteState::Ci | RemoteState::Takeover | RemoteState::HandingBack => true, | |
| 166 | + | RemoteState::Following | RemoteState::Stuck => false, | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Where a check reported to GitHub links: the status's own page on g1t, | |
| 171 | + | /// or the commit's when it has none. | |
| 172 | + | pub fn status_target(site: &str, repo: &str, sha: &str, target: Option<&str>) -> String { | |
| 173 | + | let site = site.trim_end_matches('/'); | |
| 174 | + | match target.map(str::trim).filter(|url| !url.is_empty()) { | |
| 175 | + | Some(url) if url.starts_with("https://") || url.starts_with("http://") => url.to_owned(), | |
| 176 | + | Some(path) if path.starts_with('/') => format!("{site}{path}"), | |
| 177 | + | _ => format!("{site}/{repo}/commit/{sha}"), | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// A status's description cut to what GitHub keeps. | |
| 182 | + | pub fn github_description(description: Option<&str>) -> Option<String> { | |
| 183 | + | let text = description.map(str::trim).filter(|text| !text.is_empty())?; | |
| 184 | + | if text.chars().count() <= GITHUB_DESCRIPTION_CHARS { | |
| 185 | + | return Some(text.to_owned()); | |
| 186 | + | } | |
| 187 | + | let cut: String = text.chars().take(GITHUB_DESCRIPTION_CHARS - 1).collect(); | |
| 188 | + | Some(format!("{}…", cut.trim_end())) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// Whether `sha` is a whole commit id, as GitHub's statuses take it. | |
| 192 | + | pub fn whole_commit(sha: &str) -> bool { | |
| 193 | + | sha.len() == 40 && sha.bytes().all(|byte| byte.is_ascii_hexdigit()) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// What one report to GitHub leaves on the link. | |
| 197 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 198 | + | pub enum Reported { | |
| 199 | + | /// It went through: any recorded problem is over. | |
| 200 | + | Clear, | |
| 201 | + | /// Nothing to say: the commit is not on GitHub (made on g1t during a | |
| 202 | + | /// takeover), the commit has all the statuses GitHub keeps, or GitHub | |
| 203 | + | /// is busy or limiting. The next status tries again. | |
| 204 | + | Keep, | |
| 205 | + | /// A lasting problem, shown on the mirror's page until a report goes | |
| 206 | + | /// through. | |
| 207 | + | Problem(String), | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// Reads GitHub's answer to `POST /repos/{owner}/{repo}/statuses/{sha}`. | |
| 211 | + | /// `message` is GitHub's own words, `problem` them said for people. | |
| 212 | + | pub fn reported(status: u16, message: &str, problem: &str) -> Reported { | |
| 213 | + | match status { | |
| 214 | + | 200..=299 => Reported::Clear, | |
| 215 | + | 403 if message.to_lowercase().contains("rate limit") => Reported::Keep, | |
| 216 | + | 403 => Reported::Problem(CHECKS_PERMISSION.to_owned()), | |
| 217 | + | 422 | 429 | 500..=599 => Reported::Keep, | |
| 218 | + | _ => Reported::Problem(format!("g1t can't report checks to GitHub: {problem}")), | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 121 | 222 | /// How a host's health moves with one more check. `now` in milliseconds. | |
| 122 | 223 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 123 | 224 | pub struct HostHealth { | |
| ⋯ | |||
| 294 | 395 | pub settings: String, | |
| 295 | 396 | pub synced_at: Option<String>, | |
| 296 | 397 | pub last_error: Option<String>, | |
| 398 | + | /// Why the last check could not be reported to the remote. | |
| 399 | + | #[serde(default)] | |
| 400 | + | pub checks_error: Option<String>, | |
| 297 | 401 | pub created_at: String, | |
| 298 | 402 | } | |
| 299 | 403 | ||
| ⋯ | |||
| 356 | 460 | unreachable_since: health.and_then(|health| health.unreachable_since.clone()), | |
| 357 | 461 | synced_at: self.synced_at.clone(), | |
| 358 | 462 | last_error: self.last_error.clone(), | |
| 463 | + | checks_problem: self.checks_error.clone(), | |
| 359 | 464 | settings: self.settings(), | |
| 360 | 465 | created_at: self.created_at.clone(), | |
| 361 | 466 | } | |
| ⋯ | |||
| 409 | 514 | identity: Fetcher, | |
| 410 | 515 | events: Option<Fetcher>, | |
| 411 | 516 | github: GithubApp, | |
| 517 | + | /// g1t's own address, which checks reported to a remote link to. | |
| 518 | + | site_url: String, | |
| 412 | 519 | } | |
| 413 | 520 | ||
| 414 | 521 | impl Mirrors { | |
| 415 | 522 | pub fn new(env: &Env) -> Result<Self> { | |
| 416 | 523 | Ok(Mirrors { | |
| 524 | + | site_url: env.var("SITE_URL").map(|value| value.to_string()).unwrap_or_else(|_| "https://g1t.sh".to_owned()), | |
| 417 | 525 | db: env.d1("DB")?, | |
| 418 | 526 | sealer: env.secret("INTEGRATIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())), | |
| 419 | 527 | repos: env.service("REPOS")?, | |
| ⋯ | |||
| 1216 | 1324 | settings: "{}".to_owned(), | |
| 1217 | 1325 | synced_at: None, | |
| 1218 | 1326 | last_error: None, | |
| 1327 | + | checks_error: None, | |
| 1219 | 1328 | created_at: rfc3339(now), | |
| 1220 | 1329 | }; | |
| 1221 | 1330 | if row.name.eq_ignore_ascii_case(&format!("g1t.sh/{}", row.repo)) { | |
| ⋯ | |||
| 1435 | 1544 | Ok(()) | |
| 1436 | 1545 | } | |
| 1437 | 1546 | ||
| 1547 | + | /// A status set on a commit (`status.created`): a workflow run, a | |
| 1548 | + | /// deploy. A mirror of a GitHub repository whose workflows g1t runs | |
| 1549 | + | /// reports it to the commit there, once, as `g1t / <context>`. What | |
| 1550 | + | /// GitHub refuses for good is kept on the link for its page; nothing is | |
| 1551 | + | /// sent again until the next status. | |
| 1552 | + | pub async fn on_status(&self, event: &g1t_contracts::events::Event) -> Result<()> { | |
| 1553 | + | let Some(repo_id) = event.repo_id.as_deref() else { return Ok(()) }; | |
| 1554 | + | let Ok(status) = serde_json::from_value::<StatusEvent>(event.data.clone()) else { return Ok(()) }; | |
| 1555 | + | let (Some(state), true) = (github_state(&status.state), whole_commit(&status.sha)) else { return Ok(()) }; | |
| 1556 | + | let Some(row) = self.leader(repo_id).await? else { return Ok(()) }; | |
| 1557 | + | if !reports_checks(row.provider(), row.leads(), row.state(), &row.settings()) { | |
| 1558 | + | return Ok(()); | |
| 1559 | + | } | |
| 1560 | + | let Some(installation) = row.connection_id.as_deref().and_then(|id| id.parse::<u64>().ok()) else { | |
| 1561 | + | let problem = "g1t can't report checks to GitHub: this link has no GitHub installation.".to_owned(); | |
| 1562 | + | return self.checks_noted(&row, Reported::Problem(problem)).await; | |
| 1563 | + | }; | |
| 1564 | + | let token = match self.github.installation_token(installation).await? { | |
| 1565 | + | Ok(token) => token, | |
| 1566 | + | Err(reason) => { | |
| 1567 | + | return self.checks_noted(&row, Reported::Problem(format!("g1t can't report checks to GitHub: {reason}"))).await; | |
| 1568 | + | } | |
| 1569 | + | }; | |
| 1570 | + | let mut body = serde_json::json!({ | |
| 1571 | + | "state": state, | |
| 1572 | + | "context": github_context(&status.context), | |
| 1573 | + | "target_url": status_target(&self.site_url, &row.repo, &status.sha, status.target_url.as_deref()), | |
| 1574 | + | }); | |
| 1575 | + | if let Some(description) = github_description(status.description.as_deref()) { | |
| 1576 | + | body["description"] = Value::String(description); | |
| 1577 | + | } | |
| 1578 | + | let path = format!("/repos/{}/statuses/{}", row.full_name(), status.sha); | |
| 1579 | + | let answer = match self.github.api(Method::Post, &path, &token, Some(body)).await { | |
| 1580 | + | Ok(answer) => answer, | |
| 1581 | + | Err(error) => { | |
| 1582 | + | worker::console_log!("mirrors: {} check not reported to {}: {error}", row.repo, row.name); | |
| 1583 | + | return Ok(()); | |
| 1584 | + | } | |
| 1585 | + | }; | |
| 1586 | + | let said = answer.json(); | |
| 1587 | + | let verdict = reported(answer.status, said["message"].as_str().unwrap_or_default(), &answer.problem("GitHub")); | |
| 1588 | + | if verdict == Reported::Keep && !answer.ok() { | |
| 1589 | + | worker::console_log!("mirrors: {} check not reported to {}: {}", row.repo, row.name, answer.problem("GitHub")); | |
| 1590 | + | } | |
| 1591 | + | self.checks_noted(&row, verdict).await | |
| 1592 | + | } | |
| 1593 | + | ||
| 1594 | + | /// Keeps what the last report to a remote left, writing only a change. | |
| 1595 | + | async fn checks_noted(&self, row: &RemoteRow, verdict: Reported) -> Result<()> { | |
| 1596 | + | let next = match verdict { | |
| 1597 | + | Reported::Keep => return Ok(()), | |
| 1598 | + | Reported::Clear => None, | |
| 1599 | + | Reported::Problem(problem) => Some(problem), | |
| 1600 | + | }; | |
| 1601 | + | if next == row.checks_error { | |
| 1602 | + | return Ok(()); | |
| 1603 | + | } | |
| 1604 | + | self.db | |
| 1605 | + | .prepare("UPDATE remotes SET checks_error = ?2 WHERE id = ?1") | |
| 1606 | + | .bind(&[row.id.as_str().into(), null_or(next.as_deref())])? | |
| 1607 | + | .run() | |
| 1608 | + | .await?; | |
| 1609 | + | Ok(()) | |
| 1610 | + | } | |
| 1611 | + | ||
| 1438 | 1612 | /// Records one check of a host, and acts when it goes down or comes back. | |
| 1439 | 1613 | async fn host_checked(&self, host: &str, answered: bool, problem: Option<&str>) -> Result<()> { | |
| 1440 | 1614 | let now = now_ms(); | |
| ⋯ | |||
| 1782 | 1956 | assert_eq!(moves[0].old.as_deref(), Some("old"), "an old hand-back branch is moved from where it is"); | |
| 1783 | 1957 | assert_eq!(moves.len(), 2); | |
| 1784 | 1958 | } | |
| 1959 | + | ||
| 1960 | + | #[test] | |
| 1961 | + | fn checks_are_named_for_g1t_on_github() { | |
| 1962 | + | assert_eq!(github_context("CI / push"), "g1t / CI / push"); | |
| 1963 | + | assert_eq!(github_context("g1t / deploy"), "g1t / deploy", "already g1t's"); | |
| 1964 | + | assert_eq!(github_context(" Lint / pull_request "), "g1t / Lint / pull_request"); | |
| 1965 | + | assert_eq!(github_context("g1t/deploy"), "g1t / g1t/deploy", "only the exact prefix counts"); | |
| 1966 | + | for state in ["pending", "success", "failure", "error"] { | |
| 1967 | + | assert_eq!(github_state(state), Some(state)); | |
| 1968 | + | } | |
| 1969 | + | assert_eq!(github_state("cancelled"), None); | |
| 1970 | + | assert_eq!(github_state(""), None); | |
| 1971 | + | } | |
| 1972 | + | ||
| 1973 | + | #[test] | |
| 1974 | + | fn only_a_github_mirror_running_workflows_reports_checks() { | |
| 1975 | + | let on = MirrorSettings::default(); | |
| 1976 | + | let warm = MirrorSettings { keep_ci_warm: true, ..MirrorSettings::default() }; | |
| 1977 | + | let off = MirrorSettings { report_checks: false, keep_ci_warm: true, ..MirrorSettings::default() }; | |
| 1978 | + | let github = RemoteProvider::Github; | |
| 1979 | + | assert!(!reports_checks(github, true, RemoteState::Standby, &on), "a quiet standby runs nothing"); | |
| 1980 | + | assert!(reports_checks(github, true, RemoteState::Standby, &warm), "CI kept warm"); | |
| 1981 | + | for state in [RemoteState::Ci, RemoteState::Takeover, RemoteState::HandingBack] { | |
| 1982 | + | assert!(reports_checks(github, true, state, &on), "{state:?}"); | |
| 1983 | + | assert!(!reports_checks(github, true, state, &off), "{state:?} turned off"); | |
| 1984 | + | assert!(!reports_checks(RemoteProvider::G1t, true, state, &on), "{state:?} on another g1t"); | |
| 1985 | + | assert!(!reports_checks(RemoteProvider::Git, true, state, &on), "{state:?} on a git host"); | |
| 1986 | + | } | |
| 1987 | + | assert!(!reports_checks(github, false, RemoteState::Following, &warm), "a follower is g1t's own"); | |
| 1988 | + | assert!(!reports_checks(github, true, RemoteState::Stuck, &warm)); | |
| 1989 | + | } | |
| 1990 | + | ||
| 1991 | + | #[test] | |
| 1992 | + | fn a_reported_check_links_back_to_g1t() { | |
| 1993 | + | let sha = "a".repeat(40); | |
| 1994 | + | assert_eq!( | |
| 1995 | + | status_target("https://g1t.sh", "acme/web", &sha, Some("https://g1t.sh/acme/web/actions/runs/run_1")), | |
| 1996 | + | "https://g1t.sh/acme/web/actions/runs/run_1" | |
| 1997 | + | ); | |
| 1998 | + | assert_eq!(status_target("https://g1t.sh/", "acme/web", &sha, Some("/acme/web/deployments")), "https://g1t.sh/acme/web/deployments"); | |
| 1999 | + | assert_eq!(status_target("https://g1t.sh", "acme/web", &sha, None), format!("https://g1t.sh/acme/web/commit/{sha}")); | |
| 2000 | + | assert_eq!(status_target("https://g1t.sh", "acme/web", &sha, Some(" ")), format!("https://g1t.sh/acme/web/commit/{sha}")); | |
| 2001 | + | assert!(whole_commit(&sha) && !whole_commit("abc123") && !whole_commit(&"z".repeat(40))); | |
| 2002 | + | assert_eq!(github_description(Some("CI passed")).as_deref(), Some("CI passed")); | |
| 2003 | + | assert_eq!(github_description(Some(" ")), None); | |
| 2004 | + | let long = github_description(Some(&"word ".repeat(60))).unwrap(); | |
| 2005 | + | assert!(long.chars().count() <= 140 && long.ends_with('…')); | |
| 2006 | + | } | |
| 2007 | + | ||
| 2008 | + | #[test] | |
| 2009 | + | fn what_github_answers_is_kept_only_when_it_lasts() { | |
| 2010 | + | assert_eq!(reported(201, "", ""), Reported::Clear); | |
| 2011 | + | assert_eq!(reported(403, "Resource not accessible by integration", "x"), Reported::Problem(CHECKS_PERMISSION.to_owned())); | |
| 2012 | + | assert_eq!(reported(403, "API rate limit exceeded for installation", "x"), Reported::Keep); | |
| 2013 | + | assert_eq!(reported(422, "No commit found for SHA: abc", "x"), Reported::Keep, "a commit made on g1t"); | |
| 2014 | + | assert_eq!(reported(502, "", "x"), Reported::Keep); | |
| 2015 | + | assert_eq!( | |
| 2016 | + | reported(404, "Not Found", "GitHub has nothing there (Not Found)."), | |
| 2017 | + | Reported::Problem("g1t can't report checks to GitHub: GitHub has nothing there (Not Found).".to_owned()) | |
| 2018 | + | ); | |
| 2019 | + | } | |
| 1785 | 2020 | } | |
| 10 | 10 | //! failing jobs printed; once it is out of revisions, only a required | |
| 11 | 11 | //! check holds the pull request for a person. | |
| 12 | 12 | ||
| 13 | + | use g1t_contracts::checks::StatusEvent; | |
| 13 | 14 | use g1t_contracts::events::ChecksEvent; | |
| 14 | 15 | use g1t_contracts::time::rfc3339; | |
| 15 | 16 | use g1t_contracts::work::{ | |
| ⋯ | |||
| 215 | 216 | .collect()) | |
| 216 | 217 | } | |
| 217 | 218 | ||
| 219 | + | /// A status from another part of g1t: a workflow run, a deploy, a scan. | |
| 220 | + | /// Published as `status.created`, as one set through the API is, so | |
| 221 | + | /// webhooks hear it and a GitHub mirror's commit is told (integrations). | |
| 222 | + | /// Publishing is best effort: the status is kept either way. | |
| 218 | 223 | pub(crate) async fn set_commit_status(&self, a: SetCommitStatusArgs) -> Result<Outcome<bool>> { | |
| 219 | − | self.store_status(a, None).await | |
| 224 | + | let event = StatusEvent { | |
| 225 | + | repo_id: a.repo_id.clone(), | |
| 226 | + | sha: a.sha.clone(), | |
| 227 | + | context: a.context.clone(), | |
| 228 | + | state: a.state.clone(), | |
| 229 | + | description: a.description.clone(), | |
| 230 | + | target_url: a.target_url.clone(), | |
| 231 | + | }; | |
| 232 | + | let repo_id = a.repo_id.clone(); | |
| 233 | + | let stored = self.store_status(a, None).await?; | |
| 234 | + | if matches!(stored, Outcome::Ok(_)) | |
| 235 | + | && let Err(error) = self.publish_as("status.created", &repo_id, None, event).await | |
| 236 | + | { | |
| 237 | + | worker::console_error!("status.created not published: {error}"); | |
| 238 | + | } | |
| 239 | + | Ok(stored) | |
| 220 | 240 | } | |
| 221 | 241 | ||
| 222 | 242 | /// Sets a status, standing for check run `check_run_id` if it does, | |