Skip to content

Commit

Merge branch 'worktree-agent-a049c374e6f1c6be4' into claude/cool-newton-7nm8r9

Claudeagentcommitted Parents53e60f7ccf9266Browse files
19 files+384−100/19 viewed
+6−2
8383
8484 pub fn description(self) -> &'static str {
8585 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.",
8787 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.",
8888 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.",
8989 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.",
9191 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.",
9292 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.",
9393 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.",
9595 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.",
9696 }
9797 }
146146 "keep_ci_warm": { "type": "boolean" },
147147 "github_workflows": { "type": "boolean" },
148148 "hold_deploys": { "type": "boolean" },
149+ "report_checks": { "type": "boolean" },
149150 "remote_pushes": { "type": "string", "enum": ["adopt", "overwrite"] },
150151 }),
151152 &["repo", "id"],
195196 ("keep_ci_warm", &mut settings.keep_ci_warm),
196197 ("github_workflows", &mut settings.github_workflows),
197198 ("hold_deploys", &mut settings.hold_deploys),
199+ ("report_checks", &mut settings.report_checks),
198200 ] {
199201 if let Some(value) = input.get(key).filter(|value| !value.is_null()) {
200202 *field = flag(value).ok_or_else(|| format!("{key} is true or false."))?;
355357 assert!(changed.keep_ci_warm);
356358 assert_eq!(changed.hand_back, HandBack::WhenClean);
357359 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);
358362 let cleared = settings_from(changed, &json!({ "take_over_after": null })).unwrap();
359363 assert_eq!(cleared.take_over_after, None);
360364 assert!(settings_from(base.clone(), &json!({ "notify": "pager" })).is_err());
+16−0
1242212422 "unreachable_since": null,
1242312423 "synced_at": "2026-10-08T13:57:42.000Z",
1242412424 "last_error": null,
12425+ "checks_problem": null,
1242512426 "settings": {
1242612427 "notify": "banner",
1242712428 "take_over_after": null,
1242912430 "keep_ci_warm": false,
1243012431 "github_workflows": true,
1243112432 "hold_deploys": true,
12433+ "report_checks": true,
1243212434 "remote_pushes": "adopt"
1243312435 },
1243412436 "created_at": "2026-10-05T10:00:00.000Z"
1246112463 "unreachable_since": null,
1246212464 "synced_at": "2026-10-08T13:57:42.000Z",
1246312465 "last_error": null,
12466+ "checks_problem": null,
1246412467 "settings": {
1246512468 "notify": "banner",
1246612469 "take_over_after": null,
1246812471 "keep_ci_warm": false,
1246912472 "github_workflows": true,
1247012473 "hold_deploys": true,
12474+ "report_checks": true,
1247112475 "remote_pushes": "adopt"
1247212476 },
1247312477 "created_at": "2026-10-05T10:00:00.000Z"
1252912533 "unreachable_since": "2026-10-08T14:04:00.000Z",
1253012534 "synced_at": "2026-10-08T13:57:42.000Z",
1253112535 "last_error": null,
12536+ "checks_problem": null,
1253212537 "settings": {
1253312538 "notify": "banner",
1253412539 "take_over_after": null,
1253612541 "keep_ci_warm": false,
1253712542 "github_workflows": true,
1253812543 "hold_deploys": true,
12544+ "report_checks": true,
1253912545 "remote_pushes": "adopt"
1254012546 },
1254112547 "created_at": "2026-10-05T10:00:00.000Z"
1257012576 "unreachable_since": null,
1257112577 "synced_at": "2026-10-08T13:57:42.000Z",
1257212578 "last_error": null,
12579+ "checks_problem": null,
1257312580 "settings": {
1257412581 "notify": "banner",
1257512582 "take_over_after": null,
1257712584 "keep_ci_warm": false,
1257812585 "github_workflows": true,
1257912586 "hold_deploys": true,
12587+ "report_checks": true,
1258012588 "remote_pushes": "adopt"
1258112589 },
1258212590 "created_at": "2026-10-05T10:00:00.000Z"
1261312621 "unreachable_since": null,
1261412622 "synced_at": "2026-10-08T13:57:42.000Z",
1261512623 "last_error": null,
12624+ "checks_problem": null,
1261612625 "settings": {
1261712626 "notify": "banner",
1261812627 "take_over_after": null,
1262012629 "keep_ci_warm": false,
1262112630 "github_workflows": true,
1262212631 "hold_deploys": true,
12632+ "report_checks": true,
1262312633 "remote_pushes": "adopt"
1262412634 },
1262512635 "created_at": "2026-10-05T10:00:00.000Z"
1267112681 "unreachable_since": null,
1267212682 "synced_at": "2026-10-08T13:57:42.000Z",
1267312683 "last_error": null,
12684+ "checks_problem": null,
1267412685 "settings": {
1267512686 "notify": "banner",
1267612687 "take_over_after": null,
1267812689 "keep_ci_warm": false,
1267912690 "github_workflows": true,
1268012691 "hold_deploys": true,
12692+ "report_checks": true,
1268112693 "remote_pushes": "adopt"
1268212694 },
1268312695 "created_at": "2026-10-05T10:00:00.000Z"
1271412726 "unreachable_since": null,
1271512727 "synced_at": "2026-10-08T13:57:42.000Z",
1271612728 "last_error": null,
12729+ "checks_problem": null,
1271712730 "settings": {
1271812731 "notify": "banner",
1271912732 "take_over_after": null,
1272112734 "keep_ci_warm": false,
1272212735 "github_workflows": true,
1272312736 "hold_deploys": true,
12737+ "report_checks": true,
1272412738 "remote_pushes": "adopt"
1272512739 },
1272612740 "created_at": "2026-10-05T10:00:00.000Z"
1275212766 "unreachable_since": null,
1275312767 "synced_at": "2026-10-08T13:57:42.000Z",
1275412768 "last_error": null,
12769+ "checks_problem": null,
1275512770 "settings": {
1275612771 "notify": "inbox",
1275712772 "take_over_after": 30,
1275912774 "keep_ci_warm": false,
1276012775 "github_workflows": true,
1276112776 "hold_deploys": true,
12777+ "report_checks": true,
1276212778 "remote_pushes": "adopt"
1276312779 },
1276412780 "created_at": "2026-10-05T10:00:00.000Z"
+6−0
4242 GitHub that changes workflows or local actions waits for approval before
4343 it can use the repository's secrets.
4444
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+
4551 Then add your [secrets and variables](#secrets-and-variables): GitHub never
4652 gives their values out, so they cannot be copied across.
4753
+1−1
250250
251251 | Event | When |
252252 | --- | --- |
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. |
254254 | `check_run.created` | A check run was reported. |
255255 | `check_run.completed` | A check run completed. |
256256 | `check_run.rerequested` | Someone asked for a check run to run again. |
+1−0
162162 | Metadata | Read | Required by GitHub for every app: names, visibility, and whether a branch is protected. |
163163 | Issues | Read | Copy issues when you ask. |
164164 | 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). |
165166 | Email addresses (account) | Read | Find your verified email when you sign in. |
166167
167168 The app only ever sees the repositories you or your organization's owners
+41−2
120120 | Keep CI warm | Off | Run `.g1t/workflows` on each push copied in |
121121 | Run the remote's `.github` workflows | On | Off |
122122 | 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). |
123124
124125 **Hand back on its own** only applies to takeovers g1t started itself. A
125126 takeover a person started waits for a person to hand it back.
143144 **End CI failover** when the remote runs its workflows again. Runs already
144145 started finish.
145146
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+
146184 ## Take over
147185
148186 **Take over** makes g1t lead the repository for now, whether or not the
257295
258296 ## Not supported yet
259297
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.
262301 - CI failover starts only when someone starts it.
263302 - A takeover runs under the repository's own [rules](/guides/rules/) on
264303 g1t, not the remote's branch protection.
+1−1
243243 | Setup URL | `PUBLIC_URL/integrations/github/setup` |
244244 | Redirect on update | On |
245245 | 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 |
247247 | Account permissions | Email addresses: Read |
248248
249249 3. Create it, then on its page note the **App ID**, the **Client ID** and
+1−1
9999 | `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`. |
100100 | `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`. |
101101 | `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/). |
103103 | `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`. |
104104 | `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. |
105105 | `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`. |
+0−0

Binary or large file; its contents are not shown.

+13−0
308308 <span className="min-w-0 break-words">{remote.lastError}</span>
309309 </p>
310310 )}
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+ )}
311317 {canManage && state !== "handing_back" && (
312318 <div className="flex flex-wrap items-center gap-2 border-t border-line px-4 py-3">
313319 {(state === "standby" || state === "ci") && (
516522 <Form method="post" className="space-y-10">
517523 <input type="hidden" name="intent" value="settings" />
518524 <input type="hidden" name="kind" value="leader" />
525+ <input type="hidden" name="provider" value={remote.provider} />
519526 <input type="hidden" name="remoteId" value={remote.id} />
520527 <fieldset disabled={!canManage} className="space-y-10">
521528 <Section
567574 <SettingToggle name="holdDeploys" on={settings.holdDeploys} title="Hold workflows that deploy for approval">
568575 A job that deploys waits for someone to approve it, so taking over never ships anything by surprise.
569576 </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+ )}
570583 </Section>
571584 </fieldset>
572585 {canManage && (
+7−0
153153 keepCiWarm: false,
154154 githubWorkflows: true,
155155 holdDeploys: false,
156+ reportChecks: true,
156157 remotePushes: "overwrite",
157158 });
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);
158165 const followerForm = new FormData();
159166 followerForm.set("kind", "follower");
160167 followerForm.set("remotePushes", "overwrite");
+2−0
219219 keepCiWarm: on("keepCiWarm"),
220220 githubWorkflows: on("githubWorkflows"),
221221 holdDeploys: on("holdDeploys"),
222+ // Only a GitHub mirror's form carries it.
223+ reportChecks: form.get("provider") === "github" ? on("reportChecks") : current.reportChecks,
222224 };
223225 }
224226
+10−0
337337 pub github_workflows: bool,
338338 /// Hold deploy jobs for approval in CI failover and during a takeover.
339339 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,
340344 /// For followers.
341345 pub remote_pushes: RemotePushes,
342346 }
350354 keep_ci_warm: false,
351355 github_workflows: true,
352356 hold_deploys: true,
357+ report_checks: true,
353358 remote_pushes: RemotePushes::Adopt,
354359 }
355360 }
383388 pub unreachable_since: Option<String>,
384389 pub synced_at: Option<String>,
385390 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>,
386395 pub settings: MirrorSettings,
387396 pub created_at: String,
388397 }
727736 assert_eq!(settings.notify, Notify::Banner);
728737 assert_eq!(settings.take_over_after, None);
729738 assert!(!settings.keep_ci_warm);
739+ assert!(settings.report_checks, "checks are reported whenever g1t runs workflows, unless turned off");
730740 }
731741 }
+6−2
6565 "repo.archived",
6666 ],
6767 ),
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"]),
7072 // A hook may ask for any type, or for every one.
7173 ("SUBSCRIBER_WEBHOOKS", &["*"]),
7274 // Every event a workflow can run on (g1t_actions::events::github_events),
313315 assert!(!routed("SUBSCRIBER_ACTIONS", "check_run.completed"));
314316 assert!(routed("SUBSCRIBER_RUNNER", "comment.created"));
315317 assert!(!routed("SUBSCRIBER_RUNNER", "status.created"));
318+ assert!(routed("SUBSCRIBER_INTEGRATIONS", "status.created"));
319+ assert!(!routed("SUBSCRIBER_INTEGRATIONS", "check_run.completed"));
316320 assert!(routed("SUBSCRIBER_BILLING", "workspace.renamed"));
317321 assert!(!routed("SUBSCRIBER_BILLING", "git.push"));
318322 assert!(routed("SUBSCRIBER_SEARCH", "user.updated"));
+5−0
105105 keepCiWarm: boolean;
106106 githubWorkflows: boolean;
107107 holdDeploys: boolean;
108+ /** A GitHub mirror: report g1t's statuses to the commit on GitHub while g1t runs its workflows. */
109+ reportChecks: boolean;
108110 /** For followers: pushes made on the remote itself. */
109111 remotePushes: "adopt" | "overwrite";
110112 };
116118 keepCiWarm: false,
117119 githubWorkflows: true,
118120 holdDeploys: true,
121+ reportChecks: true,
119122 remotePushes: "adopt",
120123 };
121124
139142 unreachableSince: string | null;
140143 syncedAt: string | null;
141144 lastError: string | null;
145+ /** Why g1t could not report checks to the remote, until it next can. */
146+ checksProblem?: string | null;
142147 settings: MirrorSettings;
143148 createdAt: string;
144149 };
+6−0
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;
+6−0
17111711 if message.body().kind == "git.push" && github::first_push_in_batch(&mut pushed, message.body()) {
17121712 remotes::Mirrors::new(&env)?.on_event(message.body()).await?;
17131713 }
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+ }
17141720 service.on_event(message.body()).await?;
17151721 message.ack();
17161722 }
+235−0
2727 //! Nothing happens on its own unless someone asked for it in the link's
2828 //! settings: by default an unreachable remote is only shown on the
2929 //! 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.
3037
3138 use std::collections::{BTreeMap, BTreeSet, HashMap};
3239
3340 use g1t_contracts::access::{self, Capability};
41+use g1t_contracts::checks::StatusEvent;
3442 use g1t_contracts::events::{NewEvent, Publish};
3543 use g1t_contracts::identity::{ListMembersArgs, Member};
3644 use g1t_contracts::mirrors::*;
118126 clone_url.strip_suffix(".git").unwrap_or(clone_url).to_owned()
119127 }
120128
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+
121222 /// How a host's health moves with one more check. `now` in milliseconds.
122223 #[derive(Clone, Debug, Default, PartialEq, Eq)]
123224 pub struct HostHealth {
294395 pub settings: String,
295396 pub synced_at: Option<String>,
296397 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>,
297401 pub created_at: String,
298402 }
299403
356460 unreachable_since: health.and_then(|health| health.unreachable_since.clone()),
357461 synced_at: self.synced_at.clone(),
358462 last_error: self.last_error.clone(),
463+ checks_problem: self.checks_error.clone(),
359464 settings: self.settings(),
360465 created_at: self.created_at.clone(),
361466 }
409514 identity: Fetcher,
410515 events: Option<Fetcher>,
411516 github: GithubApp,
517+ /// g1t's own address, which checks reported to a remote link to.
518+ site_url: String,
412519 }
413520
414521 impl Mirrors {
415522 pub fn new(env: &Env) -> Result<Self> {
416523 Ok(Mirrors {
524+ site_url: env.var("SITE_URL").map(|value| value.to_string()).unwrap_or_else(|_| "https://g1t.sh".to_owned()),
417525 db: env.d1("DB")?,
418526 sealer: env.secret("INTEGRATIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())),
419527 repos: env.service("REPOS")?,
12161324 settings: "{}".to_owned(),
12171325 synced_at: None,
12181326 last_error: None,
1327+ checks_error: None,
12191328 created_at: rfc3339(now),
12201329 };
12211330 if row.name.eq_ignore_ascii_case(&format!("g1t.sh/{}", row.repo)) {
14351544 Ok(())
14361545 }
14371546
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+
14381612 /// Records one check of a host, and acts when it goes down or comes back.
14391613 async fn host_checked(&self, host: &str, answered: bool, problem: Option<&str>) -> Result<()> {
14401614 let now = now_ms();
17821956 assert_eq!(moves[0].old.as_deref(), Some("old"), "an old hand-back branch is moved from where it is");
17831957 assert_eq!(moves.len(), 2);
17841958 }
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+ }
17852020 }
+21−1
1010 //! failing jobs printed; once it is out of revisions, only a required
1111 //! check holds the pull request for a person.
1212
13+use g1t_contracts::checks::StatusEvent;
1314 use g1t_contracts::events::ChecksEvent;
1415 use g1t_contracts::time::rfc3339;
1516 use g1t_contracts::work::{
215216 .collect())
216217 }
217218
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.
218223 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)
220240 }
221241
222242 /// Sets a status, standing for check run `check_run_id` if it does,