Mirroring on the site and in the docs
A mirror says so beside its name (Mirror of, running CI, Taken over from, Handing back to, Mirrored to) with a banner when the remote isn't answering or g1t leads; every button that would write is greyed out with the reason. Settings -> Mirroring: where work happens, take over, CI failover, the hand-back plan with decisions for diverged branches, move to g1t ("g1t will no longer track the remote"), the link's levers, and followers. New guide guides/mirroring; GitHub and Actions guides updated; docs/MIRRORING.md is the design record.
34 files+2143−4840/34 viewed
| 142 | 142 | items: [ | |
| 143 | 143 | { label: 'Accounts and sign-in', slug: 'guides/authentication' }, | |
| 144 | 144 | { label: 'GitHub', slug: 'guides/github' }, | |
| 145 | + | { label: 'Mirroring', slug: 'guides/mirroring' }, | |
| 145 | 146 | { label: 'Workspaces and tokens', slug: 'guides/workspaces' }, | |
| 146 | 147 | { label: 'Access and roles', slug: 'guides/access-and-roles' }, | |
| 147 | 148 | { label: 'Teams', slug: 'guides/teams' }, |
| 19 | 19 | Workflows that still say `uses: ./.github/actions/setup` find it under | |
| 20 | 20 | `.g1t/` once `.github` is gone. | |
| 21 | 21 | ||
| 22 | − | g1t never reads `.github`. A repository mirrored to both places can keep | |
| 23 | − | `.github` for GitHub and `.g1t` for g1t, side by side. | |
| 22 | + | Otherwise g1t doesn't read `.github`, so a repository that lives in both | |
| 23 | + | places can keep `.github` for GitHub and `.g1t` for g1t, side by side. | |
| 24 | + | ||
| 25 | + | ### On a mirror | |
| 26 | + | ||
| 27 | + | A [mirror](/guides/mirroring/) of a GitHub repository runs nothing while it | |
| 28 | + | stands by: its workflows run on GitHub. Its owners can change that: | |
| 29 | + | ||
| 30 | + | - **CI failover** runs its workflows on g1t for each push copied in from | |
| 31 | + | GitHub, `.github/workflows` as well as `.g1t/workflows`. Use it when | |
| 32 | + | GitHub's workflows aren't running. | |
| 33 | + | - **Taking over** runs them for everything pushed to g1t, until it's handed | |
| 34 | + | back. | |
| 35 | + | - **Keep CI warm** runs `.g1t/workflows`, and only those, on each push | |
| 36 | + | copied in while it stands by. | |
| 37 | + | ||
| 38 | + | When both folders have a workflow of the same `name:`, the one in `.g1t` | |
| 39 | + | runs. A workflow that deploys (any job names an `environment:`) waits for | |
| 40 | + | approval during CI failover and takeovers, so it doesn't deploy from two | |
| 41 | + | places, unless the mirror's settings say otherwise. A push copied in from | |
| 42 | + | GitHub that changes workflows or local actions waits for approval before | |
| 43 | + | it can use the repository's secrets. | |
| 24 | 44 | ||
| 25 | 45 | Then add your [secrets and variables](#secrets-and-variables): GitHub never | |
| 26 | 46 | gives their values out, so they cannot be copied across. |
| 86 | 86 | | Choice | What happens | Where you push | | |
| 87 | 87 | | --- | --- | --- | | |
| 88 | 88 | | **Import** | Copied once. The g1t repository is then its own. | g1t | | |
| 89 | − | | **Mirror** | Copied, then every push to GitHub is fetched into g1t within seconds. Branches deleted on GitHub are deleted on g1t. | GitHub | | |
| 90 | − | | **Move to g1t** | Copied, then every push to g1t is pushed to GitHub, so people still working there see it. Branches that exist only on GitHub are left alone. | g1t | | |
| 89 | + | | **Standby mirror** | Copied, then every push to GitHub is copied in within seconds. On g1t it's a read-only copy that runs nothing, until you take over. | GitHub | | |
| 90 | + | | **Move to g1t** | Copied, then g1t leads: every push to g1t is pushed to GitHub, so people still working there see it. Branches that exist only on GitHub are left alone. | g1t | | |
| 91 | 91 | ||
| 92 | − | A mirror is a copy that follows GitHub: anything pushed to the g1t copy | |
| 93 | − | directly is overwritten at the next sync. Deployments, previews, checks | |
| 94 | − | and agents run on the copy as on any project. | |
| 92 | + | A standby mirror is a backup you can switch to: if GitHub is down, or | |
| 93 | + | whenever you want to work on g1t for a while, **take over** in its | |
| 94 | + | **Settings → Mirroring**, and **hand back** when you're done. You can also | |
| 95 | + | run its GitHub workflows on g1t while GitHub's don't run, or move it to g1t | |
| 96 | + | for good. See [mirroring](/guides/mirroring/). | |
| 95 | 97 | ||
| 96 | 98 | ### What comes across | |
| 97 | 99 | ||
| ⋯ | |||
| 104 | 106 | | Issue numbers | New numbers on g1t; each issue links to the original | | |
| 105 | 107 | | Comments on issues | No | | |
| 106 | 108 | | Pull requests | No. Their branches come across; open pull requests stay on GitHub. | | |
| 107 | − | | GitHub Actions workflows | Yes, as files. Rename `.github` to `.g1t` to run them on g1t: see [GitHub Actions](/guides/actions/). | | |
| 109 | + | | GitHub Actions workflows | Yes, as files. A mirror runs them on g1t in CI failover and when taken over; otherwise rename `.github` to `.g1t` to run them on g1t: see [GitHub Actions](/guides/actions/). | | |
| 108 | 110 | | Releases, wikis, Git LFS objects | No | | |
| 109 | 111 | ||
| 110 | 112 | A repository is copied in one piece of at most 40 MB, after compression. | |
| ⋯ | |||
| 112 | 114 | ||
| 113 | 115 | ### Keep a mirror in step | |
| 114 | 116 | ||
| 115 | − | The repository's overview shows where it came from and when it last | |
| 116 | − | synced. **Sync now** copies at once; **Stop mirroring** (or **Stop | |
| 117 | − | pushing**) ends the tie and leaves the g1t repository as it is. A mirror | |
| 118 | − | also stops, keeping its copy, when: | |
| 117 | + | The repository's **Settings → Mirroring** shows the link: its state, | |
| 118 | + | whether GitHub is answering, and when it last synced. **Sync now** copies | |
| 119 | + | at once. **Take over**, **Hand back**, **Move to g1t** and **Remove** are | |
| 120 | + | there too: see [mirroring](/guides/mirroring/). A standby mirror stops, | |
| 121 | + | keeping its copy, when: | |
| 119 | 122 | ||
| 120 | 123 | - the repository is deleted on GitHub, | |
| 121 | 124 | - the app is uninstalled from its GitHub account, or | |
| 122 | 125 | - a workspace owner removes that GitHub account from the workspace. | |
| 123 | 126 | ||
| 124 | − | If GitHub stops letting the app see a repository, its overview says so; | |
| 125 | − | add it back to the installation on GitHub to carry on. Renaming or | |
| 127 | + | If GitHub stops letting the app see a repository, its settings say so; add | |
| 128 | + | it back to the installation on GitHub to carry on. Renaming or | |
| 126 | 129 | transferring a repository on GitHub keeps the mirror working. | |
| 127 | 130 | ||
| 128 | 131 | ## What g1t's GitHub App can do | |
| 129 | 132 | ||
| 130 | 133 | | Permission | Access | Why | | |
| 131 | 134 | | --- | --- | --- | | |
| 132 | − | | Contents | Read and write | Read: clone the repositories you choose. Write: only for **Move to g1t**, to push. | | |
| 133 | − | | Metadata | Read | Required by GitHub for every app: names and visibility. | | |
| 135 | + | | Contents | Read and write | Read: clone the repositories you choose. Write: to push for **Move to g1t**, and to hand a takeover back. | | |
| 136 | + | | Metadata | Read | Required by GitHub for every app: names, visibility, and whether a branch is protected. | | |
| 134 | 137 | | Issues | Read | Copy issues when you ask. | | |
| 138 | + | | 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. | | |
| 135 | 139 | | Email addresses (account) | Read | Find your verified email when you sign in. | | |
| 136 | 140 | ||
| 137 | 141 | The app only ever sees the repositories you or your organization's owners | |
| 1 | + | --- | |
| 2 | + | title: Mirroring | |
| 3 | + | description: Keep a repository in step with copies of it elsewhere. A mirror stands by as a read-only copy until you take over; hand it back when you're done, or move it to g1t for good. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | Mirroring keeps a repository on g1t in step with a copy of it on another | |
| 7 | + | host: GitHub, another g1t (such as one you run yourself), or any git host | |
| 8 | + | over HTTPS. | |
| 9 | + | ||
| 10 | + | Every linked repository has exactly one **leader**, where work happens. The | |
| 11 | + | other copies follow it. That one rule answers every question about which | |
| 12 | + | push wins: a write reaches the leader, or it doesn't land. | |
| 13 | + | ||
| 14 | + | | The repository on g1t is | Who leads | What works on g1t | | |
| 15 | + | | --- | --- | --- | | |
| 16 | + | | A **mirror**, standing by | The remote | Reading, cloning, search. Nothing else: it's an exact, read-only copy that runs nothing. | | |
| 17 | + | | A mirror in **CI failover** | The remote, for code | The remote's workflows run on g1t. Code, issues and pull requests stay on the remote. | | |
| 18 | + | | A mirror **taken over** | g1t, for now | Everything: pushes, pull requests, issues, agents, workflows. | | |
| 19 | + | | **Mirrored to** other hosts | g1t | Everything. Each push is sent to the followers. | | |
| 20 | + | ||
| 21 | + | ## Make a mirror | |
| 22 | + | ||
| 23 | + | - **From GitHub:** choose **New project → Import from GitHub**, pick the | |
| 24 | + | repositories and choose **Standby mirror**. See [GitHub](/guides/github/). | |
| 25 | + | - **From another g1t or any git host:** create an empty repository, then | |
| 26 | + | in its **Settings → Mirroring** choose **Add a remote**, with **The remote | |
| 27 | + | leads**. Give its https address and a token that can read it. The | |
| 28 | + | repository is filled from the remote, and only an empty repository can | |
| 29 | + | become a mirror. | |
| 30 | + | ||
| 31 | + | A mirror follows every branch and tag of its remote: | |
| 32 | + | ||
| 33 | + | - **GitHub** tells g1t about each push, so the mirror catches up within | |
| 34 | + | seconds. | |
| 35 | + | - **Other hosts** are asked every five minutes. | |
| 36 | + | ||
| 37 | + | Branches deleted on the remote are deleted on g1t. When the remote | |
| 38 | + | force-pushes a branch or deletes it, the commit g1t had is kept under | |
| 39 | + | `refs/g1t/replaced/<branch>/<time>` for at least 30 days. Nothing a mirror | |
| 40 | + | held is lost silently. | |
| 41 | + | ||
| 42 | + | ## What a mirror standing by refuses | |
| 43 | + | ||
| 44 | + | A standby mirror is a backup you can switch to. It doesn't take work of its | |
| 45 | + | own: | |
| 46 | + | ||
| 47 | + | | | Standing by | CI failover | Taken over | | |
| 48 | + | | --- | --- | --- | --- | | |
| 49 | + | | Clone, fetch, browse, search | ✓ | ✓ | ✓ | | |
| 50 | + | | Push, merge, edit on the web | – | – | ✓ | | |
| 51 | + | | Issues, pull requests, agents | – | – | ✓ | | |
| 52 | + | | `.g1t/workflows` | Only with **Keep CI warm** | ✓ | ✓ | | |
| 53 | + | | `.github/workflows` | – | ✓ | ✓ unless turned off | | |
| 54 | + | | Deployments on g1t.page | – | – | ✓ | | |
| 55 | + | | Settings, rules, webhooks | ✓ | ✓ | ✓ | | |
| 56 | + | ||
| 57 | + | What's refused says why. A `git push` to a standby mirror answers: | |
| 58 | + | ||
| 59 | + | ```text | |
| 60 | + | remote: acme/web is a mirror of github.com/acme/web, so it is read-only here. | |
| 61 | + | remote: Push to github.com/acme/web, or take over in Settings → Mirroring to work on g1t. | |
| 62 | + | ! [remote rejected] main -> main | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | The repository shows **Mirror of github.com/acme/web** beside its name, | |
| 66 | + | and every button that would write is greyed out, with the reason on hover. | |
| 67 | + | ||
| 68 | + | ## When the remote stops answering | |
| 69 | + | ||
| 70 | + | g1t checks each remote's host every minute. A host is **unreachable** | |
| 71 | + | after three failed checks over at least two minutes. A refused token or a | |
| 72 | + | deleted repository isn't the host being down; those show as an error on | |
| 73 | + | the link instead. The host is reachable again after three good checks over | |
| 74 | + | at least five minutes. | |
| 75 | + | ||
| 76 | + | By default an unreachable remote is only **shown**: the repository's dot | |
| 77 | + | turns amber and a banner offers **Take over**. Nobody is paged and nothing | |
| 78 | + | happens on its own. If it's midnight and nobody needs to work, GitHub comes | |
| 79 | + | back and the mirror catches up. Nothing needs reconciling, because nobody | |
| 80 | + | wrote to g1t. | |
| 81 | + | ||
| 82 | + | You choose how much more happens, per link, in **Settings → Mirroring**: | |
| 83 | + | ||
| 84 | + | | Setting | Default | Options | | |
| 85 | + | | --- | --- | --- | | |
| 86 | + | | When the remote stops answering | Show it on the repository | Also tell the workspace's owners in their [inbox](/guides/inbox/) | | |
| 87 | + | | Take over automatically | Off | After 5 to 1440 minutes unreachable | | |
| 88 | + | | Hand back on its own | When every branch goes back cleanly | Only when someone hands it back | | |
| 89 | + | | Keep CI warm | Off | Run `.g1t/workflows` on each push copied in | | |
| 90 | + | | Run the remote's `.github` workflows | On | Off | | |
| 91 | + | | Hold workflows that deploy for approval | On | Off | | |
| 92 | + | ||
| 93 | + | **Hand back on its own** only applies to takeovers g1t started itself. A | |
| 94 | + | takeover a person started waits for a person to hand it back. | |
| 95 | + | ||
| 96 | + | ## CI failover | |
| 97 | + | ||
| 98 | + | Sometimes the remote's git works but its workflows don't run. **Start CI | |
| 99 | + | failover** keeps the remote in charge of the code and runs its workflows on | |
| 100 | + | g1t for each push copied in: | |
| 101 | + | ||
| 102 | + | - `.github/workflows` as well as `.g1t/workflows`. When both folders have | |
| 103 | + | a workflow of the same `name:`, g1t's own runs. | |
| 104 | + | - Workflows that deploy wait for approval, so nothing deploys from two | |
| 105 | + | places. A workflow deploys when any job names an `environment:`. | |
| 106 | + | - Secrets come from the [project on g1t](/guides/secrets-and-variables/), | |
| 107 | + | never the remote's. | |
| 108 | + | ||
| 109 | + | **End CI failover** when the remote runs its workflows again. Runs already | |
| 110 | + | started finish. | |
| 111 | + | ||
| 112 | + | ## Take over | |
| 113 | + | ||
| 114 | + | **Take over** makes g1t lead the repository for now, whether or not the | |
| 115 | + | remote is answering. Everything works on g1t from then on. Each branch's | |
| 116 | + | commit at that moment is recorded as where the takeover began. | |
| 117 | + | ||
| 118 | + | You can take over any time: during an outage g1t detected, a partial one | |
| 119 | + | it didn't, or simply because you want to work on g1t for a while. | |
| 120 | + | ||
| 121 | + | If the remote is still answering, people may keep pushing there. Their | |
| 122 | + | work isn't lost. Each branch that changed on both sides waits for a | |
| 123 | + | decision when you hand back. | |
| 124 | + | ||
| 125 | + | ## Hand back | |
| 126 | + | ||
| 127 | + | **Review hand-back** shows what handing back would do, branch by branch, | |
| 128 | + | by comparing each side with where the takeover began: | |
| 129 | + | ||
| 130 | + | | Plan | When | What happens | | |
| 131 | + | | --- | --- | --- | | |
| 132 | + | | **Push** | Only g1t changed it | g1t's commits are pushed to the remote. | | |
| 133 | + | | **Take** | Only the remote changed it | The remote's commits are copied in. | | |
| 134 | + | | **Pull request** | Only g1t changed it, and the remote protects the branch | g1t's commits go to `g1t/handback/<branch>` on the remote, with a pull request into the branch. g1t then follows the remote's branch. | | |
| 135 | + | | **Diverged** | Both changed it | You decide: keep g1t's (pushed over the remote's), keep the remote's (g1t's is kept under `refs/g1t/replaced/`), or send g1t's as a pull request. | | |
| 136 | + | ||
| 137 | + | **Hand back** carries out the plan. The repository is read-only while it | |
| 138 | + | goes back, so nothing moves under it. If the remote refuses anything, g1t | |
| 139 | + | keeps the lead and says why; nobody is left stuck. When every branch has | |
| 140 | + | gone back, the mirror stands by again and lists any pull requests it | |
| 141 | + | opened. | |
| 142 | + | ||
| 143 | + | Hand back is refused while the remote isn't answering, and while a | |
| 144 | + | diverged branch has no decision. | |
| 145 | + | ||
| 146 | + | ## Move to g1t | |
| 147 | + | ||
| 148 | + | **Move to g1t** makes the move permanent. The repository stops being a | |
| 149 | + | mirror, and **g1t no longer tracks the remote**: pushes made there don't | |
| 150 | + | come here any more. You can move from standby, CI failover or during a | |
| 151 | + | takeover, and the takeover's work stays as it is. | |
| 152 | + | ||
| 153 | + | Tick **Keep the remote updated from g1t** to turn the remote into a | |
| 154 | + | follower instead of dropping it. g1t then pushes every change to it, as | |
| 155 | + | below. | |
| 156 | + | ||
| 157 | + | ## Mirror to other hosts | |
| 158 | + | ||
| 159 | + | A repository g1t leads can be **mirrored to** any number of followers. | |
| 160 | + | Use another g1t to keep a copy on your own servers, or keep GitHub up to | |
| 161 | + | date for people still working there. | |
| 162 | + | ||
| 163 | + | In **Settings → Mirroring**, **Add a remote** with **g1t leads**, its https | |
| 164 | + | address, a username if the host needs one, and a token that can push. The | |
| 165 | + | token is stored encrypted and never shown again. Repositories imported | |
| 166 | + | from GitHub with **Move to g1t** follow g1t this way already. | |
| 167 | + | ||
| 168 | + | Each push to g1t is sent to every follower. When someone pushes to a | |
| 169 | + | follower directly: | |
| 170 | + | ||
| 171 | + | - **Take in fast-forwards** (the default): a push that builds on g1t's | |
| 172 | + | branch is copied in. Anything else marks the follower **stuck**. g1t | |
| 173 | + | stops pushing to it until someone chooses **Sync now**, which pushes | |
| 174 | + | g1t's branches over it. | |
| 175 | + | - **Overwrite with g1t's**: g1t pushes its branches over the change at once. | |
| 176 | + | ||
| 177 | + | A follower that refuses g1t's push, a protected branch say, is also marked | |
| 178 | + | stuck, with its answer on the link. | |
| 179 | + | ||
| 180 | + | ## Self-hosted g1t and g1t.sh | |
| 181 | + | ||
| 182 | + | The `g1t` remote works both ways between instances: | |
| 183 | + | ||
| 184 | + | - Run g1t on your own servers as the leader and mirror to | |
| 185 | + | [g1t.sh](https://g1t.sh) for a hosted copy, or the other way round. | |
| 186 | + | - Make a standby mirror on your own g1t of a repository on g1t.sh. Take | |
| 187 | + | over if g1t.sh is unreachable from your network. | |
| 188 | + | ||
| 189 | + | Use a [token](/guides/authentication/) with `code:read` for a leader, or | |
| 190 | + | `code:write` for a follower, on the other instance. | |
| 191 | + | ||
| 192 | + | ## API, MCP and events | |
| 193 | + | ||
| 194 | + | Everything here is also in the API at `/repos/{owner}/{name}/mirror`, and | |
| 195 | + | as the `mirror_*` actions of the MCP `repository` tool. See the | |
| 196 | + | [API reference](/reference/api/). Reading a repository's | |
| 197 | + | mirroring needs read access; syncing needs push; everything else needs the | |
| 198 | + | Admin role. Agents never move a repository to g1t or handle a remote's | |
| 199 | + | token. | |
| 200 | + | ||
| 201 | + | [Webhooks](/guides/webhooks/) can subscribe to: | |
| 202 | + | ||
| 203 | + | - `mirror.unreachable` and `mirror.reachable` | |
| 204 | + | - `mirror.state_changed`, sent when a mirror stands by, enters CI | |
| 205 | + | failover, is taken over or is handed back | |
| 206 | + | - `mirror.moved_in`, sent when a mirror is moved to g1t or stops because | |
| 207 | + | its remote is gone | |
| 208 | + | ||
| 209 | + | ## When a remote goes away | |
| 210 | + | ||
| 211 | + | A standby mirror becomes an ordinary repository, keeping what it has, when: | |
| 212 | + | ||
| 213 | + | - the repository is deleted on GitHub, | |
| 214 | + | - g1t's GitHub App is uninstalled from its account, or | |
| 215 | + | - a workspace owner removes that GitHub account from the workspace. | |
| 216 | + | ||
| 217 | + | A takeover in progress keeps going and says why on the link: move it to | |
| 218 | + | g1t to keep it. |
| 2 | 2 | ||
| 3 | 3 | import { cloneUrl, sshUrl, useAddresses } from "../lib/addresses"; | |
| 4 | 4 | import { AgentSetup } from "./agent-setup"; | |
| 5 | + | import { MirrorCloneNote, useRepoMirror } from "./mirror"; | |
| 5 | 6 | import { CopyLine } from "./ui"; | |
| 6 | 7 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 7 | 8 | ||
| 8 | 9 | /** The ways to get a repository onto a machine or in front of an agent. */ | |
| 9 | 10 | export function CloneBox({ path }: { path: string }) { | |
| 10 | 11 | const addresses = useAddresses(); | |
| 12 | + | // A mirror takes no pushes until someone takes over: it says where to push. | |
| 13 | + | const { repo, admin } = useRepoMirror(); | |
| 14 | + | const mirror = repo && `${repo.namespace}/${repo.name}` === path ? repo.mirror : null; | |
| 15 | + | const readOnly = mirror != null && mirror.state !== "takeover"; | |
| 11 | 16 | return ( | |
| 12 | 17 | <Tabs defaultValue="https"> | |
| 13 | 18 | <TabsList> | |
| ⋯ | |||
| 17 | 22 | </TabsList> | |
| 18 | 23 | <TabsContent value="https"> | |
| 19 | 24 | <CopyLine text={cloneUrl(addresses, path)} /> | |
| 20 | − | <p className="mt-2 text-xs text-muted"> | |
| 21 | − | To push, use your username and an{" "} | |
| 22 | − | <Link to="/settings/tokens" className="text-fg underline underline-offset-4"> | |
| 23 | − | access token | |
| 24 | − | </Link>{" "} | |
| 25 | − | as the password. | |
| 26 | − | </p> | |
| 25 | + | <MirrorCloneNote mirror={mirror} base={`/${path}`} admin={admin} /> | |
| 26 | + | {!readOnly && ( | |
| 27 | + | <p className="mt-2 text-xs text-muted"> | |
| 28 | + | To push, use your username and an{" "} | |
| 29 | + | <Link to="/settings/tokens" className="text-fg underline underline-offset-4"> | |
| 30 | + | access token | |
| 31 | + | </Link>{" "} | |
| 32 | + | as the password. | |
| 33 | + | </p> | |
| 34 | + | )} | |
| 27 | 35 | </TabsContent> | |
| 28 | 36 | <TabsContent value="ssh"> | |
| 29 | 37 | <CopyLine text={sshUrl(addresses, path)} disabled /> | |
| 1 | 1 | import type { GithubRepoLink } from "@g1t/contracts"; | |
| 2 | − | import { Form } from "react-router"; | |
| 3 | 2 | import { siGithub } from "simple-icons"; | |
| 4 | 3 | ||
| 5 | − | import { SubmitButton, TimeAgo } from "./ui"; | |
| 4 | + | import { TimeAgo } from "./ui"; | |
| 6 | 5 | ||
| 7 | 6 | /** GitHub's mark (from Simple Icons), for the buttons that go there. */ | |
| 8 | 7 | export function GithubMark({ className = "size-4" }: { className?: string }) { | |
| ⋯ | |||
| 39 | 38 | </div> | |
| 40 | 39 | ); | |
| 41 | 40 | } | |
| 42 | − | ||
| 43 | − | const LINK_LABEL: Record<GithubRepoLink["mode"], string> = { | |
| 44 | − | import: "Imported from", | |
| 45 | − | mirror: "Mirrored from", | |
| 46 | − | push: "Pushed to", | |
| 47 | − | }; | |
| 48 | 41 | ||
| 49 | 42 | /** | |
| 50 | − | * Where a repository's code came from on GitHub, on its overview: how it | |
| 51 | − | * stays in step, when it last did, and what went wrong if anything did. | |
| 43 | + | * Where a repository's code was imported from on GitHub, on its overview, | |
| 44 | + | * and when. A repository that stays in step with GitHub says so beside its | |
| 45 | + | * name instead, and is managed under Settings → Mirroring. | |
| 52 | 46 | */ | |
| 53 | 47 | export function GithubLinkStrip({ link }: { link: GithubRepoLink }) { | |
| 54 | − | const syncing = link.mode !== "import"; | |
| 55 | 48 | return ( | |
| 56 | 49 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-line px-4 py-2.5 text-sm"> | |
| 57 | 50 | <span className="flex min-w-0 items-start gap-2 text-muted"> | |
| 58 | 51 | <GithubMark className="mt-0.5 size-4 shrink-0" /> | |
| 59 | 52 | <span className="min-w-0"> | |
| 60 | − | {LINK_LABEL[link.mode]}{" "} | |
| 53 | + | Imported from{" "} | |
| 61 | 54 | <a href={`https://github.com/${link.fullName}`} className="font-mono break-all text-fg hover:text-accent" target="_blank" rel="noreferrer"> | |
| 62 | 55 | github.com/{link.fullName} | |
| 63 | 56 | </a> | |
| ⋯ | |||
| 65 | 58 | </span> | |
| 66 | 59 | {link.syncedAt && ( | |
| 67 | 60 | <span className="text-xs text-faint"> | |
| 68 | − | {syncing ? "synced" : "copied"} <TimeAgo at={link.syncedAt} /> | |
| 61 | + | copied <TimeAgo at={link.syncedAt} /> | |
| 69 | 62 | </span> | |
| 70 | − | )} | |
| 71 | − | {syncing && ( | |
| 72 | − | <Form method="post" className="ml-auto flex items-center gap-3"> | |
| 73 | − | <SubmitButton | |
| 74 | − | name="intent" | |
| 75 | − | value="github-sync" | |
| 76 | − | pending="Syncing…" | |
| 77 | − | className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50" | |
| 78 | − | > | |
| 79 | − | Sync now | |
| 80 | − | </SubmitButton> | |
| 81 | − | <SubmitButton | |
| 82 | − | name="intent" | |
| 83 | − | value="github-stop" | |
| 84 | − | pending="Stopping…" | |
| 85 | − | className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50" | |
| 86 | − | > | |
| 87 | − | Stop {link.mode === "mirror" ? "mirroring" : "pushing"} | |
| 88 | − | </SubmitButton> | |
| 89 | − | </Form> | |
| 90 | 63 | )} | |
| 91 | 64 | {link.lastError && <p className="w-full text-xs text-warn">{link.lastError}</p>} | |
| 92 | 65 | </div> | |
| 1 | + | import { ExternalLink, RefreshCw, Trash2, TriangleAlert } from "lucide-react"; | |
| 2 | + | import { type ReactNode, useEffect, useId, useState } from "react"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { HandbackPlan, MirrorView, RefDecision, Remote, RepoMirror } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { DangerAction, DangerZone } from "./danger-zone"; | |
| 8 | + | import { MirrorNotes, TakeOverDialog } from "./mirror"; | |
| 9 | + | import { ConfirmDialog } from "./repo-lifecycle"; | |
| 10 | + | import { SettingsSection as Section, SettingToggle } from "./settings-section"; | |
| 11 | + | import { Button, ButtonLink, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 12 | + | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 13 | + | import { Checkbox, CheckboxOption } from "./ui/checkbox"; | |
| 14 | + | import { Hint } from "./ui/hint"; | |
| 15 | + | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 16 | + | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 17 | + | import { | |
| 18 | + | MIRRORING_DOCS, | |
| 19 | + | TAKE_OVER_MINUTES, | |
| 20 | + | decisionChoices, | |
| 21 | + | decisionField, | |
| 22 | + | handBackReady, | |
| 23 | + | refActionWords, | |
| 24 | + | remoteStateWords, | |
| 25 | + | shortSha, | |
| 26 | + | } from "../lib/mirror"; | |
| 27 | + | ||
| 28 | + | /** What a form on the mirroring page came back with. */ | |
| 29 | + | export type MirrorOutcome = { | |
| 30 | + | intent: string; | |
| 31 | + | ok: boolean; | |
| 32 | + | message: string | null; | |
| 33 | + | error: string | null; | |
| 34 | + | /** What it did that people should know: pull requests it opened, and so on. */ | |
| 35 | + | notes?: string[]; | |
| 36 | + | }; | |
| 37 | + | ||
| 38 | + | const STATE_TONES: Record<string, BadgeTone> = { | |
| 39 | + | standby: "neutral", | |
| 40 | + | ci: "info", | |
| 41 | + | takeover: "warn", | |
| 42 | + | handing_back: "warn", | |
| 43 | + | following: "success", | |
| 44 | + | stuck: "warn", | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | const PROVIDERS: Record<Remote["provider"], string> = { | |
| 48 | + | github: "GitHub", | |
| 49 | + | g1t: "Another g1t", | |
| 50 | + | git: "A git host", | |
| 51 | + | }; | |
| 52 | + | ||
| 53 | + | /** Who put it in this state, in words. */ | |
| 54 | + | function byWhom(by: string | null): string { | |
| 55 | + | if (!by) return ""; | |
| 56 | + | return by === "g1t" ? ", on its own" : `, by ${by}`; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /** A green dot when the remote answers, amber when it does not. */ | |
| 60 | + | function ReachDot({ reachable }: { reachable: boolean }) { | |
| 61 | + | return ( | |
| 62 | + | <span | |
| 63 | + | aria-hidden="true" | |
| 64 | + | className={`inline-block size-2 shrink-0 rounded-full ${reachable ? "bg-success" : "bg-warn shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-warn)_20%,transparent)]"}`} | |
| 65 | + | /> | |
| 66 | + | ); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | function Fact({ label, children }: { label: string; children: ReactNode }) { | |
| 70 | + | return ( | |
| 71 | + | <div className="min-w-0"> | |
| 72 | + | <dt className="text-xs text-faint">{label}</dt> | |
| 73 | + | <dd className="mt-0.5 text-sm text-fg">{children}</dd> | |
| 74 | + | </div> | |
| 75 | + | ); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** A result line under the form that sent it. */ | |
| 79 | + | function Result({ result }: { result: MirrorOutcome | undefined }) { | |
| 80 | + | if (!result) return null; | |
| 81 | + | if (!result.ok) return <ErrorText>{result.error}</ErrorText>; | |
| 82 | + | return ( | |
| 83 | + | <> | |
| 84 | + | {result.message && ( | |
| 85 | + | <p className="text-sm text-success" role="status"> | |
| 86 | + | {result.message} | |
| 87 | + | </p> | |
| 88 | + | )} | |
| 89 | + | <MirrorNotes notes={result.notes} /> | |
| 90 | + | </> | |
| 91 | + | ); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | const DOCS_LINK = ( | |
| 95 | + | <a href={MIRRORING_DOCS} className="text-fg underline-offset-2 hover:underline"> | |
| 96 | + | How mirroring works | |
| 97 | + | </a> | |
| 98 | + | ); | |
| 99 | + | ||
| 100 | + | /** | |
| 101 | + | * The page under Settings → Mirroring: for a mirror, the remote that leads, | |
| 102 | + | * what can be done about it and how it behaves; for a repository that | |
| 103 | + | * leads, the remotes that follow it and adding one. | |
| 104 | + | */ | |
| 105 | + | export function MirroringSettings({ | |
| 106 | + | base, | |
| 107 | + | full, | |
| 108 | + | mirror, | |
| 109 | + | view, | |
| 110 | + | planRequested, | |
| 111 | + | planError, | |
| 112 | + | result, | |
| 113 | + | }: { | |
| 114 | + | base: string; | |
| 115 | + | full: string; | |
| 116 | + | mirror: RepoMirror | null; | |
| 117 | + | view: MirrorView; | |
| 118 | + | /** Whether the hand-back was asked to be reviewed (the plan is then filled, or `planError` says why not). */ | |
| 119 | + | planRequested: boolean; | |
| 120 | + | planError: string | null; | |
| 121 | + | result: MirrorOutcome | undefined; | |
| 122 | + | }) { | |
| 123 | + | const leader = view.remotes.find((remote) => remote.role === "leader") ?? null; | |
| 124 | + | const followers = view.remotes.filter((remote) => remote.role === "follower"); | |
| 125 | + | const resultFor = (...intents: string[]) => (result && intents.includes(result.intent) ? result : undefined); | |
| 126 | + | if (leader && mirror) { | |
| 127 | + | return ( | |
| 128 | + | <div className="max-w-4xl space-y-10"> | |
| 129 | + | <Section | |
| 130 | + | title="Where work happens" | |
| 131 | + | about={ | |
| 132 | + | <> | |
| 133 | + | {mirror.state === "takeover" | |
| 134 | + | ? `g1t has taken over from ${leader.name}, which leads otherwise. Work here as usual, then hand it back.` | |
| 135 | + | : `${full} follows ${leader.name}, which leads. While it stands by it is an exact, read-only copy that runs nothing. Take over whenever you need to work here.`}{" "} | |
| 136 | + | {DOCS_LINK}. | |
| 137 | + | </> | |
| 138 | + | } | |
| 139 | + | > | |
| 140 | + | <LeaderCard base={base} full={full} remote={leader} mirror={mirror} canManage={view.canManage} result={resultFor("take-over", "ci-on", "ci-off", "sync")} /> | |
| 141 | + | </Section> | |
| 142 | + | ||
| 143 | + | {(mirror.state === "takeover" || mirror.state === "handing_back") && ( | |
| 144 | + | <Section | |
| 145 | + | id="hand-back" | |
| 146 | + | title="Hand back" | |
| 147 | + | about={`When you're done, everything g1t did goes back to ${leader.name}, branch by branch, and ${full} follows it again.`} | |
| 148 | + | > | |
| 149 | + | {mirror.state === "handing_back" ? ( | |
| 150 | + | <p className="rounded-xl border border-line bg-surface p-4 text-sm text-muted"> | |
| 151 | + | Handing back to {leader.name} now. {full} is read-only until every branch is across; this page shows it | |
| 152 | + | standing by again once it is. | |
| 153 | + | </p> | |
| 154 | + | ) : view.plan ? ( | |
| 155 | + | <HandBackForm full={full} remote={leader.name} plan={view.plan} canManage={view.canManage} result={resultFor("hand-back")} /> | |
| 156 | + | ) : ( | |
| 157 | + | <div className="space-y-3 rounded-xl border border-line bg-surface p-4"> | |
| 158 | + | <p className="text-sm text-muted"> | |
| 159 | + | g1t compares each branch with {leader.name} and shows what will happen to it before anything goes back. | |
| 160 | + | </p> | |
| 161 | + | {planRequested && planError && <ErrorText>{planError}</ErrorText>} | |
| 162 | + | <ButtonLink to="?plan=1#hand-back" preventScrollReset> | |
| 163 | + | Review hand-back | |
| 164 | + | </ButtonLink> | |
| 165 | + | </div> | |
| 166 | + | )} | |
| 167 | + | </Section> | |
| 168 | + | )} | |
| 169 | + | ||
| 170 | + | <LeaderSettings remote={leader} full={full} canManage={view.canManage} result={resultFor("settings")} /> | |
| 171 | + | ||
| 172 | + | {mirror.state !== "handing_back" && view.canManage && ( | |
| 173 | + | <div className="border-t border-line pt-8"> | |
| 174 | + | <DangerZone> | |
| 175 | + | <MoveToG1t full={full} remote={leader.name} error={resultFor("move-in")?.error ?? null} /> | |
| 176 | + | </DangerZone> | |
| 177 | + | </div> | |
| 178 | + | )} | |
| 179 | + | </div> | |
| 180 | + | ); | |
| 181 | + | } | |
| 182 | + | return ( | |
| 183 | + | <div className="max-w-4xl space-y-10"> | |
| 184 | + | <Section | |
| 185 | + | title="Mirrored to" | |
| 186 | + | about={ | |
| 187 | + | <> | |
| 188 | + | Remotes that follow {full}. g1t leads, and every push here goes to each of them. {DOCS_LINK}. | |
| 189 | + | </> | |
| 190 | + | } | |
| 191 | + | > | |
| 192 | + | <Result result={resultFor("sync", "remove", "settings")} /> | |
| 193 | + | {followers.length === 0 ? ( | |
| 194 | + | <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> | |
| 195 | + | Not mirrored anywhere yet. | |
| 196 | + | </p> | |
| 197 | + | ) : ( | |
| 198 | + | <> | |
| 199 | + | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 200 | + | {followers.map((remote) => ( | |
| 201 | + | <FollowerRow key={remote.id} remote={remote} canManage={view.canManage} /> | |
| 202 | + | ))} | |
| 203 | + | </ul> | |
| 204 | + | {view.canManage && ( | |
| 205 | + | <Form method="post" className="flex flex-wrap items-center gap-3"> | |
| 206 | + | <SubmitButton variant="quiet" name="intent" value="sync" pending="Syncing…"> | |
| 207 | + | <RefreshCw size={14} /> | |
| 208 | + | Sync now | |
| 209 | + | </SubmitButton> | |
| 210 | + | <span className="text-xs text-muted">Pushes g1t's branches to every remote here now, over any that moved.</span> | |
| 211 | + | </Form> | |
| 212 | + | )} | |
| 213 | + | </> | |
| 214 | + | )} | |
| 215 | + | </Section> | |
| 216 | + | ||
| 217 | + | {view.canManage && ( | |
| 218 | + | <Section | |
| 219 | + | title="Add a remote" | |
| 220 | + | about={ | |
| 221 | + | <> | |
| 222 | + | Another g1t, or any host that speaks git over HTTPS. For GitHub, use{" "} | |
| 223 | + | <Link to="/new/github" className="text-fg underline-offset-2 hover:underline"> | |
| 224 | + | New → Import from GitHub | |
| 225 | + | </Link> | |
| 226 | + | . | |
| 227 | + | </> | |
| 228 | + | } | |
| 229 | + | > | |
| 230 | + | <AddRemote result={resultFor("add")} /> | |
| 231 | + | </Section> | |
| 232 | + | )} | |
| 233 | + | </div> | |
| 234 | + | ); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /** The remote a mirror follows: how it stands, and what can be done now. */ | |
| 238 | + | function LeaderCard({ | |
| 239 | + | base, | |
| 240 | + | full, | |
| 241 | + | remote, | |
| 242 | + | mirror, | |
| 243 | + | canManage, | |
| 244 | + | result, | |
| 245 | + | }: { | |
| 246 | + | base: string; | |
| 247 | + | full: string; | |
| 248 | + | remote: Remote; | |
| 249 | + | mirror: RepoMirror; | |
| 250 | + | canManage: boolean; | |
| 251 | + | result: MirrorOutcome | undefined; | |
| 252 | + | }) { | |
| 253 | + | const state = mirror.state; | |
| 254 | + | const silent = !remote.reachable; | |
| 255 | + | return ( | |
| 256 | + | <div className="space-y-3"> | |
| 257 | + | <div className="rounded-xl border border-line bg-surface"> | |
| 258 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-line px-4 py-3"> | |
| 259 | + | <ReachDot reachable={remote.reachable} /> | |
| 260 | + | <a | |
| 261 | + | href={remote.url} | |
| 262 | + | target="_blank" | |
| 263 | + | rel="noreferrer" | |
| 264 | + | className="inline-flex min-w-0 items-center gap-1.5 font-mono text-sm font-medium break-all hover:text-accent" | |
| 265 | + | > | |
| 266 | + | {remote.name} | |
| 267 | + | <ExternalLink size={12} className="shrink-0 text-faint" /> | |
| 268 | + | </a> | |
| 269 | + | <Badge tone={STATE_TONES[state]}>{remoteStateWords(state)}</Badge> | |
| 270 | + | <span className="ml-auto text-xs text-faint">{PROVIDERS[remote.provider]}</span> | |
| 271 | + | </div> | |
| 272 | + | <dl className="grid gap-x-6 gap-y-3 px-4 py-3 sm:grid-cols-3"> | |
| 273 | + | <Fact label="State"> | |
| 274 | + | {remoteStateWords(state)}{" "} | |
| 275 | + | <span className="text-muted"> | |
| 276 | + | <TimeAgo at={mirror.since} /> | |
| 277 | + | {byWhom(remote.stateBy)} | |
| 278 | + | </span> | |
| 279 | + | </Fact> | |
| 280 | + | <Fact label="Remote"> | |
| 281 | + | {silent ? ( | |
| 282 | + | <span className="text-warn"> | |
| 283 | + | Not answering | |
| 284 | + | {remote.unreachableSince && ( | |
| 285 | + | <span className="text-muted"> | |
| 286 | + | {" "} | |
| 287 | + | for <Since at={remote.unreachableSince} /> | |
| 288 | + | </span> | |
| 289 | + | )} | |
| 290 | + | </span> | |
| 291 | + | ) : ( | |
| 292 | + | "Answering" | |
| 293 | + | )} | |
| 294 | + | </Fact> | |
| 295 | + | <Fact label="Last synced">{remote.syncedAt ? <TimeAgo at={remote.syncedAt} /> : <span className="text-muted">Not yet</span>}</Fact> | |
| 296 | + | </dl> | |
| 297 | + | {remote.lastError && ( | |
| 298 | + | <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"> | |
| 299 | + | <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" /> | |
| 300 | + | <span className="min-w-0 break-words">{remote.lastError}</span> | |
| 301 | + | </p> | |
| 302 | + | )} | |
| 303 | + | {canManage && state !== "handing_back" && ( | |
| 304 | + | <div className="flex flex-wrap items-center gap-2 border-t border-line px-4 py-3"> | |
| 305 | + | {(state === "standby" || state === "ci") && ( | |
| 306 | + | <TakeOverDialog | |
| 307 | + | base={base} | |
| 308 | + | full={full} | |
| 309 | + | mirror={mirror} | |
| 310 | + | error={result?.intent === "take-over" ? result.error : null} | |
| 311 | + | trigger={(open) => ( | |
| 312 | + | <Button type="button" variant={silent ? "primary" : "quiet"} onClick={open}> | |
| 313 | + | Take over | |
| 314 | + | </Button> | |
| 315 | + | )} | |
| 316 | + | /> | |
| 317 | + | )} | |
| 318 | + | {(state === "standby" || state === "ci") && ( | |
| 319 | + | <Form method="post" className="contents"> | |
| 320 | + | {state === "standby" ? ( | |
| 321 | + | <SubmitButton variant="quiet" name="intent" value="ci-on" pending="Starting…"> | |
| 322 | + | Start CI failover | |
| 323 | + | </SubmitButton> | |
| 324 | + | ) : ( | |
| 325 | + | <SubmitButton variant="quiet" name="intent" value="ci-off" pending="Ending…"> | |
| 326 | + | End CI failover | |
| 327 | + | </SubmitButton> | |
| 328 | + | )} | |
| 329 | + | {state === "standby" && ( | |
| 330 | + | <SubmitButton variant="quiet" name="intent" value="sync" pending="Syncing…"> | |
| 331 | + | <RefreshCw size={14} /> | |
| 332 | + | Sync now | |
| 333 | + | </SubmitButton> | |
| 334 | + | )} | |
| 335 | + | </Form> | |
| 336 | + | )} | |
| 337 | + | {state === "takeover" && ( | |
| 338 | + | <ButtonLink to="?plan=1#hand-back" preventScrollReset> | |
| 339 | + | Review hand-back | |
| 340 | + | </ButtonLink> | |
| 341 | + | )} | |
| 342 | + | </div> | |
| 343 | + | )} | |
| 344 | + | </div> | |
| 345 | + | <p className="text-xs text-muted"> | |
| 346 | + | {state === "standby" | |
| 347 | + | ? `CI failover keeps the code on ${remote.name} and runs its workflows here, results going back. Taking over makes g1t lead until you hand it back.` | |
| 348 | + | : state === "ci" | |
| 349 | + | ? `${remote.name} keeps the code; g1t runs its workflows. ${full} stays read-only for code, issues and pull requests.` | |
| 350 | + | : state === "takeover" | |
| 351 | + | ? `Everything works here. Nothing goes back to ${remote.name} until you hand it back.` | |
| 352 | + | : `Read-only until every branch is back on ${remote.name}.`} | |
| 353 | + | </p> | |
| 354 | + | {/* A refused takeover says why in its dialog, which opens again. */} | |
| 355 | + | {(result?.ok || result?.intent !== "take-over") && <Result result={result} />} | |
| 356 | + | </div> | |
| 357 | + | ); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | /** How long since: "5 minutes", "3 hours". */ | |
| 361 | + | function Since({ at }: { at: string }) { | |
| 362 | + | const minutes = Math.max(1, Math.round((Date.now() - new Date(at).getTime()) / 60_000)); | |
| 363 | + | const words = | |
| 364 | + | minutes < 60 | |
| 365 | + | ? `${minutes} minute${minutes === 1 ? "" : "s"}` | |
| 366 | + | : minutes < 48 * 60 | |
| 367 | + | ? `${Math.round(minutes / 60)} hour${Math.round(minutes / 60) === 1 ? "" : "s"}` | |
| 368 | + | : `${Math.round(minutes / 1440)} days`; | |
| 369 | + | return ( | |
| 370 | + | <time dateTime={new Date(at).toISOString()} suppressHydrationWarning> | |
| 371 | + | {words} | |
| 372 | + | </time> | |
| 373 | + | ); | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | /** Each branch, what happens to it, and a choice for those both sides moved. */ | |
| 377 | + | function HandBackForm({ | |
| 378 | + | full, | |
| 379 | + | remote, | |
| 380 | + | plan, | |
| 381 | + | canManage, | |
| 382 | + | result, | |
| 383 | + | }: { | |
| 384 | + | full: string; | |
| 385 | + | remote: string; | |
| 386 | + | plan: HandbackPlan; | |
| 387 | + | canManage: boolean; | |
| 388 | + | result: MirrorOutcome | undefined; | |
| 389 | + | }) { | |
| 390 | + | const [chosen, setChosen] = useState<Record<string, RefDecision | undefined>>({}); | |
| 391 | + | const moving = plan.refs.filter((ref) => ref.action !== "same"); | |
| 392 | + | const same = plan.refs.length - moving.length; | |
| 393 | + | const ready = handBackReady(plan, chosen); | |
| 394 | + | const choices = decisionChoices(remote); | |
| 395 | + | return ( | |
| 396 | + | <Form method="post" preventScrollReset className="space-y-4"> | |
| 397 | + | <input type="hidden" name="intent" value="hand-back" /> | |
| 398 | + | {!plan.reachable && ( | |
| 399 | + | <p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3.5 py-2.5 text-sm text-fg-soft"> | |
| 400 | + | <TriangleAlert size={15} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" /> | |
| 401 | + | {remote} isn't answering yet; hand back once it is. | |
| 402 | + | </p> | |
| 403 | + | )} | |
| 404 | + | {moving.length === 0 ? ( | |
| 405 | + | <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> | |
| 406 | + | Every branch is already the same on both sides. Handing back only makes {remote} lead again. | |
| 407 | + | </p> | |
| 408 | + | ) : ( | |
| 409 | + | <div className="overflow-hidden rounded-xl border border-line"> | |
| 410 | + | <div className="hidden grid-cols-[minmax(0,1fr)_5.5rem_5.5rem_minmax(0,1.5fr)] gap-4 border-b border-line bg-surface px-4 py-2 text-xs font-medium text-muted sm:grid"> | |
| 411 | + | <span>Branch</span> | |
| 412 | + | <span>g1t</span> | |
| 413 | + | <span className="truncate">{remote.split("/")[0]}</span> | |
| 414 | + | <span>What happens</span> | |
| 415 | + | </div> | |
| 416 | + | <ul className="divide-y divide-line"> | |
| 417 | + | {moving.map((ref) => ( | |
| 418 | + | <li | |
| 419 | + | key={ref.ref} | |
| 420 | + | className="grid gap-x-4 gap-y-1.5 px-4 py-3 text-sm sm:grid-cols-[minmax(0,1fr)_5.5rem_5.5rem_minmax(0,1.5fr)] sm:items-start" | |
| 421 | + | > | |
| 422 | + | <span className="min-w-0 truncate font-mono font-medium">{ref.ref}</span> | |
| 423 | + | <div className="flex gap-4 sm:contents"> | |
| 424 | + | <span className="font-mono text-xs text-muted sm:pt-0.5"> | |
| 425 | + | <span className="text-faint sm:hidden">g1t </span> | |
| 426 | + | {shortSha(ref.ours)} | |
| 427 | + | </span> | |
| 428 | + | <span className="font-mono text-xs text-muted sm:pt-0.5"> | |
| 429 | + | <span className="text-faint sm:hidden">{remote.split("/")[0]} </span> | |
| 430 | + | {shortSha(ref.theirs)} | |
| 431 | + | </span> | |
| 432 | + | </div> | |
| 433 | + | {ref.action === "diverged" ? ( | |
| 434 | + | <div className="space-y-1.5"> | |
| 435 | + | <p className="flex items-center gap-1.5 text-warn"> | |
| 436 | + | <TriangleAlert size={13} aria-hidden="true" /> | |
| 437 | + | Both moved: choose | |
| 438 | + | </p> | |
| 439 | + | <RadioGroup | |
| 440 | + | name={decisionField(ref.ref)} | |
| 441 | + | value={chosen[ref.ref] ?? ref.decision ?? undefined} | |
| 442 | + | onValueChange={(value) => setChosen((now) => ({ ...now, [ref.ref]: value as RefDecision }))} | |
| 443 | + | disabled={!canManage} | |
| 444 | + | aria-label={`What happens to ${ref.ref}`} | |
| 445 | + | className="gap-1.5" | |
| 446 | + | > | |
| 447 | + | {choices.map((choice) => ( | |
| 448 | + | <RadioOption key={choice.value} value={choice.value} label={choice.label} /> | |
| 449 | + | ))} | |
| 450 | + | </RadioGroup> | |
| 451 | + | </div> | |
| 452 | + | ) : ( | |
| 453 | + | <span className={ref.action === "pull_request" ? "text-info" : "text-fg-soft"}> | |
| 454 | + | {refActionWords(ref.action, remote)} | |
| 455 | + | {ref.action === "pull_request" && ( | |
| 456 | + | <span className="mt-0.5 block font-mono text-xs text-muted">g1t/handback/{ref.ref}</span> | |
| 457 | + | )} | |
| 458 | + | </span> | |
| 459 | + | )} | |
| 460 | + | </li> | |
| 461 | + | ))} | |
| 462 | + | </ul> | |
| 463 | + | </div> | |
| 464 | + | )} | |
| 465 | + | {same > 0 && moving.length > 0 && ( | |
| 466 | + | <p className="text-xs text-muted"> | |
| 467 | + | {same} other branch{same === 1 ? " is" : "es are"} already the same on both sides. | |
| 468 | + | </p> | |
| 469 | + | )} | |
| 470 | + | <Result result={result} /> | |
| 471 | + | {canManage && ( | |
| 472 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 473 | + | <SubmitButton variant="accent" disabled={!ready} pending="Handing back…"> | |
| 474 | + | Hand back | |
| 475 | + | </SubmitButton> | |
| 476 | + | <span className="text-xs text-muted"> | |
| 477 | + | {ready | |
| 478 | + | ? `${remote} leads again once every branch is across. Until then ${full} is read-only.` | |
| 479 | + | : plan.reachable | |
| 480 | + | ? "Choose what happens to each branch both sides moved." | |
| 481 | + | : `Waiting for ${remote} to answer.`} | |
| 482 | + | </span> | |
| 483 | + | </div> | |
| 484 | + | )} | |
| 485 | + | </Form> | |
| 486 | + | ); | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | /** How a mirror behaves when its remote stops answering, and what runs in CI failover and takeovers. */ | |
| 490 | + | function LeaderSettings({ | |
| 491 | + | remote, | |
| 492 | + | full, | |
| 493 | + | canManage, | |
| 494 | + | result, | |
| 495 | + | }: { | |
| 496 | + | remote: Remote; | |
| 497 | + | full: string; | |
| 498 | + | canManage: boolean; | |
| 499 | + | result: MirrorOutcome | undefined; | |
| 500 | + | }) { | |
| 501 | + | const settings = remote.settings; | |
| 502 | + | const [auto, setAuto] = useState(settings.takeOverAfter != null); | |
| 503 | + | const id = useId(); | |
| 504 | + | const [least, most] = TAKE_OVER_MINUTES; | |
| 505 | + | return ( | |
| 506 | + | <Form method="post" className="space-y-10"> | |
| 507 | + | <input type="hidden" name="intent" value="settings" /> | |
| 508 | + | <input type="hidden" name="kind" value="leader" /> | |
| 509 | + | <input type="hidden" name="remoteId" value={remote.id} /> | |
| 510 | + | <fieldset disabled={!canManage} className="space-y-10"> | |
| 511 | + | <Section | |
| 512 | + | title={`When ${remote.name} stops answering`} | |
| 513 | + | about="g1t checks every minute. Unless you choose otherwise, it only says so here: nobody is paged, and nothing happens on its own." | |
| 514 | + | > | |
| 515 | + | <RadioGroup name="notify" defaultValue={settings.notify} className="gap-3"> | |
| 516 | + | <RadioOption value="banner" label="Show it on the repository" description={`A line across ${full}'s pages, and an amber dot beside its name.`} /> | |
| 517 | + | <RadioOption value="inbox" label="Also tell workspace owners in their inbox" description="Once when it stops answering, and once when it answers again." /> | |
| 518 | + | </RadioGroup> | |
| 519 | + | <div className="rounded-xl border border-line bg-surface p-4"> | |
| 520 | + | <div className="flex flex-wrap items-center gap-x-2.5 gap-y-2"> | |
| 521 | + | <Checkbox id={`${id}-auto`} name="autoTakeOver" checked={auto} onCheckedChange={(checked) => setAuto(checked === true)} /> | |
| 522 | + | <label htmlFor={`${id}-auto`} className="cursor-pointer text-sm font-medium"> | |
| 523 | + | Take over automatically after | |
| 524 | + | </label> | |
| 525 | + | <span className="flex items-center gap-2"> | |
| 526 | + | <span className="w-20"> | |
| 527 | + | <Input | |
| 528 | + | type="number" | |
| 529 | + | name="takeOverAfter" | |
| 530 | + | aria-label="Minutes" | |
| 531 | + | min={least} | |
| 532 | + | max={most} | |
| 533 | + | step={1} | |
| 534 | + | defaultValue={settings.takeOverAfter ?? 30} | |
| 535 | + | disabled={!auto} | |
| 536 | + | /> | |
| 537 | + | </span> | |
| 538 | + | <span className="text-sm text-muted">minutes</span> | |
| 539 | + | </span> | |
| 540 | + | </div> | |
| 541 | + | <p className="mt-1.5 pl-6.5 text-xs text-faint"> | |
| 542 | + | Off unless you turn it on: people take over when they want. From {least} minutes to a day. | |
| 543 | + | </p> | |
| 544 | + | </div> | |
| 545 | + | <SettingToggle name="handBackWhenClean" on={settings.handBack === "when_clean"} title="Hand back on its own when every branch goes back cleanly"> | |
| 546 | + | Only after an automatic takeover. A branch that moved on both sides always waits for someone to choose. | |
| 547 | + | </SettingToggle> | |
| 548 | + | </Section> | |
| 549 | + | ||
| 550 | + | <Section title="Workflows" about="What runs on g1t during CI failover and takeovers. A mirror standing by runs nothing unless CI is kept warm."> | |
| 551 | + | <SettingToggle name="keepCiWarm" on={settings.keepCiWarm} title="Keep CI warm"> | |
| 552 | + | Run .g1t/workflows on every push copied in from {remote.name}, so CI is ready the moment you need it. | |
| 553 | + | </SettingToggle> | |
| 554 | + | <SettingToggle name="githubWorkflows" on={settings.githubWorkflows} title={`Run ${remote.name}'s .github workflows`}> | |
| 555 | + | In CI failover and takeovers, workflows in .github/workflows run on g1t too, beside .g1t/workflows. | |
| 556 | + | </SettingToggle> | |
| 557 | + | <SettingToggle name="holdDeploys" on={settings.holdDeploys} title="Hold workflows that deploy for approval"> | |
| 558 | + | A job that deploys waits for someone to approve it, so taking over never ships anything by surprise. | |
| 559 | + | </SettingToggle> | |
| 560 | + | </Section> | |
| 561 | + | </fieldset> | |
| 562 | + | {canManage && ( | |
| 563 | + | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> | |
| 564 | + | <SubmitButton pending="Saving…">Save settings</SubmitButton> | |
| 565 | + | {result?.ok && <span className="text-sm text-muted">Saved.</span>} | |
| 566 | + | <ErrorText>{result && !result.ok ? result.error : null}</ErrorText> | |
| 567 | + | </div> | |
| 568 | + | )} | |
| 569 | + | </Form> | |
| 570 | + | ); | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | /** Making g1t the leader for good, said plainly, with the remote kept as a follower if they like. */ | |
| 574 | + | function MoveToG1t({ full, remote, error }: { full: string; remote: string; error: string | null }) { | |
| 575 | + | return ( | |
| 576 | + | <ConfirmDialog | |
| 577 | + | intent="move-in" | |
| 578 | + | title={`Move ${full} to g1t?`} | |
| 579 | + | description={`g1t will no longer track ${remote}. Pushes made there won't come here.`} | |
| 580 | + | confirm={full} | |
| 581 | + | submit="Move to g1t" | |
| 582 | + | busy="Moving…" | |
| 583 | + | error={error} | |
| 584 | + | trigger={(open) => ( | |
| 585 | + | <DangerAction | |
| 586 | + | title="Move to g1t" | |
| 587 | + | action={ | |
| 588 | + | <Button type="button" variant="danger" onClick={open}> | |
| 589 | + | Move to g1t | |
| 590 | + | </Button> | |
| 591 | + | } | |
| 592 | + | > | |
| 593 | + | Make g1t lead {full} for good. It stops being a mirror, and g1t stops tracking {remote}. | |
| 594 | + | </DangerAction> | |
| 595 | + | )} | |
| 596 | + | extra={ | |
| 597 | + | <CheckboxOption | |
| 598 | + | name="keepRemoteUpdated" | |
| 599 | + | label={`Keep ${remote} updated from g1t`} | |
| 600 | + | description={`${remote} follows instead: every push here is pushed there. Off, g1t leaves it as it is.`} | |
| 601 | + | /> | |
| 602 | + | } | |
| 603 | + | > | |
| 604 | + | <li>{full} stops being a mirror: pushes, pull requests, issues, agents and workflows work here from now on.</li> | |
| 605 | + | <li>This is for good. There is nothing to hand back afterwards.</li> | |
| 606 | + | </ConfirmDialog> | |
| 607 | + | ); | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | /** One remote that follows: how it stands, what happens to pushes made on it, and removing it. */ | |
| 611 | + | function FollowerRow({ remote, canManage }: { remote: Remote; canManage: boolean }) { | |
| 612 | + | const stuck = remote.state === "stuck"; | |
| 613 | + | return ( | |
| 614 | + | <li className="space-y-2 px-4 py-3"> | |
| 615 | + | <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1"> | |
| 616 | + | <ReachDot reachable={remote.reachable} /> | |
| 617 | + | <a href={remote.url} target="_blank" rel="noreferrer" className="min-w-0 font-mono text-sm font-medium break-all hover:text-accent"> | |
| 618 | + | {remote.name} | |
| 619 | + | </a> | |
| 620 | + | <Hint label={stuck ? "It refused a push or moved on its own. Sync now pushes g1t's branches over it." : null}> | |
| 621 | + | <Badge tone={STATE_TONES[remote.state] ?? "neutral"} tabIndex={stuck ? 0 : undefined}> | |
| 622 | + | {remoteStateWords(remote.state)} | |
| 623 | + | </Badge> | |
| 624 | + | </Hint> | |
| 625 | + | {canManage && ( | |
| 626 | + | <span className="ml-auto"> | |
| 627 | + | <ConfirmDialog | |
| 628 | + | intent="remove" | |
| 629 | + | fields={{ remoteId: remote.id }} | |
| 630 | + | title={`Stop mirroring to ${remote.name}?`} | |
| 631 | + | description={`${remote.name} keeps what it has; g1t stops pushing to it. Its token is deleted.`} | |
| 632 | + | submit="Remove" | |
| 633 | + | busy="Removing…" | |
| 634 | + | trigger={(open) => ( | |
| 635 | + | <Button type="button" variant="quiet" onClick={open} aria-label={`Remove ${remote.name}`}> | |
| 636 | + | <Trash2 size={14} /> | |
| 637 | + | <span className="hidden sm:inline">Remove</span> | |
| 638 | + | </Button> | |
| 639 | + | )} | |
| 640 | + | /> | |
| 641 | + | </span> | |
| 642 | + | )} | |
| 643 | + | </div> | |
| 644 | + | <p className="text-xs text-faint"> | |
| 645 | + | {PROVIDERS[remote.provider]} · {remote.syncedAt ? <>Synced <TimeAgo at={remote.syncedAt} /></> : "Not synced yet"} | |
| 646 | + | {!remote.reachable && <span className="text-warn"> · Not answering</span>} | |
| 647 | + | </p> | |
| 648 | + | {remote.lastError && ( | |
| 649 | + | <p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-xs text-fg-soft"> | |
| 650 | + | <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" /> | |
| 651 | + | <span className="min-w-0 break-words">{remote.lastError}</span> | |
| 652 | + | </p> | |
| 653 | + | )} | |
| 654 | + | {canManage && ( | |
| 655 | + | <Form method="post" className="flex flex-wrap items-center gap-2 text-sm"> | |
| 656 | + | <input type="hidden" name="intent" value="settings" /> | |
| 657 | + | <input type="hidden" name="kind" value="follower" /> | |
| 658 | + | <input type="hidden" name="remoteId" value={remote.id} /> | |
| 659 | + | <span className="text-muted">Pushes made on {remote.name}:</span> | |
| 660 | + | <Select name="remotePushes" defaultValue={remote.settings.remotePushes}> | |
| 661 | + | <SelectTrigger size="sm" aria-label={`Pushes made on ${remote.name}`} className="w-auto min-w-44"> | |
| 662 | + | <SelectValue /> | |
| 663 | + | </SelectTrigger> | |
| 664 | + | <SelectContent> | |
| 665 | + | <SelectItem value="adopt">Take in fast-forwards</SelectItem> | |
| 666 | + | <SelectItem value="overwrite">Overwrite with g1t's</SelectItem> | |
| 667 | + | </SelectContent> | |
| 668 | + | </Select> | |
| 669 | + | <SubmitButton | |
| 670 | + | variant="quiet" | |
| 671 | + | match={{ intent: "settings", remoteId: remote.id }} | |
| 672 | + | pending="Saving…" | |
| 673 | + | className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50" | |
| 674 | + | > | |
| 675 | + | Save | |
| 676 | + | </SubmitButton> | |
| 677 | + | </Form> | |
| 678 | + | )} | |
| 679 | + | </li> | |
| 680 | + | ); | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | /** A remote to add: where, which one leads, and how to sign in to it. */ | |
| 684 | + | function AddRemote({ result }: { result: MirrorOutcome | undefined }) { | |
| 685 | + | const [round, setRound] = useState(0); | |
| 686 | + | // Cleared once added, ready for the next. | |
| 687 | + | useEffect(() => { | |
| 688 | + | if (result?.ok) setRound((n) => n + 1); | |
| 689 | + | }, [result]); | |
| 690 | + | return ( | |
| 691 | + | <Form key={round} method="post" className="space-y-5 rounded-xl border border-line bg-surface p-4"> | |
| 692 | + | <input type="hidden" name="intent" value="add" /> | |
| 693 | + | <fieldset className="space-y-2"> | |
| 694 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Provider</legend> | |
| 695 | + | <RadioGroup name="provider" defaultValue="git" className="gap-2.5 sm:grid-cols-2"> | |
| 696 | + | <RadioOption value="g1t" label="Another g1t" description="A repository on another g1t, such as one you host yourself." /> | |
| 697 | + | <RadioOption value="git" label="Any git host" description="Anywhere that takes git over HTTPS." /> | |
| 698 | + | </RadioGroup> | |
| 699 | + | </fieldset> | |
| 700 | + | <fieldset className="space-y-2"> | |
| 701 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Which one leads</legend> | |
| 702 | + | <RadioGroup name="role" defaultValue="follower" className="gap-2.5"> | |
| 703 | + | <RadioOption value="follower" label="g1t leads; the remote follows" description="Every push here is pushed there." /> | |
| 704 | + | <RadioOption | |
| 705 | + | value="leader" | |
| 706 | + | label="The remote leads; this repository mirrors it" | |
| 707 | + | description="Only for an empty repository: g1t copies the remote, and this repository becomes a read-only mirror of it." | |
| 708 | + | /> | |
| 709 | + | </RadioGroup> | |
| 710 | + | </fieldset> | |
| 711 | + | <Field label="HTTPS URL"> | |
| 712 | + | <Input name="url" type="url" required inputMode="url" spellCheck={false} placeholder="https://git.example.com/acme/web.git" /> | |
| 713 | + | </Field> | |
| 714 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 715 | + | <Field label="Username (optional)"> | |
| 716 | + | <Input name="username" spellCheck={false} autoCapitalize="off" /> | |
| 717 | + | </Field> | |
| 718 | + | <Field label="Token" hint="Stored encrypted; never shown again."> | |
| 719 | + | <Input name="token" type="password" spellCheck={false} /> | |
| 720 | + | </Field> | |
| 721 | + | </div> | |
| 722 | + | <Result result={result} /> | |
| 723 | + | <SubmitButton pending="Adding…" match={{ intent: "add" }}> | |
| 724 | + | Add remote | |
| 725 | + | </SubmitButton> | |
| 726 | + | </Form> | |
| 727 | + | ); | |
| 728 | + | } |
| 1 | + | import { CloudOff, Flag, Undo2, Workflow } from "lucide-react"; | |
| 2 | + | import type { ReactNode } from "react"; | |
| 3 | + | import { Form, Link, useRouteLoaderData } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { RepoMirror } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { ConfirmDialog } from "./repo-lifecycle"; | |
| 8 | + | import { SubmitButton, TimeAgo } from "./ui"; | |
| 9 | + | import { Badge } from "./ui/badge"; | |
| 10 | + | import { Hint } from "./ui/hint"; | |
| 11 | + | import { | |
| 12 | + | type MirrorBrief, | |
| 13 | + | type MirroredRepo, | |
| 14 | + | followersOf, | |
| 15 | + | leaderOf, | |
| 16 | + | mirrorBadge, | |
| 17 | + | mirrorBanner, | |
| 18 | + | mirrorReason, | |
| 19 | + | mirroringSettings, | |
| 20 | + | workflowReason, | |
| 21 | + | } from "../lib/mirror"; | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | * The repository the page is in, its remotes in brief and whether the | |
| 25 | + | * viewer manages them, from the repository layout's loader. Empty outside | |
| 26 | + | * a repository. | |
| 27 | + | */ | |
| 28 | + | export function useRepoMirror(): { repo: MirroredRepo | null; briefs: MirrorBrief[]; admin: boolean } { | |
| 29 | + | const layout = useRouteLoaderData("routes/repo/layout") as | |
| 30 | + | | { repo?: MirroredRepo; mirrorBriefs?: MirrorBrief[]; access?: { can?: { manage_integrations?: boolean } } } | |
| 31 | + | | undefined; | |
| 32 | + | return { | |
| 33 | + | repo: layout?.repo ?? null, | |
| 34 | + | briefs: layout?.mirrorBriefs ?? [], | |
| 35 | + | admin: Boolean(layout?.access?.can?.manage_integrations), | |
| 36 | + | }; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /** | |
| 40 | + | * Why pushing, merging, opening issues and pull requests and assigning | |
| 41 | + | * agents are off in the repository the page is in; null when they are not | |
| 42 | + | * off because it is a mirror. | |
| 43 | + | */ | |
| 44 | + | export function useMirrorReason(): string | null { | |
| 45 | + | return mirrorReason(useRepoMirror().repo); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** Why running workflows is off: a mirror standing by or handing back runs nothing. */ | |
| 49 | + | export function useWorkflowReason(): string | null { | |
| 50 | + | return workflowReason(useRepoMirror().repo); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** What the badge means, said on hover. */ | |
| 54 | + | function badgeHint(mirror: RepoMirror | null | undefined, briefs: readonly MirrorBrief[]): string { | |
| 55 | + | if (mirror) { | |
| 56 | + | switch (mirror.state) { | |
| 57 | + | case "standby": | |
| 58 | + | return `A read-only copy that follows ${mirror.remote}. Work happens there; clone and fetch freely here.`; | |
| 59 | + | case "ci": | |
| 60 | + | return `${mirror.remote} keeps the code; g1t runs its workflows and sends the results back.`; | |
| 61 | + | case "takeover": | |
| 62 | + | return `g1t leads for now: everything works here until it is handed back to ${mirror.remote}.`; | |
| 63 | + | case "handing_back": | |
| 64 | + | return `Going back to ${mirror.remote} branch by branch. Read-only until it is done.`; | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | const names = followersOf(briefs).map((brief) => brief.name); | |
| 68 | + | return `g1t leads. Every push here goes to ${names.join(", ")}.`; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /** | |
| 72 | + | * Beside the repository's name: what it is to its remotes. An amber dot | |
| 73 | + | * when the remote a mirror follows is not answering. | |
| 74 | + | */ | |
| 75 | + | export function MirrorBadge({ mirror, briefs }: { mirror: RepoMirror | null | undefined; briefs: readonly MirrorBrief[] }) { | |
| 76 | + | const badge = mirrorBadge(mirror, briefs); | |
| 77 | + | if (!badge) return null; | |
| 78 | + | const silent = Boolean(mirror) && leaderOf(briefs)?.reachable === false; | |
| 79 | + | return ( | |
| 80 | + | <Hint label={badgeHint(mirror, briefs)}> | |
| 81 | + | <Badge tone={badge.tone} tabIndex={0} className="min-w-0 max-w-full px-2 py-0.5 text-xs font-normal"> | |
| 82 | + | {silent && <span aria-label="Not answering" className="size-1.5 shrink-0 rounded-full bg-warn" />} | |
| 83 | + | <span className="truncate">{badge.label}</span> | |
| 84 | + | {badge.more > 0 && <span className="shrink-0 opacity-70">+{badge.more} more</span>} | |
| 85 | + | </Badge> | |
| 86 | + | </Hint> | |
| 87 | + | ); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | const BANNER_TONES = { | |
| 91 | + | warn: { box: "border-warn/40 bg-warn/5", icon: "text-warn", action: "text-warn" }, | |
| 92 | + | info: { box: "border-info/40 bg-info/5", icon: "text-info", action: "text-info" }, | |
| 93 | + | } as const; | |
| 94 | + | ||
| 95 | + | /** A compact line across the top of the page, with what it is about and one action. */ | |
| 96 | + | function BannerBox({ tone, icon, title, children, action, className = "" }: { | |
| 97 | + | tone: keyof typeof BANNER_TONES; | |
| 98 | + | className?: string; | |
| 99 | + | icon: ReactNode; | |
| 100 | + | title: ReactNode; | |
| 101 | + | children?: ReactNode; | |
| 102 | + | action?: ReactNode; | |
| 103 | + | }) { | |
| 104 | + | const colours = BANNER_TONES[tone]; | |
| 105 | + | return ( | |
| 106 | + | <div | |
| 107 | + | role="status" | |
| 108 | + | className={`flex flex-col gap-2 rounded-lg border px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between ${colours.box} ${className}`} | |
| 109 | + | > | |
| 110 | + | <p className="flex min-w-0 gap-2.5"> | |
| 111 | + | <span className={`mt-0.5 shrink-0 ${colours.icon} [&_svg]:size-4`}>{icon}</span> | |
| 112 | + | <span className="min-w-0"> | |
| 113 | + | <span className="font-medium text-fg">{title}</span> | |
| 114 | + | {children && <> <span className="text-muted">{children}</span></>} | |
| 115 | + | </span> | |
| 116 | + | </p> | |
| 117 | + | {action && <div className={`shrink-0 pl-6.5 font-medium sm:pl-0 ${colours.action}`}>{action}</div>} | |
| 118 | + | </div> | |
| 119 | + | ); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | const BANNER_ACTION = "inline-flex items-center gap-1.5 font-medium hover:underline disabled:opacity-50"; | |
| 123 | + | ||
| 124 | + | /** | |
| 125 | + | * Taking over, from anywhere: says what it does and posts to the | |
| 126 | + | * mirroring settings, which then show it. | |
| 127 | + | */ | |
| 128 | + | export function TakeOverDialog({ | |
| 129 | + | base, | |
| 130 | + | full, | |
| 131 | + | mirror, | |
| 132 | + | error, | |
| 133 | + | trigger, | |
| 134 | + | }: { | |
| 135 | + | base: string; | |
| 136 | + | full: string; | |
| 137 | + | mirror: Pick<RepoMirror, "remote" | "holdDeploys">; | |
| 138 | + | error?: string | null; | |
| 139 | + | trigger: (open: () => void) => ReactNode; | |
| 140 | + | }) { | |
| 141 | + | return ( | |
| 142 | + | <ConfirmDialog | |
| 143 | + | intent="take-over" | |
| 144 | + | action={mirroringSettings(base)} | |
| 145 | + | title={`Take over ${full}?`} | |
| 146 | + | description={`g1t leads ${full} until you hand it back. Pushes, pull requests, agents and workflows work here meanwhile.`} | |
| 147 | + | submit="Take over" | |
| 148 | + | busy="Taking over…" | |
| 149 | + | danger={false} | |
| 150 | + | error={error} | |
| 151 | + | trigger={trigger} | |
| 152 | + | > | |
| 153 | + | <li>{mirror.remote} keeps what it has. Nothing goes back to it until you hand it back, branch by branch.</li> | |
| 154 | + | {mirror.holdDeploys !== false && <li>Workflows that deploy wait for someone to approve them.</li>} | |
| 155 | + | </ConfirmDialog> | |
| 156 | + | ); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /** | |
| 160 | + | * Across the top of a mirror's pages, when there is something to know: its | |
| 161 | + | * remote is not answering, g1t runs its CI, has taken over, or is handing | |
| 162 | + | * back. A mirror standing by whose remote answers gets none: the badge | |
| 163 | + | * beside its name says it. | |
| 164 | + | */ | |
| 165 | + | export function MirrorBanner({ | |
| 166 | + | base, | |
| 167 | + | full, | |
| 168 | + | mirror, | |
| 169 | + | briefs, | |
| 170 | + | admin, | |
| 171 | + | className, | |
| 172 | + | }: { | |
| 173 | + | base: string; | |
| 174 | + | full: string; | |
| 175 | + | mirror: RepoMirror | null | undefined; | |
| 176 | + | briefs: readonly MirrorBrief[]; | |
| 177 | + | /** Whether the viewer manages its mirroring: they get its one action. */ | |
| 178 | + | admin: boolean; | |
| 179 | + | className?: string; | |
| 180 | + | }) { | |
| 181 | + | const kind = mirrorBanner(mirror, briefs); | |
| 182 | + | if (!kind || !mirror) return null; | |
| 183 | + | const settings = mirroringSettings(base); | |
| 184 | + | switch (kind) { | |
| 185 | + | case "unreachable": { | |
| 186 | + | const synced = leaderOf(briefs)?.syncedAt; | |
| 187 | + | return ( | |
| 188 | + | <BannerBox | |
| 189 | + | className={className} | |
| 190 | + | tone="warn" | |
| 191 | + | icon={<CloudOff />} | |
| 192 | + | title={`${mirror.remote} isn't answering.`} | |
| 193 | + | action={ | |
| 194 | + | admin ? ( | |
| 195 | + | <TakeOverDialog | |
| 196 | + | base={base} | |
| 197 | + | full={full} | |
| 198 | + | mirror={mirror} | |
| 199 | + | trigger={(open) => ( | |
| 200 | + | <button type="button" onClick={open} className={BANNER_ACTION}> | |
| 201 | + | Take over | |
| 202 | + | </button> | |
| 203 | + | )} | |
| 204 | + | /> | |
| 205 | + | ) : undefined | |
| 206 | + | } | |
| 207 | + | > | |
| 208 | + | {synced ? ( | |
| 209 | + | <> | |
| 210 | + | g1t has its copy as of <TimeAgo at={synced} />. | |
| 211 | + | </> | |
| 212 | + | ) : ( | |
| 213 | + | "g1t has its copy as of the last sync." | |
| 214 | + | )} | |
| 215 | + | {mirror.state === "ci" && " Its workflows keep running here."} | |
| 216 | + | </BannerBox> | |
| 217 | + | ); | |
| 218 | + | } | |
| 219 | + | case "ci": | |
| 220 | + | return ( | |
| 221 | + | <BannerBox | |
| 222 | + | className={className} | |
| 223 | + | tone="info" | |
| 224 | + | icon={<Workflow />} | |
| 225 | + | title={`Running ${mirror.remote}'s workflows on g1t.`} | |
| 226 | + | action={ | |
| 227 | + | admin ? ( | |
| 228 | + | <Form method="post" action={settings}> | |
| 229 | + | <SubmitButton name="intent" value="ci-off" pending="Ending…" className={BANNER_ACTION}> | |
| 230 | + | End CI failover | |
| 231 | + | </SubmitButton> | |
| 232 | + | </Form> | |
| 233 | + | ) : undefined | |
| 234 | + | } | |
| 235 | + | > | |
| 236 | + | CI failover started <TimeAgo at={mirror.since} />. | |
| 237 | + | {mirror.holdDeploys !== false && " Deploying workflows wait for approval."} | |
| 238 | + | </BannerBox> | |
| 239 | + | ); | |
| 240 | + | case "takeover": | |
| 241 | + | return ( | |
| 242 | + | <BannerBox | |
| 243 | + | className={className} | |
| 244 | + | tone="warn" | |
| 245 | + | icon={<Flag />} | |
| 246 | + | title={`g1t is leading ${full} for now.`} | |
| 247 | + | action={ | |
| 248 | + | admin ? ( | |
| 249 | + | <Link to={`${settings}?plan=1#hand-back`} className={BANNER_ACTION}> | |
| 250 | + | Hand back… | |
| 251 | + | </Link> | |
| 252 | + | ) : undefined | |
| 253 | + | } | |
| 254 | + | > | |
| 255 | + | Everything works here; when you're done, hand it back to {mirror.remote}. | |
| 256 | + | </BannerBox> | |
| 257 | + | ); | |
| 258 | + | case "handing_back": | |
| 259 | + | return ( | |
| 260 | + | <BannerBox | |
| 261 | + | className={className} | |
| 262 | + | tone="warn" | |
| 263 | + | icon={<Undo2 />} | |
| 264 | + | title={`Handing back to ${mirror.remote}…`} | |
| 265 | + | action={ | |
| 266 | + | admin ? ( | |
| 267 | + | <Link to={settings} className={BANNER_ACTION}> | |
| 268 | + | Progress | |
| 269 | + | </Link> | |
| 270 | + | ) : undefined | |
| 271 | + | } | |
| 272 | + | > | |
| 273 | + | {full} is read-only until it's done. | |
| 274 | + | </BannerBox> | |
| 275 | + | ); | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /** | |
| 280 | + | * Under the clone address: what pushing here does when the repository | |
| 281 | + | * mirrors a remote. Null when it leads. | |
| 282 | + | */ | |
| 283 | + | export function MirrorCloneNote({ mirror, base, admin }: { mirror: RepoMirror | null | undefined; base: string; admin: boolean }) { | |
| 284 | + | if (!mirror) return null; | |
| 285 | + | const where = admin ? ( | |
| 286 | + | <Link to={mirroringSettings(base)} className="text-fg underline underline-offset-4"> | |
| 287 | + | Settings → Mirroring | |
| 288 | + | </Link> | |
| 289 | + | ) : ( | |
| 290 | + | "Settings → Mirroring" | |
| 291 | + | ); | |
| 292 | + | return ( | |
| 293 | + | <p className="mt-2 text-xs text-muted"> | |
| 294 | + | {mirror.state === "takeover" ? ( | |
| 295 | + | <>g1t leads for now: pushes here go back to {mirror.remote} when it is handed back.</> | |
| 296 | + | ) : mirror.state === "handing_back" ? ( | |
| 297 | + | <>Handing back to {mirror.remote}: clone and fetch freely; pushes wait until it is done.</> | |
| 298 | + | ) : ( | |
| 299 | + | <> | |
| 300 | + | A mirror of {mirror.remote}. Clone and fetch freely; to push, push there, or take over in {where}. | |
| 301 | + | </> | |
| 302 | + | )} | |
| 303 | + | </p> | |
| 304 | + | ); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | /** | |
| 308 | + | * On the repository's overview, one quiet line when no banner says it: a | |
| 309 | + | * mirror standing by, or the remotes that follow it, and when it last synced. | |
| 310 | + | */ | |
| 311 | + | export function MirrorOverviewNote({ | |
| 312 | + | base, | |
| 313 | + | mirror, | |
| 314 | + | briefs, | |
| 315 | + | admin, | |
| 316 | + | syncedAt, | |
| 317 | + | lastError, | |
| 318 | + | }: { | |
| 319 | + | base: string; | |
| 320 | + | mirror: RepoMirror | null | undefined; | |
| 321 | + | briefs: readonly MirrorBrief[]; | |
| 322 | + | admin: boolean; | |
| 323 | + | syncedAt?: string | null; | |
| 324 | + | lastError?: string | null; | |
| 325 | + | }) { | |
| 326 | + | const followers = followersOf(briefs); | |
| 327 | + | // A banner says it already. | |
| 328 | + | if (mirror && mirrorBanner(mirror, briefs)) return null; | |
| 329 | + | if (!mirror && followers.length === 0) return null; | |
| 330 | + | const stuck = followers.filter((brief) => brief.state === "stuck"); | |
| 331 | + | return ( | |
| 332 | + | <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted"> | |
| 333 | + | <p className="min-w-0"> | |
| 334 | + | {mirror ? ( | |
| 335 | + | <> | |
| 336 | + | A read-only mirror of{" "} | |
| 337 | + | <a href={mirror.url} target="_blank" rel="noreferrer" className="font-mono break-all text-fg hover:text-accent"> | |
| 338 | + | {mirror.remote} | |
| 339 | + | </a> | |
| 340 | + | . | |
| 341 | + | </> | |
| 342 | + | ) : ( | |
| 343 | + | <> | |
| 344 | + | Mirrored to <span className="font-mono text-fg">{followers[0]!.name}</span> | |
| 345 | + | {followers.length > 1 && ` and ${followers.length - 1} more`}: every push here goes there too. | |
| 346 | + | </> | |
| 347 | + | )} | |
| 348 | + | {syncedAt && ( | |
| 349 | + | <span className="text-faint"> | |
| 350 | + | {" "} | |
| 351 | + | Synced <TimeAgo at={syncedAt} />. | |
| 352 | + | </span> | |
| 353 | + | )} | |
| 354 | + | </p> | |
| 355 | + | {admin && ( | |
| 356 | + | <Link to={mirroringSettings(base)} className="text-xs text-muted hover:text-fg"> | |
| 357 | + | Mirroring settings | |
| 358 | + | </Link> | |
| 359 | + | )} | |
| 360 | + | {(lastError || stuck.length > 0) && ( | |
| 361 | + | <p className="w-full text-xs text-warn"> | |
| 362 | + | {lastError ?? `${stuck.map((brief) => brief.name).join(", ")} stopped taking pushes. See Settings → Mirroring.`} | |
| 363 | + | </p> | |
| 364 | + | )} | |
| 365 | + | </div> | |
| 366 | + | ); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | /** What an action did that people should know, such as the pull requests it opened, with addresses as links. */ | |
| 370 | + | export function MirrorNotes({ notes }: { notes: readonly string[] | null | undefined }) { | |
| 371 | + | if (!notes || notes.length === 0) return null; | |
| 372 | + | return ( | |
| 373 | + | <div role="status" className="rounded-lg border border-success/40 bg-success/5 px-4 py-3 text-sm"> | |
| 374 | + | <ul className="space-y-1"> | |
| 375 | + | {notes.map((note, at) => ( | |
| 376 | + | <li key={at} className="text-fg-soft"> | |
| 377 | + | {note.split(/(https?:\/\/\S+?)(?=[.,;)]*(?:\s|$))/).map((piece, index) => | |
| 378 | + | /^https?:\/\//.test(piece) ? ( | |
| 379 | + | <a key={index} href={piece} className="break-all text-fg underline underline-offset-2 hover:text-accent"> | |
| 380 | + | {piece} | |
| 381 | + | </a> | |
| 382 | + | ) : ( | |
| 383 | + | piece | |
| 384 | + | ), | |
| 385 | + | )} | |
| 386 | + | </li> | |
| 387 | + | ))} | |
| 388 | + | </ul> | |
| 389 | + | </div> | |
| 390 | + | ); | |
| 391 | + | } |
| 35 | 35 | danger = true, | |
| 36 | 36 | action, | |
| 37 | 37 | trigger, | |
| 38 | + | extra, | |
| 38 | 39 | }: { | |
| 39 | 40 | intent: string; | |
| 40 | 41 | /** Other hidden fields to post with it. */ | |
| ⋯ | |||
| 53 | 54 | /** Where to post, when not the page's own action. */ | |
| 54 | 55 | action?: string; | |
| 55 | 56 | trigger: (open: () => void) => ReactNode; | |
| 57 | + | /** More fields to post, under the list: a choice that goes with it. */ | |
| 58 | + | extra?: ReactNode; | |
| 56 | 59 | }) { | |
| 57 | 60 | const [open, setOpen] = useState(Boolean(error)); | |
| 58 | 61 | const [typed, setTyped] = useState(""); | |
| ⋯ | |||
| 89 | 92 | {description && <AlertDialogDescription>{description}</AlertDialogDescription>} | |
| 90 | 93 | </AlertDialogHeader> | |
| 91 | 94 | {children && <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">{children}</ul>} | |
| 95 | + | {extra} | |
| 92 | 96 | {confirm != null && ( | |
| 93 | 97 | <FormField> | |
| 94 | 98 | <FieldLabel htmlFor={`${id}-confirm`}> | |
| 9 | 9 | agents: { title: "Agents", about: "How g1t picks up work here, and what it reads first." }, | |
| 10 | 10 | guardrails: { title: "Guardrails", about: "What agents may reach, run and spend while they work here." }, | |
| 11 | 11 | repository: { title: "Repository", about: "Its name, details and default branch, who can see it, and archiving, moving or deleting it." }, | |
| 12 | + | mirroring: { title: "Mirroring", about: "Copies of this repository on other hosts: which one leads, taking over and handing back, and remotes that follow it." }, | |
| 12 | 13 | access: { title: "Access", about: "Who can see and change the repository, with which role, and invitations to it." }, | |
| 13 | 14 | keys: { title: "Deploy keys", about: "SSH keys that let a machine clone this repository, or push to it, and reach nothing else." }, | |
| 14 | 15 | branches: { title: "Branches and merging", about: "How pull requests merge, what g1t's agents do with theirs, and who owns which files." }, |
| 1 | − | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Activity, ArrowLeftRight, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 1095 | 1095 | Repository | |
| 1096 | 1096 | </SidebarLink> | |
| 1097 | 1097 | )} | |
| 1098 | + | {shows("mirroring") && ( | |
| 1099 | + | <SidebarLink to={`${base}/settings/mirroring`} icon={<ArrowLeftRight size={15} />}> | |
| 1100 | + | Mirroring | |
| 1101 | + | </SidebarLink> | |
| 1102 | + | )} | |
| 1098 | 1103 | {shows("access") && ( | |
| 1099 | 1104 | <SidebarLink to={`${base}/settings/access`} icon={<Users size={15} />}> | |
| 1100 | 1105 | Access | |
| 37 | 37 | runners: "manage_integrations", | |
| 38 | 38 | deployments: "manage_integrations", | |
| 39 | 39 | domains: "manage_integrations", | |
| 40 | + | // Taking over, handing back and adding remotes: Admins. | |
| 41 | + | mirroring: "manage_integrations", | |
| 40 | 42 | dependencies: "manage_settings", | |
| 41 | 43 | // Write and up can see who has access; Admins change it. | |
| 42 | 44 | access: "push", |
| 81 | 81 | }, | |
| 82 | 82 | { | |
| 83 | 83 | id: "mirror", | |
| 84 | − | title: "Mirror", | |
| 85 | − | text: "Keep the code on GitHub. Every push there is fetched into g1t, so agents can work on it, and deployments build it once you turn them on.", | |
| 84 | + | title: "Standby mirror", | |
| 85 | + | text: "g1t keeps a read-only copy that follows GitHub. Take over whenever you need to work here.", | |
| 86 | 86 | }, | |
| 87 | 87 | { | |
| 88 | 88 | id: "push", | |
| 89 | 89 | title: "Move to g1t", | |
| 90 | − | text: "Work on g1t from now on. Every push here is pushed to GitHub, so it stays up to date for everyone else.", | |
| 90 | + | text: "g1t leads; GitHub follows every push.", | |
| 91 | 91 | }, | |
| 92 | 92 | ] as const; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { HandbackPlan, RepoMirror } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { DEFAULT_MIRROR_SETTINGS, TAKE_OVER_AFTER_MINUTES, mirrorWritable } from "../../../../packages/contracts/src/mirrors.ts"; | |
| 7 | + | ||
| 8 | + | import { | |
| 9 | + | clampMinutes, | |
| 10 | + | decisionsFrom, | |
| 11 | + | handBackReady, | |
| 12 | + | mirrorBadge, | |
| 13 | + | mirrorBanner, | |
| 14 | + | mirrorReason, | |
| 15 | + | mirrorSettingsFrom, | |
| 16 | + | shortSha, | |
| 17 | + | TAKE_OVER_MINUTES, | |
| 18 | + | workflowReason, | |
| 19 | + | writable, | |
| 20 | + | } from "./mirror.ts"; | |
| 21 | + | ||
| 22 | + | const mirror = (state: RepoMirror["state"]): RepoMirror => ({ | |
| 23 | + | state, | |
| 24 | + | remote: "github.com/acme/web", | |
| 25 | + | url: "https://github.com/acme/web", | |
| 26 | + | since: "2026-10-08T10:00:00Z", | |
| 27 | + | }); | |
| 28 | + | const repo = (state: RepoMirror["state"] | null) => ({ namespace: "acme", name: "web", mirror: state ? mirror(state) : null }); | |
| 29 | + | const leader = (reachable: boolean) => ({ repoId: "r", role: "leader" as const, name: "github.com/acme/web", state: "standby" as const, reachable }); | |
| 30 | + | const follower = (name: string) => ({ repoId: "r", role: "follower" as const, name, state: "following" as const, reachable: true }); | |
| 31 | + | ||
| 32 | + | test("the copies agree with the contracts", () => { | |
| 33 | + | for (const state of [null, "standby", "ci", "takeover", "handing_back"] as const) { | |
| 34 | + | assert.equal(writable(state ? mirror(state) : null), mirrorWritable(state ? mirror(state) : null)); | |
| 35 | + | } | |
| 36 | + | assert.deepEqual([...TAKE_OVER_MINUTES], [...TAKE_OVER_AFTER_MINUTES]); | |
| 37 | + | }); | |
| 38 | + | ||
| 39 | + | test("a read-only mirror says why its buttons are off, and where to take over", () => { | |
| 40 | + | assert.equal( | |
| 41 | + | mirrorReason(repo("standby")), | |
| 42 | + | "acme/web is a mirror of github.com/acme/web. Work happens there until someone takes over in Settings → Mirroring.", | |
| 43 | + | ); | |
| 44 | + | assert.equal(mirrorReason(repo("ci")), mirrorReason(repo("standby"))); | |
| 45 | + | assert.equal(mirrorReason(repo("handing_back")), "acme/web is handing back to github.com/acme/web; it takes changes again when that is done."); | |
| 46 | + | assert.equal(mirrorReason(repo("takeover")), null); | |
| 47 | + | assert.equal(mirrorReason(repo(null)), null); | |
| 48 | + | assert.equal(mirrorReason(null), null); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | test("workflows run in CI failover and takeovers, not while standing by or handing back", () => { | |
| 52 | + | assert.equal(workflowReason(repo("ci")), null); | |
| 53 | + | assert.equal(workflowReason(repo("takeover")), null); | |
| 54 | + | assert.ok(workflowReason(repo("standby"))); | |
| 55 | + | assert.ok(workflowReason(repo("handing_back"))); | |
| 56 | + | assert.equal(workflowReason(repo(null)), null); | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | test("the badge says what the repository is to its remotes", () => { | |
| 60 | + | assert.deepEqual(mirrorBadge(mirror("standby"), []), { label: "Mirror of github.com/acme/web", tone: "neutral", more: 0 }); | |
| 61 | + | assert.equal(mirrorBadge(mirror("ci"), [])?.tone, "info"); | |
| 62 | + | assert.equal(mirrorBadge(mirror("takeover"), [])?.label, "Taken over from github.com/acme/web"); | |
| 63 | + | assert.equal(mirrorBadge(mirror("handing_back"), [])?.label, "Handing back to github.com/acme/web"); | |
| 64 | + | assert.deepEqual(mirrorBadge(null, [follower("github.com/acme/web"), follower("git.example.com/web")]), { | |
| 65 | + | label: "Mirrored to github.com/acme/web", | |
| 66 | + | tone: "accent", | |
| 67 | + | more: 1, | |
| 68 | + | }); | |
| 69 | + | assert.equal(mirrorBadge(null, []), null); | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | test("a mirror standing by whose remote answers needs no banner", () => { | |
| 73 | + | assert.equal(mirrorBanner(mirror("standby"), [leader(true)]), null); | |
| 74 | + | assert.equal(mirrorBanner(mirror("standby"), []), null); | |
| 75 | + | assert.equal(mirrorBanner(mirror("standby"), [leader(false)]), "unreachable"); | |
| 76 | + | assert.equal(mirrorBanner(mirror("ci"), [leader(false)]), "unreachable"); | |
| 77 | + | assert.equal(mirrorBanner(mirror("ci"), [leader(true)]), "ci"); | |
| 78 | + | assert.equal(mirrorBanner(mirror("takeover"), [leader(false)]), "takeover"); | |
| 79 | + | assert.equal(mirrorBanner(mirror("handing_back"), []), "handing_back"); | |
| 80 | + | assert.equal(mirrorBanner(null, [follower("x")]), null); | |
| 81 | + | }); | |
| 82 | + | ||
| 83 | + | test("hand-back decisions are read from their fields, and only known ones", () => { | |
| 84 | + | const form = new FormData(); | |
| 85 | + | form.set("intent", "hand-back"); | |
| 86 | + | form.set("decision:main", "keep_ours"); | |
| 87 | + | form.set("decision:release/1.x", "pull_request"); | |
| 88 | + | form.set("decision:dev", "delete_everything"); | |
| 89 | + | assert.deepEqual(decisionsFrom(form.entries()), { main: "keep_ours", "release/1.x": "pull_request" }); | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | test("a hand-back is ready once the remote answers and every diverged branch has a decision", () => { | |
| 93 | + | const plan: HandbackPlan = { | |
| 94 | + | reachable: true, | |
| 95 | + | ready: false, | |
| 96 | + | refs: [ | |
| 97 | + | { ref: "main", base: "a", ours: "b", theirs: "c", action: "diverged", decision: null }, | |
| 98 | + | { ref: "dev", base: "a", ours: "b", theirs: "a", action: "push", decision: null }, | |
| 99 | + | ], | |
| 100 | + | }; | |
| 101 | + | assert.equal(handBackReady(plan, {}), false); | |
| 102 | + | assert.equal(handBackReady(plan, { main: "keep_theirs" }), true); | |
| 103 | + | assert.equal(handBackReady({ ...plan, reachable: false }, { main: "keep_theirs" }), false); | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test("settings come back from the form, keeping what it did not carry", () => { | |
| 107 | + | const leaderForm = new FormData(); | |
| 108 | + | leaderForm.set("kind", "leader"); | |
| 109 | + | leaderForm.set("notify", "inbox"); | |
| 110 | + | leaderForm.set("autoTakeOver", "on"); | |
| 111 | + | leaderForm.set("takeOverAfter", "2"); | |
| 112 | + | leaderForm.set("githubWorkflows", "on"); | |
| 113 | + | const current = { ...DEFAULT_MIRROR_SETTINGS, remotePushes: "overwrite" as const }; | |
| 114 | + | assert.deepEqual(mirrorSettingsFrom(leaderForm, current), { | |
| 115 | + | notify: "inbox", | |
| 116 | + | takeOverAfter: 5, | |
| 117 | + | handBack: "ask", | |
| 118 | + | keepCiWarm: false, | |
| 119 | + | githubWorkflows: true, | |
| 120 | + | holdDeploys: false, | |
| 121 | + | remotePushes: "overwrite", | |
| 122 | + | }); | |
| 123 | + | const followerForm = new FormData(); | |
| 124 | + | followerForm.set("kind", "follower"); | |
| 125 | + | followerForm.set("remotePushes", "overwrite"); | |
| 126 | + | assert.deepEqual(mirrorSettingsFrom(followerForm, DEFAULT_MIRROR_SETTINGS), { ...DEFAULT_MIRROR_SETTINGS, remotePushes: "overwrite" }); | |
| 127 | + | }); | |
| 128 | + | ||
| 129 | + | test("minutes and commits are shown within bounds", () => { | |
| 130 | + | assert.equal(clampMinutes(3), 5); | |
| 131 | + | assert.equal(clampMinutes(90.4), 90); | |
| 132 | + | assert.equal(clampMinutes(99999), 1440); | |
| 133 | + | assert.equal(clampMinutes(Number.NaN), 5); | |
| 134 | + | assert.equal(shortSha("0123456789abcdef"), "0123456"); | |
| 135 | + | assert.equal(shortSha(null), "—"); | |
| 136 | + | }); |
| 1 | + | /** | |
| 2 | + | * Mirroring, as the site says it: the badge beside a repository's name, | |
| 3 | + | * the line across its pages, why a button is off on a read-only mirror, | |
| 4 | + | * and the settings and hand-back forms read back from what was posted. | |
| 5 | + | * The rules themselves (what a mirror refuses) are the integrations | |
| 6 | + | * service's; these only put them into words. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | // Types only: the contracts' index does not load under `node --test`. | |
| 10 | + | // mirror.test.ts holds the copies below to the contracts' own values. | |
| 11 | + | import type { HandbackPlan, MirrorSettings, RefAction, RefDecision, RemoteBrief, RepoMirror } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | /** As `mirrorWritable` in the contracts: it leads, or g1t has taken over. */ | |
| 14 | + | export function writable(mirror: RepoMirror | null | undefined): boolean { | |
| 15 | + | return !mirror || mirror.state === "takeover"; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /** As `TAKE_OVER_AFTER_MINUTES` in the contracts: 5 minutes to a day. */ | |
| 19 | + | export const TAKE_OVER_MINUTES = [5, 24 * 60] as const; | |
| 20 | + | ||
| 21 | + | /** What a repository needs to say anything about its mirroring. */ | |
| 22 | + | export type MirroredRepo = { namespace: string; name: string; mirror?: RepoMirror | null }; | |
| 23 | + | ||
| 24 | + | /** | |
| 25 | + | * A remote in brief, as the repository layout loads it for every page. | |
| 26 | + | * `syncedAt` when the service says when it last copied, for the line that | |
| 27 | + | * says how fresh g1t's copy is. | |
| 28 | + | */ | |
| 29 | + | export type MirrorBrief = RemoteBrief & { syncedAt?: string | null }; | |
| 30 | + | ||
| 31 | + | /** Where the mirroring settings are, and the guide. */ | |
| 32 | + | export const MIRRORING_DOCS = "https://docs.g1t.sh/guides/mirroring/"; | |
| 33 | + | export const mirroringSettings = (base: string) => `${base}/settings/mirroring`; | |
| 34 | + | ||
| 35 | + | /** | |
| 36 | + | * Why pushing, merging, opening issues and pull requests, and assigning | |
| 37 | + | * agents are off: the repository follows a remote that leads. Null when | |
| 38 | + | * they are not off for that reason (it leads, or g1t has taken over). | |
| 39 | + | */ | |
| 40 | + | export function mirrorReason(repo: MirroredRepo | null | undefined): string | null { | |
| 41 | + | const mirror = repo?.mirror; | |
| 42 | + | if (!repo || writable(mirror)) return null; | |
| 43 | + | const full = `${repo.namespace}/${repo.name}`; | |
| 44 | + | if (mirror!.state === "handing_back") { | |
| 45 | + | return `${full} is handing back to ${mirror!.remote}; it takes changes again when that is done.`; | |
| 46 | + | } | |
| 47 | + | return `${full} is a mirror of ${mirror!.remote}. Work happens there until someone takes over in Settings → Mirroring.`; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * Why running a workflow or running one again is off. In CI failover g1t | |
| 52 | + | * runs the remote's workflows, so they may be run here; a mirror standing | |
| 53 | + | * by, or handing back, runs nothing. | |
| 54 | + | */ | |
| 55 | + | export function workflowReason(repo: MirroredRepo | null | undefined): string | null { | |
| 56 | + | const state = repo?.mirror?.state; | |
| 57 | + | if (state === "ci" || state === "takeover") return null; | |
| 58 | + | return mirrorReason(repo); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** The leader a mirror follows, from the briefs. */ | |
| 62 | + | export function leaderOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief | null { | |
| 63 | + | return briefs?.find((brief) => brief.role === "leader") ?? null; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** The remotes that follow this repository, from the briefs. */ | |
| 67 | + | export function followersOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief[] { | |
| 68 | + | return (briefs ?? []).filter((brief) => brief.role === "follower"); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | export type MirrorTone = "neutral" | "info" | "warn" | "accent"; | |
| 72 | + | ||
| 73 | + | /** The badge beside the repository's name: what it is to its remotes, in a few words. */ | |
| 74 | + | export function mirrorBadge( | |
| 75 | + | mirror: RepoMirror | null | undefined, | |
| 76 | + | briefs: readonly MirrorBrief[] | null | undefined, | |
| 77 | + | ): { label: string; tone: MirrorTone; more: number } | null { | |
| 78 | + | if (mirror) { | |
| 79 | + | switch (mirror.state) { | |
| 80 | + | case "standby": | |
| 81 | + | return { label: `Mirror of ${mirror.remote}`, tone: "neutral", more: 0 }; | |
| 82 | + | case "ci": | |
| 83 | + | return { label: "Mirror · running CI", tone: "info", more: 0 }; | |
| 84 | + | case "takeover": | |
| 85 | + | return { label: `Taken over from ${mirror.remote}`, tone: "warn", more: 0 }; | |
| 86 | + | case "handing_back": | |
| 87 | + | return { label: `Handing back to ${mirror.remote}`, tone: "warn", more: 0 }; | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | const followers = followersOf(briefs); | |
| 91 | + | if (followers.length === 0) return null; | |
| 92 | + | return { label: `Mirrored to ${followers[0]!.name}`, tone: "accent", more: followers.length - 1 }; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * Which line goes across the repository's pages, if any. A mirror standing | |
| 97 | + | * by whose remote answers needs none: the badge says it. | |
| 98 | + | */ | |
| 99 | + | export type MirrorBannerKind = "unreachable" | "ci" | "takeover" | "handing_back"; | |
| 100 | + | ||
| 101 | + | export function mirrorBanner( | |
| 102 | + | mirror: RepoMirror | null | undefined, | |
| 103 | + | briefs: readonly MirrorBrief[] | null | undefined, | |
| 104 | + | ): MirrorBannerKind | null { | |
| 105 | + | if (!mirror) return null; | |
| 106 | + | const answering = leaderOf(briefs)?.reachable !== false; | |
| 107 | + | if ((mirror.state === "standby" || mirror.state === "ci") && !answering) return "unreachable"; | |
| 108 | + | if (mirror.state === "standby") return null; | |
| 109 | + | return mirror.state; | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /** What happens to one branch when the takeover goes back. */ | |
| 113 | + | export function refActionWords(action: RefAction, remote: string): string { | |
| 114 | + | switch (action) { | |
| 115 | + | case "same": | |
| 116 | + | return "Already the same"; | |
| 117 | + | case "push": | |
| 118 | + | return `Pushed to ${remote}`; | |
| 119 | + | case "fetch": | |
| 120 | + | return `Taken from ${remote}`; | |
| 121 | + | case "pull_request": | |
| 122 | + | return "Sent as a pull request (protected there)"; | |
| 123 | + | case "diverged": | |
| 124 | + | return "Both moved: choose"; | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /** The choices for a branch both sides moved, in order. */ | |
| 129 | + | export function decisionChoices(remote: string): { value: RefDecision; label: string }[] { | |
| 130 | + | return [ | |
| 131 | + | { value: "keep_ours", label: "Keep g1t's" }, | |
| 132 | + | { value: "keep_theirs", label: `Keep ${remote}'s` }, | |
| 133 | + | { value: "pull_request", label: "Send g1t's as a pull request" }, | |
| 134 | + | ]; | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /** A commit's short name, or a dash when the branch is not there. */ | |
| 138 | + | export function shortSha(sha: string | null | undefined): string { | |
| 139 | + | return sha ? sha.slice(0, 7) : "—"; | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /** A branch name as a form field, for its decision. */ | |
| 143 | + | export const decisionField = (ref: string) => `decision:${ref}`; | |
| 144 | + | ||
| 145 | + | /** The decisions posted with a hand-back: one per branch both sides moved. */ | |
| 146 | + | export function decisionsFrom(entries: Iterable<[string, FormDataEntryValue]>): Record<string, RefDecision> { | |
| 147 | + | const decisions: Record<string, RefDecision> = {}; | |
| 148 | + | for (const [name, value] of entries) { | |
| 149 | + | if (!name.startsWith("decision:")) continue; | |
| 150 | + | const decision = String(value); | |
| 151 | + | if (decision === "keep_ours" || decision === "keep_theirs" || decision === "pull_request") { | |
| 152 | + | decisions[name.slice("decision:".length)] = decision; | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | return decisions; | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** | |
| 159 | + | * Whether the hand-back can go: the remote answers, and every branch both | |
| 160 | + | * sides moved has a decision, the one chosen on the page or the plan's. | |
| 161 | + | */ | |
| 162 | + | export function handBackReady(plan: HandbackPlan, chosen: Record<string, RefDecision | undefined>): boolean { | |
| 163 | + | if (!plan.reachable) return false; | |
| 164 | + | return plan.refs.every((ref) => ref.action !== "diverged" || (chosen[ref.ref] ?? ref.decision) != null); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /** Minutes before an automatic takeover, held to what the service takes. */ | |
| 168 | + | export function clampMinutes(value: number): number { | |
| 169 | + | const [least, most] = TAKE_OVER_MINUTES; | |
| 170 | + | if (!Number.isFinite(value)) return least; | |
| 171 | + | return Math.min(most, Math.max(least, Math.round(value))); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | type FormLike = { get(name: string): FormDataEntryValue | null }; | |
| 175 | + | ||
| 176 | + | /** | |
| 177 | + | * A remote's settings from its form. The leader's form (a mirror's remote) | |
| 178 | + | * and a follower's carry different fields: what a form does not carry | |
| 179 | + | * keeps its value from `current`. | |
| 180 | + | */ | |
| 181 | + | export function mirrorSettingsFrom(form: FormLike, current: MirrorSettings): MirrorSettings { | |
| 182 | + | if (form.get("kind") === "follower") { | |
| 183 | + | const pushes = form.get("remotePushes"); | |
| 184 | + | return { ...current, remotePushes: pushes === "overwrite" ? "overwrite" : pushes === "adopt" ? "adopt" : current.remotePushes }; | |
| 185 | + | } | |
| 186 | + | const on = (name: string) => form.get(name) === "on"; | |
| 187 | + | return { | |
| 188 | + | ...current, | |
| 189 | + | notify: form.get("notify") === "inbox" ? "inbox" : "banner", | |
| 190 | + | takeOverAfter: on("autoTakeOver") ? clampMinutes(Number(form.get("takeOverAfter") ?? "")) : null, | |
| 191 | + | handBack: on("handBackWhenClean") ? "when_clean" : "ask", | |
| 192 | + | keepCiWarm: on("keepCiWarm"), | |
| 193 | + | githubWorkflows: on("githubWorkflows"), | |
| 194 | + | holdDeploys: on("holdDeploys"), | |
| 195 | + | }; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /** Words for a remote's state, in its row. */ | |
| 199 | + | export function remoteStateWords(state: string): string { | |
| 200 | + | switch (state) { | |
| 201 | + | case "standby": | |
| 202 | + | return "Standing by"; | |
| 203 | + | case "ci": | |
| 204 | + | return "CI failover"; | |
| 205 | + | case "takeover": | |
| 206 | + | return "Taken over"; | |
| 207 | + | case "handing_back": | |
| 208 | + | return "Handing back"; | |
| 209 | + | case "following": | |
| 210 | + | return "Following"; | |
| 211 | + | case "stuck": | |
| 212 | + | return "Stuck"; | |
| 213 | + | default: | |
| 214 | + | return state; | |
| 215 | + | } | |
| 216 | + | } |
| 8 | 8 | contextClient, | |
| 9 | 9 | deploymentsClient, | |
| 10 | 10 | memoryReviewClient, | |
| 11 | + | mirrorsClient, | |
| 11 | 12 | packagesClient, | |
| 12 | 13 | projectsClient, | |
| 13 | 14 | eventsClient, | |
| ⋯ | |||
| 54 | 55 | /** Each person's inbox, which the events service keeps. */ | |
| 55 | 56 | export const inbox = inboxClient(EVENTS); | |
| 56 | 57 | export const integrations = integrationsClient(INTEGRATIONS); | |
| 58 | + | /** Mirroring: a repository's remotes, takeovers and hand-backs, kept by integrations. */ | |
| 59 | + | export const mirrors = mirrorsClient(INTEGRATIONS); | |
| 57 | 60 | export const webhooks = webhooksClient(WEBHOOKS); | |
| 58 | 61 | export const actions = actionsClient(ACTIONS); | |
| 59 | 62 | export const deployments = deploymentsClient(DEPLOYMENTS); | |
| 209 | 209 | route("plans/:id", "routes/repo/plan.tsx"), | |
| 210 | 210 | route("settings", "routes/repo/settings.tsx"), | |
| 211 | 211 | route("settings/repository", "routes/repo/settings-repository.tsx"), | |
| 212 | + | route("settings/mirroring", "routes/repo/settings-mirroring.tsx"), | |
| 212 | 213 | route("settings/access", "routes/repo/settings-access.tsx"), | |
| 213 | 214 | route("settings/keys", "routes/repo/settings-deploy-keys.tsx"), | |
| 214 | 215 | route("settings/branches", "routes/repo/settings-branches.tsx"), |
| 38 | 38 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog"; | |
| 39 | 39 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; | |
| 40 | 40 | import { Hint } from "../../components/ui/hint"; | |
| 41 | + | import { useWorkflowReason } from "../../components/mirror"; | |
| 41 | 42 | import { searchLog } from "../../lib/log-lines"; | |
| 42 | 43 | import { listArtifacts } from "../../lib/artifacts.server"; | |
| 43 | 44 | import { expiresIn, formatBytes } from "../../lib/artifacts"; | |
| ⋯ | |||
| 671 | 672 | const cancelling = live && run.conclusion === "cancelled"; | |
| 672 | 673 | // Re-running is for the latest attempt of a finished run. | |
| 673 | 674 | const canRerun = member && !run.error && !live && latest; | |
| 675 | + | // A mirror standing by runs nothing; in CI failover it runs them again. | |
| 676 | + | const rerunBlocked = useWorkflowReason(); | |
| 674 | 677 | const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`; | |
| 675 | 678 | // Waiting on a person needs no quick refresh; a running job does. | |
| 676 | 679 | useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500); | |
| ⋯ | |||
| 730 | 733 | busy={busy} | |
| 731 | 734 | > | |
| 732 | 735 | {(open) => ( | |
| 733 | − | <Button type="button" variant="quiet" disabled={busy} onClick={open}> | |
| 734 | − | <RotateCw size={13} /> | |
| 735 | − | Re-run failed jobs | |
| 736 | − | </Button> | |
| 736 | + | <Hint label={rerunBlocked} disabled={rerunBlocked != null}> | |
| 737 | + | <Button type="button" variant="quiet" disabled={busy || rerunBlocked != null} onClick={open}> | |
| 738 | + | <RotateCw size={13} /> | |
| 739 | + | Re-run failed jobs | |
| 740 | + | </Button> | |
| 741 | + | </Hint> | |
| 737 | 742 | )} | |
| 738 | 743 | </RerunDialog> | |
| 739 | 744 | )} | |
| ⋯ | |||
| 745 | 750 | busy={busy} | |
| 746 | 751 | > | |
| 747 | 752 | {(open) => ( | |
| 748 | − | <Button type="button" variant="quiet" disabled={busy} onClick={open}> | |
| 749 | − | <RotateCw size={13} /> | |
| 750 | − | Re-run all jobs | |
| 751 | − | </Button> | |
| 753 | + | <Hint label={rerunBlocked} disabled={rerunBlocked != null}> | |
| 754 | + | <Button type="button" variant="quiet" disabled={busy || rerunBlocked != null} onClick={open}> | |
| 755 | + | <RotateCw size={13} /> | |
| 756 | + | Re-run all jobs | |
| 757 | + | </Button> | |
| 758 | + | </Hint> | |
| 752 | 759 | )} | |
| 753 | 760 | </RerunDialog> | |
| 754 | 761 | )} | |
| ⋯ | |||
| 885 | 892 | busy={busy} | |
| 886 | 893 | > | |
| 887 | 894 | {(open) => ( | |
| 888 | − | <Hint label="Re-run this job"> | |
| 895 | + | <Hint label={rerunBlocked ?? "Re-run this job"} disabled={rerunBlocked != null}> | |
| 889 | 896 | <button | |
| 890 | 897 | type="button" | |
| 891 | 898 | aria-label={`Re-run ${selected.name}`} | |
| 892 | − | disabled={busy} | |
| 899 | + | disabled={busy || rerunBlocked != null} | |
| 893 | 900 | onClick={open} | |
| 894 | 901 | className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg disabled:opacity-50" | |
| 895 | 902 | > | |
| 9 | 9 | import { Notes, StatusIcon, duration, shortRef } from "../../components/actions"; | |
| 10 | 10 | import { AddCiPrompt } from "../../components/add-ci"; | |
| 11 | 11 | import { Button, ComputeNote, CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 12 | + | import { useWorkflowReason } from "../../components/mirror"; | |
| 13 | + | import { Hint } from "../../components/ui/hint"; | |
| 12 | 14 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 13 | 15 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog"; | |
| 14 | 16 | import { useAddresses } from "../../lib/addresses"; | |
| ⋯ | |||
| 181 | 183 | ||
| 182 | 184 | function RunWorkflow({ workflow }: { workflow: Workflow }) { | |
| 183 | 185 | const [open, setOpen] = useState(false); | |
| 186 | + | // A mirror standing by runs nothing; in CI failover it runs them. | |
| 187 | + | const blocked = useWorkflowReason(); | |
| 184 | 188 | // Open, saying it is starting, until the run shows below or the error does. | |
| 185 | 189 | const running = usePending({ intent: "dispatch", workflow: workflow.id }); | |
| 186 | 190 | const was = useRef(false); | |
| ⋯ | |||
| 192 | 196 | const booleans = inputs.filter(([, spec]) => spec.type === "boolean").map(([name]) => name); | |
| 193 | 197 | return ( | |
| 194 | 198 | <div className="relative"> | |
| 195 | − | <Button type="button" variant="quiet" onClick={() => setOpen((v) => !v)}> | |
| 196 | − | <Play size={14} /> | |
| 197 | − | Run workflow | |
| 198 | − | </Button> | |
| 199 | + | <Hint label={blocked} disabled={blocked != null}> | |
| 200 | + | <Button type="button" variant="quiet" disabled={blocked != null} onClick={() => setOpen((v) => !v)}> | |
| 201 | + | <Play size={14} /> | |
| 202 | + | Run workflow | |
| 203 | + | </Button> | |
| 204 | + | </Hint> | |
| 199 | 205 | {open && ( | |
| 200 | 206 | <Form | |
| 201 | 207 | method="post" | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui"; | |
| 10 | 10 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 11 | + | import { Hint } from "../../components/ui/hint"; | |
| 12 | + | import { useMirrorReason } from "../../components/mirror"; | |
| 11 | 13 | import { LabelChip } from "../../components/labels"; | |
| 12 | 14 | import { integrations, work } from "../../lib/services.server"; | |
| 13 | 15 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| ⋯ | |||
| 95 | 97 | const [params] = useSearchParams(); | |
| 96 | 98 | const refused = actionData && "notStarted" in actionData ? actionData.notStarted : null; | |
| 97 | 99 | const names = loaderData.sources.map((source) => PROVIDERS[source].label); | |
| 100 | + | // A mirror takes no new issues until someone takes over. | |
| 101 | + | const mirrorBlocked = useMirrorReason(); | |
| 98 | 102 | return ( | |
| 99 | 103 | <div className="max-w-2xl"> | |
| 100 | 104 | {names.length > 0 && ( | |
| ⋯ | |||
| 113 | 117 | <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" /> | |
| 114 | 118 | </div> | |
| 115 | 119 | <CheckboxOption name="assign" label="Put an agent on it" className="items-center" labelClassName="text-muted" /> | |
| 116 | − | <SubmitButton variant="quiet" match={{ intent: "import" }} pending="Importing…"> | |
| 117 | − | Import | |
| 118 | − | </SubmitButton> | |
| 120 | + | <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}> | |
| 121 | + | <SubmitButton variant="quiet" match={{ intent: "import" }} pending="Importing…" disabled={mirrorBlocked != null}> | |
| 122 | + | Import | |
| 123 | + | </SubmitButton> | |
| 124 | + | </Hint> | |
| 119 | 125 | </div> | |
| 120 | 126 | {actionData && "importError" in actionData && ( | |
| 121 | 127 | <div className="mt-2"> | |
| ⋯ | |||
| 217 | 223 | </div> | |
| 218 | 224 | )} | |
| 219 | 225 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 220 | − | <SubmitButton name="intent" value="open" pending="Opening…"> | |
| 221 | − | Open issue | |
| 222 | − | </SubmitButton> | |
| 226 | + | <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}> | |
| 227 | + | <SubmitButton name="intent" value="open" pending="Opening…" disabled={mirrorBlocked != null}> | |
| 228 | + | Open issue | |
| 229 | + | </SubmitButton> | |
| 230 | + | </Hint> | |
| 223 | 231 | </Form> | |
| 224 | 232 | </div> | |
| 225 | 233 | ); | |
| 40 | 40 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 41 | 41 | import { accessTo, refusal, repoFor } from "../../lib/access.server"; | |
| 42 | 42 | import { SubscriptionBox } from "../../components/notifications"; | |
| 43 | + | import { useMirrorReason } from "../../components/mirror"; | |
| 44 | + | import { Hint } from "../../components/ui/hint"; | |
| 43 | 45 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 44 | 46 | ||
| 45 | 47 | ||
| ⋯ | |||
| 271 | 273 | ||
| 272 | 274 | export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 273 | 275 | const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData; | |
| 276 | + | // A mirror runs no agents until someone takes over. | |
| 277 | + | const mirrorBlocked = useMirrorReason(); | |
| 274 | 278 | ||
| 275 | 279 | // Follow agents at work without a manual reload. | |
| 276 | 280 | const running = pulls.some( | |
| ⋯ | |||
| 535 | 539 | {open && agentsEnabled && can.run && !assigned && !issue.queued && ( | |
| 536 | 540 | <Form method="post" className="mt-3 space-y-2"> | |
| 537 | 541 | <input type="hidden" name="action" value="run-hosted" /> | |
| 538 | − | <div className="*:w-full"> | |
| 539 | − | <SubmitButton variant="accent" match={{ action: "run-hosted" }} pending="Starting a sandbox…"> | |
| 540 | − | <Sparkles size={14} /> | |
| 541 | − | Assign to g1t | |
| 542 | − | </SubmitButton> | |
| 542 | + | <div className="*:w-full [&_button]:w-full"> | |
| 543 | + | <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}> | |
| 544 | + | <SubmitButton variant="accent" match={{ action: "run-hosted" }} pending="Starting a sandbox…" disabled={mirrorBlocked != null}> | |
| 545 | + | <Sparkles size={14} /> | |
| 546 | + | Assign to g1t | |
| 547 | + | </SubmitButton> | |
| 548 | + | </Hint> | |
| 543 | 549 | </div> | |
| 544 | 550 | <details> | |
| 545 | 551 | <summary className="cursor-pointer text-xs text-faint hover:text-fg"> | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/issues"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | − | import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 8 | + | import { Button, ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 9 | 9 | import { Checkbox } from "../../components/ui/checkbox"; | |
| 10 | 10 | import { Hint } from "../../components/ui/hint"; | |
| 11 | + | import { mirrorReason, type MirroredRepo } from "../../lib/mirror"; | |
| 11 | 12 | import { | |
| 12 | 13 | Assignee, | |
| 13 | 14 | AssigneeStack, | |
| ⋯ | |||
| 105 | 106 | kept.delete("q"); | |
| 106 | 107 | ||
| 107 | 108 | // An archived repository's issues are locked: no new ones. | |
| 108 | − | const layout = useRouteLoaderData("routes/repo/layout") as { repo?: { archivedAt?: string | null } } | undefined; | |
| 109 | + | const layout = useRouteLoaderData("routes/repo/layout") as { repo?: MirroredRepo & { archivedAt?: string | null } } | undefined; | |
| 109 | 110 | const archived = Boolean(layout?.repo?.archivedAt); | |
| 111 | + | // A mirror takes no new issues until someone takes over: the button says why. | |
| 112 | + | const mirrorBlocked = mirrorReason(layout?.repo); | |
| 110 | 113 | // Handed over: the ticks are cleared. Refused: they stay, to try again. | |
| 111 | 114 | const form = useRef<HTMLFormElement>(null); | |
| 112 | 115 | useEffect(() => { | |
| ⋯ | |||
| 119 | 122 | state={state} | |
| 120 | 123 | query={kept.toString()} | |
| 121 | 124 | action={ | |
| 122 | − | archived ? undefined : ( | |
| 125 | + | archived ? undefined : mirrorBlocked ? ( | |
| 126 | + | <Hint label={mirrorBlocked} disabled> | |
| 127 | + | <Button type="button" disabled> | |
| 128 | + | <Plus size={15} /> | |
| 129 | + | New issue | |
| 130 | + | </Button> | |
| 131 | + | </Hint> | |
| 132 | + | ) : ( | |
| 123 | 133 | <ButtonLink to={`${base}/new`}> | |
| 124 | 134 | <Plus size={15} /> | |
| 125 | 135 | New issue | |
| ⋯ | |||
| 184 | 194 | Tick the issues to hand over. g1t takes each one in a run of its own, | |
| 185 | 195 | and they all work at once. | |
| 186 | 196 | </p> | |
| 187 | − | <SubmitButton variant="accent" pending="Starting sandboxes…"> | |
| 188 | − | Assign to g1t | |
| 189 | − | </SubmitButton> | |
| 197 | + | <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}> | |
| 198 | + | <SubmitButton variant="accent" pending="Starting sandboxes…" disabled={mirrorBlocked != null}> | |
| 199 | + | Assign to g1t | |
| 200 | + | </SubmitButton> | |
| 201 | + | </Hint> | |
| 190 | 202 | {loaderData.computeNote && ( | |
| 191 | 203 | <div className="basis-full"> | |
| 192 | 204 | <ComputeNote note={loaderData.computeNote} /> | |
| 16 | 16 | import { PinButton } from "../../components/pin-button"; | |
| 17 | 17 | import { StarButton } from "../../components/star-button"; | |
| 18 | 18 | import { ArchivedBanner } from "../../components/repo-lifecycle"; | |
| 19 | + | import { MirrorBadge, MirrorBanner } from "../../components/mirror"; | |
| 20 | + | import type { MirrorBrief } from "../../lib/mirror"; | |
| 19 | 21 | import { WelcomeBanner } from "../../components/welcome"; | |
| 20 | 22 | import { clearWelcome, welcomes } from "../../lib/invites"; | |
| 21 | 23 | import { notFound } from "../../lib/not-found.server"; | |
| 22 | 24 | import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server"; | |
| 23 | 25 | import { accessFor, countsFor, projectFor, repoFor } from "../../lib/access.server"; | |
| 24 | − | import { inbox, projects, repos } from "../../lib/services.server"; | |
| 26 | + | import { inbox, mirrors, projects, repos } from "../../lib/services.server"; | |
| 25 | 27 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 26 | 28 | ||
| 27 | 29 | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 33 | 35 | const path = { namespace: params.owner, name: params.repo }; | |
| 34 | 36 | // Members pin the workspace's projects, and what they open is their Recent. | |
| 35 | 37 | const member = viewer ? roleIn(viewer, params.owner) != null : false; | |
| 36 | − | const [repo, counts, found, watching, shortcuts, stars] = await Promise.all([ | |
| 38 | + | const [repo, counts, found, watching, shortcuts, stars, mirrorBriefs] = await Promise.all([ | |
| 37 | 39 | repoFor(context, params), | |
| 38 | 40 | countsFor(context, params), | |
| 39 | 41 | projectFor(context, params), | |
| ⋯ | |||
| 46 | 48 | member ? projects.shortcuts(params.owner, viewer).catch(() => null) : null, | |
| 47 | 49 | // How many starred it and whether they did, for the header's Star button. | |
| 48 | 50 | repos.stars(path, viewer).then((found) => (found.ok ? found.value : null)).catch(() => null), | |
| 51 | + | // Its remotes in brief, for the badge and the line across its pages: | |
| 52 | + | // whether the one it mirrors answers, and those that follow it. | |
| 53 | + | repoFor(context, params).then((found) => | |
| 54 | + | found.ok ? mirrors.briefs([found.value.id]).catch((): MirrorBrief[] => []) : ([] as MirrorBrief[]), | |
| 55 | + | ), | |
| 49 | 56 | ]); | |
| 50 | 57 | if (!repo.ok && !found.ok) { | |
| 51 | 58 | // Under a workspace's old name, after a rename: the project is at the new one. | |
| ⋯ | |||
| 80 | 87 | // Null for a pull request's working copy, or when it could not be read. | |
| 81 | 88 | stars: value.forkOf ? null : stars, | |
| 82 | 89 | signedIn: Boolean(viewer), | |
| 90 | + | mirrorBriefs: mirrorBriefs as MirrorBrief[], | |
| 83 | 91 | // Null when they cannot pin it: not one of their workspaces. | |
| 84 | 92 | pinned: member && project ? (shortcuts?.pinned ?? []).some((pinned) => pinned.id === project.id) : null, | |
| 85 | 93 | }, { headers }); | |
| ⋯ | |||
| 101 | 109 | } | |
| 102 | 110 | ||
| 103 | 111 | /** A repository's topics, each a way into Explore. */ | |
| 104 | − | function Header({ project, isPrivate, archived, namespace, name, description, large, actions }: { | |
| 112 | + | function Header({ project, isPrivate, archived, namespace, name, description, large, actions, mirror }: { | |
| 105 | 113 | project: Project | null; | |
| 106 | 114 | isPrivate: boolean; | |
| 107 | 115 | namespace: string; | |
| ⋯ | |||
| 111 | 119 | large?: boolean; | |
| 112 | 120 | /** At the end of the row: Pin, Watch and Star. */ | |
| 113 | 121 | actions?: ReactNode; | |
| 122 | + | /** Beside the visibility: what it is to its remotes. */ | |
| 123 | + | mirror?: ReactNode; | |
| 114 | 124 | }) { | |
| 115 | 125 | const base = `/${namespace}/${name}`; | |
| 116 | 126 | return ( | |
| ⋯ | |||
| 131 | 141 | </h1> | |
| 132 | 142 | <Pill>{isPrivate ? "private" : "public"}</Pill> | |
| 133 | 143 | {archived && <Pill>archived</Pill>} | |
| 144 | + | {mirror} | |
| 134 | 145 | {/* On a phone, on its own line under the name and the Watch menu. */} | |
| 135 | 146 | {description && <p className="order-last min-w-0 basis-full truncate text-sm text-muted sm:order-none sm:basis-0 sm:flex-1">{description}</p>} | |
| 136 | 147 | {actions && <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div>} | |
| ⋯ | |||
| 177 | 188 | } | |
| 178 | 189 | ||
| 179 | 190 | export default function ProjectLayout({ loaderData }: Route.ComponentProps) { | |
| 180 | − | const { repo, project, member, access, welcome, watching, pinned, stars, signedIn } = loaderData; | |
| 191 | + | const { repo, project, member, access, welcome, watching, pinned, stars, signedIn, mirrorBriefs } = loaderData; | |
| 181 | 192 | const base = `/${repo.namespace}/${repo.name}`; | |
| 182 | 193 | // The project's own description, else the repository's as it is now. | |
| 183 | 194 | const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description; | |
| ⋯ | |||
| 199 | 210 | name={repo.name} | |
| 200 | 211 | // The files' own About says it there, as the one place. | |
| 201 | 212 | description={filesPage ? null : description} | |
| 213 | + | mirror={<MirrorBadge mirror={repo.mirror} briefs={mirrorBriefs} />} | |
| 202 | 214 | actions={ | |
| 203 | 215 | watching || stars || (project && pinned != null) ? ( | |
| 204 | 216 | <> | |
| ⋯ | |||
| 233 | 245 | <ArchivedBanner base={base} owner={access.can.administer} settings={/^settings(\/|$)/.test(pathname.slice(base.length + 1))} /> | |
| 234 | 246 | </div> | |
| 235 | 247 | )} | |
| 248 | + | {/* The mirroring settings say it themselves, in more detail. */} | |
| 249 | + | {!/^settings\/mirroring(\/|$)/.test(pathname.slice(base.length + 1)) && ( | |
| 250 | + | <MirrorBanner | |
| 251 | + | base={base} | |
| 252 | + | full={`${repo.namespace}/${repo.name}`} | |
| 253 | + | mirror={repo.mirror} | |
| 254 | + | briefs={mirrorBriefs} | |
| 255 | + | admin={access.can.manage_integrations} | |
| 256 | + | className="mb-6" | |
| 257 | + | /> | |
| 258 | + | )} | |
| 236 | 259 | <Outlet /> | |
| 237 | 260 | </div> | |
| 238 | 261 | </> | |
| 52 | 52 | import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton"; | |
| 53 | 53 | import { ProductionShot } from "../../components/production-shot"; | |
| 54 | 54 | import { GithubLinkStrip } from "../../components/github"; | |
| 55 | + | import { MirrorOverviewNote, useRepoMirror } from "../../components/mirror"; | |
| 55 | 56 | import { distinctFacts } from "../../lib/memory-facts"; | |
| 56 | 57 | import { githubApp } from "../../lib/github.server"; | |
| 57 | 58 | import { Avatar, ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui"; | |
| ⋯ | |||
| 491 | 492 | const saved = await projects.update(user, params.owner, params.repo, projectChanges(form)); | |
| 492 | 493 | return saved.ok ? { notice: "Saved." } : { error: saved.error.message }; | |
| 493 | 494 | } | |
| 494 | − | // Syncing from GitHub is pushing; stopping it is an integration; deploying is compute. | |
| 495 | − | const refused = await refusal(context, params, intent === "github-sync" ? "push" : intent === "github-stop" ? "manage_integrations" : "run"); | |
| 495 | + | // Deploying is compute. Syncing with GitHub is under Settings → Mirroring. | |
| 496 | + | const refused = await refusal(context, params, "run"); | |
| 496 | 497 | if (refused) return { error: refused }; | |
| 497 | − | if (intent === "github-sync" || intent === "github-stop") { | |
| 498 | − | const repo = await repos.get({ namespace: params.owner, name: params.repo }, user); | |
| 499 | − | if (!repo.ok) return { error: repo.error.message }; | |
| 500 | − | const done = | |
| 501 | − | intent === "github-sync" ? await githubApp.sync(user, repo.value.id) : await githubApp.unlinkRepo(user, repo.value.id); | |
| 502 | − | return done.ok | |
| 503 | − | ? { notice: intent === "github-sync" ? "Synced with GitHub." : "It is no longer kept in step with GitHub." } | |
| 504 | − | : { error: done.error.message }; | |
| 505 | − | } | |
| 506 | 498 | const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null); | |
| 507 | 499 | return started.ok ? { notice: "Production is building." } : { error: started.error.message }; | |
| 508 | 500 | } | |
| ⋯ | |||
| 791 | 783 | const { member, project, settings, builds, live, commit, open, dependencies, agentsLive, columns, needs, landed, groups, health, knows, checklist, branches, library, checks } = | |
| 792 | 784 | loaderData; | |
| 793 | 785 | const base = `/${params.owner}/${params.repo}`; | |
| 786 | + | // What it is to its remotes, from the repository layout. | |
| 787 | + | const { repo: mirrorRepo, briefs: mirrorBriefs, admin: mirrorAdmin } = useRepoMirror(); | |
| 794 | 788 | const production = live.find((app) => app.kind === "production") ?? null; | |
| 795 | 789 | // The project's own domain, once it is active, is where production is visited. | |
| 796 | 790 | const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null; | |
| ⋯ | |||
| 819 | 813 | ||
| 820 | 814 | return ( | |
| 821 | 815 | <div className="space-y-8"> | |
| 822 | − | {loaderData.github && <GithubLinkStrip link={loaderData.github} />} | |
| 816 | + | {loaderData.github?.mode === "import" ? ( | |
| 817 | + | <GithubLinkStrip link={loaderData.github} /> | |
| 818 | + | ) : ( | |
| 819 | + | <MirrorOverviewNote | |
| 820 | + | base={base} | |
| 821 | + | mirror={mirrorRepo?.mirror} | |
| 822 | + | briefs={mirrorBriefs} | |
| 823 | + | admin={mirrorAdmin} | |
| 824 | + | syncedAt={loaderData.github?.syncedAt} | |
| 825 | + | lastError={loaderData.github?.lastError} | |
| 826 | + | /> | |
| 827 | + | )} | |
| 823 | 828 | {/* What the project is, running, and where its code is. */} | |
| 824 | 829 | <section className="overflow-hidden rounded-2xl border border-line bg-surface"> | |
| 825 | 830 | {project && ( | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | 6 | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 7 | 7 | import { Combobox } from "../../components/ui/combobox"; | |
| 8 | + | import { Hint } from "../../components/ui/hint"; | |
| 9 | + | import { useMirrorReason } from "../../components/mirror"; | |
| 8 | 10 | import { | |
| 9 | 11 | SubmitButton, | |
| 10 | 12 | CopyLine, | |
| ⋯ | |||
| 78 | 80 | export default function NewPull({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 79 | 81 | const { defaultBranch, base, bases, branches, selected, issue } = loaderData; | |
| 80 | 82 | const remote = cloneUrl(useAddresses(), `${params.owner}/${params.repo}`); | |
| 83 | + | // A mirror takes no pull requests until someone takes over. | |
| 84 | + | const mirrorBlocked = useMirrorReason(); | |
| 81 | 85 | ||
| 82 | 86 | if (branches.length === 0) { | |
| 83 | 87 | return ( | |
| ⋯ | |||
| 140 | 144 | <Input name="issue" type="number" min={1} defaultValue={issue} placeholder="12" /> | |
| 141 | 145 | </Field> | |
| 142 | 146 | <ErrorText>{actionData?.error}</ErrorText> | |
| 143 | − | <SubmitButton pending="Opening…">Open pull request</SubmitButton> | |
| 147 | + | <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}> | |
| 148 | + | <SubmitButton pending="Opening…" disabled={mirrorBlocked != null}> | |
| 149 | + | Open pull request | |
| 150 | + | </SubmitButton> | |
| 151 | + | </Hint> | |
| 144 | 152 | </Form> | |
| 145 | 153 | ); | |
| 146 | 154 | } | |
| 96 | 96 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 97 | 97 | import { accessTo, refusal, repoFor } from "../../lib/access.server"; | |
| 98 | 98 | import { SubscriptionBox } from "../../components/notifications"; | |
| 99 | + | import { useMirrorReason } from "../../components/mirror"; | |
| 99 | 100 | import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh"; | |
| 100 | 101 | ||
| 101 | 102 | const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false }; | |
| ⋯ | |||
| 788 | 789 | const [bypassing, setBypassing] = useState(false); | |
| 789 | 790 | const rulesUnmet = (rules?.unmet ?? []).filter((violation) => violation.rule !== "required_status_checks"); | |
| 790 | 791 | const rulesBypassable = rules?.bypassable ?? []; | |
| 792 | + | // A mirror takes no merges until someone takes over. | |
| 793 | + | const mirrorBlocked = useMirrorReason(); | |
| 791 | 794 | // Why the merge button cannot be pressed, if it cannot. | |
| 792 | − | const mergeBlocked = conflicting | |
| 795 | + | const mergeBlocked = mirrorBlocked ?? (conflicting | |
| 793 | 796 | ? "Resolve the conflicts first." | |
| 794 | 797 | : probing | |
| 795 | 798 | ? "Waiting to find out whether it merges cleanly." | |
| ⋯ | |||
| 803 | 806 | ? `Rules for ${defaultBranch} are not met. You may bypass them.` | |
| 804 | 807 | : ownersMissing | |
| 805 | 808 | ? "Code owners have to approve first." | |
| 806 | − | : null; | |
| 809 | + | : null); | |
| 807 | 810 | ||
| 808 | 811 | return ( | |
| 809 | 812 | // The changes get the whole width; people and settings are a tab away. | |
| 1 | + | import type { MirrorAddInput, MirrorView, Result } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/settings-mirroring"; | |
| 4 | + | import { type MirrorOutcome, MirroringSettings } from "../../components/mirror-settings"; | |
| 5 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 6 | + | import { page } from "../../lib/meta"; | |
| 7 | + | import { decisionsFrom, mirrorSettingsFrom } from "../../lib/mirror"; | |
| 8 | + | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 9 | + | import { mirrors, repos } from "../../lib/services.server"; | |
| 10 | + | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 11 | + | ||
| 12 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 13 | + | return page(args, { title: `Mirroring · ${params.owner}/${params.repo} · g1t` }); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 17 | + | // Admins; to anyone without a role here the page does not exist. | |
| 18 | + | const { viewer, repo } = await requireInsider(context, params, "manage_integrations"); | |
| 19 | + | const mirror = repo.mirror ?? null; | |
| 20 | + | // The hand-back plan asks the remote about every branch, so it is read | |
| 21 | + | // only when someone asks to review it, and only while g1t has taken over. | |
| 22 | + | const planRequested = new URL(request.url).searchParams.has("plan") && mirror?.state === "takeover"; | |
| 23 | + | if (planRequested && viewer) { | |
| 24 | + | const planned = await mirrors.plan(viewer, repo.id); | |
| 25 | + | if (planned.ok) return { mirror, view: planned.value, planRequested, planError: null }; | |
| 26 | + | return { mirror, view: unwrap(await mirrors.view(viewer, repo.id)), planRequested, planError: planned.error.message }; | |
| 27 | + | } | |
| 28 | + | return { mirror, view: unwrap(await mirrors.view(viewer, repo.id)), planRequested, planError: null }; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | export async function action({ request, params, context }: Route.ActionArgs): Promise<MirrorOutcome> { | |
| 32 | + | assertSameOrigin(request); | |
| 33 | + | const user = requireUser(context, request); | |
| 34 | + | const form = await request.formData(); | |
| 35 | + | const intent = String(form.get("intent") ?? ""); | |
| 36 | + | const failed = (error: string): MirrorOutcome => ({ intent, ok: false, message: null, error }); | |
| 37 | + | const [refused, found] = await Promise.all([ | |
| 38 | + | refusal(context, params, "manage_integrations"), | |
| 39 | + | repos.get({ namespace: params.owner, name: params.repo }, user), | |
| 40 | + | ]); | |
| 41 | + | if (refused) return failed(refused); | |
| 42 | + | if (!found.ok) return failed(found.error.message); | |
| 43 | + | const repoId = found.value.id; | |
| 44 | + | const done = (result: Result<MirrorView | unknown>, message: string | null): MirrorOutcome => | |
| 45 | + | result.ok | |
| 46 | + | ? { | |
| 47 | + | intent, | |
| 48 | + | ok: true, | |
| 49 | + | message, | |
| 50 | + | error: null, | |
| 51 | + | notes: (result.value as Partial<MirrorView> | null)?.notes ?? [], | |
| 52 | + | } | |
| 53 | + | : failed(result.error.message); | |
| 54 | + | switch (intent) { | |
| 55 | + | case "take-over": | |
| 56 | + | return done(await mirrors.takeOver(user, repoId), "g1t leads now."); | |
| 57 | + | case "ci-on": | |
| 58 | + | return done(await mirrors.ci(user, repoId, true), "CI failover is on."); | |
| 59 | + | case "ci-off": | |
| 60 | + | return done(await mirrors.ci(user, repoId, false), "CI failover is off."); | |
| 61 | + | case "sync": | |
| 62 | + | return done(await mirrors.sync(user, repoId), "Synced."); | |
| 63 | + | case "hand-back": | |
| 64 | + | return done(await mirrors.handBack(user, repoId, decisionsFrom(form.entries())), "Handing back."); | |
| 65 | + | case "move-in": | |
| 66 | + | return done(await mirrors.moveIn(user, repoId, form.get("keepRemoteUpdated") === "on"), "Moved to g1t."); | |
| 67 | + | case "settings": { | |
| 68 | + | // The settings the form does not carry keep their values. | |
| 69 | + | const view = await mirrors.view(user, repoId); | |
| 70 | + | if (!view.ok) return failed(view.error.message); | |
| 71 | + | const remote = view.value.remotes.find((one) => one.id === String(form.get("remoteId") ?? "")); | |
| 72 | + | if (!remote) return failed("That remote is no longer linked to this repository."); | |
| 73 | + | return done(await mirrors.settings(user, remote.id, mirrorSettingsFrom(form, remote.settings)), "Saved."); | |
| 74 | + | } | |
| 75 | + | case "add": { | |
| 76 | + | const text = (name: string) => String(form.get(name) ?? "").trim() || null; | |
| 77 | + | const input: MirrorAddInput = { | |
| 78 | + | provider: form.get("provider") === "g1t" ? "g1t" : "git", | |
| 79 | + | role: form.get("role") === "leader" ? "leader" : "follower", | |
| 80 | + | url: text("url") ?? "", | |
| 81 | + | username: text("username"), | |
| 82 | + | token: text("token"), | |
| 83 | + | }; | |
| 84 | + | const added = await mirrors.add(user, repoId, input); | |
| 85 | + | return added.ok ? { intent, ok: true, message: `Added ${added.value.name}.`, error: null } : failed(added.error.message); | |
| 86 | + | } | |
| 87 | + | case "remove": | |
| 88 | + | return done(await mirrors.remove(user, String(form.get("remoteId") ?? "")), "Removed."); | |
| 89 | + | } | |
| 90 | + | return failed("Unknown action."); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | export default function Mirroring({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 94 | + | const base = `/${params.owner}/${params.repo}`; | |
| 95 | + | return ( | |
| 96 | + | <> | |
| 97 | + | <RepoSettingsHeading base={base} /> | |
| 98 | + | <MirroringSettings | |
| 99 | + | base={base} | |
| 100 | + | full={`${params.owner}/${params.repo}`} | |
| 101 | + | mirror={loaderData.mirror} | |
| 102 | + | view={loaderData.view} | |
| 103 | + | planRequested={loaderData.planRequested} | |
| 104 | + | planError={loaderData.planError} | |
| 105 | + | result={actionData} | |
| 106 | + | /> | |
| 107 | + | </> | |
| 108 | + | ); | |
| 109 | + | } |
| 134 | 134 | </Link> | |
| 135 | 135 | </div> | |
| 136 | 136 | <p className="mt-3 text-xs text-faint"> | |
| 137 | − | Mirroring a repository from GitHub, GitLab or Bitbucket as a project's source is coming next. | |
| 137 | + | To keep it in step with a copy on another host, either way round, see{" "} | |
| 138 | + | <Link to={`${base}/settings/mirroring`} className="text-muted hover:text-fg"> | |
| 139 | + | Mirroring | |
| 140 | + | </Link> | |
| 141 | + | . | |
| 138 | 142 | </p> | |
| 139 | 143 | </div> | |
| 140 | 144 | )} |
| 247 | 247 | </Link> | |
| 248 | 248 | <Badge>{repo.isPrivate ? "private" : "public"}</Badge> | |
| 249 | 249 | {repo.archivedAt && <Badge tone="warn">archived</Badge>} | |
| 250 | + | {repo.mirror && ( | |
| 251 | + | <Badge tone={repo.mirror.state === "takeover" || repo.mirror.state === "handing_back" ? "warn" : "neutral"}> | |
| 252 | + | {repo.mirror.state === "takeover" ? "taken over" : repo.mirror.state === "handing_back" ? "handing back" : "mirror"} | |
| 253 | + | </Badge> | |
| 254 | + | )} | |
| 250 | 255 | </div> | |
| 251 | 256 | {repo.description && <p className="mt-0.5 line-clamp-2 text-sm text-muted">{repo.description}</p>} | |
| 252 | 257 | <p className="mt-1 text-xs text-faint"> |
| 413 | 413 | pub name: String, | |
| 414 | 414 | pub state: RemoteState, | |
| 415 | 415 | pub reachable: bool, | |
| 416 | + | /// When g1t last copied from it or pushed to it. | |
| 417 | + | #[serde(default)] | |
| 418 | + | pub synced_at: Option<String>, | |
| 416 | 419 | } | |
| 417 | 420 | ||
| 418 | 421 | /// The payload of the `mirror.*` events: `mirror.unreachable` (a mirror's |
| 2 | 2 | ||
| 3 | 3 | > **2026-10-08:** "bidirectional synchronization from GitHub or whatever | |
| 4 | 4 | > providers … as well as setting up a different remote (think self-hosted g1t | |
| 5 | − | > and mirroring to the hosted g1t.sh platform) so that it will keep remotes in | |
| 6 | − | > sync with each other." And: "GitHub is down, I want to run GitHub's | |
| 7 | − | > workflows on g1t, but only until GitHub is back up. But what if I push on | |
| 8 | − | > g1t?" And: "It needs to be highly clear when a repository is mirrored, and | |
| 9 | − | > things are disabled … It should still work as a repository when it's in | |
| 10 | − | > mirror mode." | |
| 11 | − | ||
| 12 | − | This replaces the three loose modes shipped in Projects step 5 (import, | |
| 13 | − | mirror, push, see PLAN.md "Projects") with one model that has a clear answer | |
| 14 | − | for every push, wherever it lands. | |
| 15 | − | ||
| 16 | − | ## What exists today | |
| 17 | − | ||
| 18 | − | - GitHub only, through the `g1t-sh` App. `github_repos.mode` is one of | |
| 19 | − | `import | mirror | push` (integrations `0003_github.sql`). | |
| 20 | − | - **Mirror** pulls on GitHub's push webhook and calls `mirror::copy` with | |
| 21 | − | `Prune::Yes`, which *overwrites* g1t's refs and deletes g1t-only branches. | |
| 22 | − | Anything pushed to g1t is silently lost at the next sync. | |
| 23 | − | - **Push** ("Move to g1t") forwards each `git.push` to GitHub. A ref GitHub | |
| 24 | − | refuses ends up in `last_error`, and nothing reconciles it. | |
| 25 | − | - Mirrored and imported refs publish `git.push` with no actor. They start | |
| 26 | − | `.g1t/workflows` like any push. They are written straight to the store, so | |
| 27 | − | they skip `workflow_gate`. | |
| 28 | − | - `.github/workflows` is never read, except as a reusable `uses:` target. | |
| 29 | − | - No provider port. `Endpoint::github` and `https://github.com/{full_name}.git` | |
| 30 | − | are hard-coded. `ProjectSource { kind: "mirror", provider }` is declared but | |
| 31 | − | unused. | |
| 5 | + | > and mirroring to the hosted g1t.sh platform)". "GitHub is down, I want to | |
| 6 | + | > run GitHub's workflows on g1t, but only until GitHub is back up. But what if | |
| 7 | + | > I push on g1t?" "It needs to be highly clear when a repository is mirrored, | |
| 8 | + | > and things are disabled." "When it's mirroring it's just mirroring … you | |
| 9 | + | > can use g1t as a disaster recovery option intentionally." "We really don't | |
| 10 | + | > want to force someone's hand." "When we've decided that we've moved the | |
| 11 | + | > repository entirely to g1t and it's no longer a mirror … mention that we'll | |
| 12 | + | > no longer track the remote." | |
| 32 | 13 | ||
| 33 | − | The first three behaviours are the bugs this plan fixes. Pushes are lost | |
| 34 | − | silently, divergence goes undetected, and anyone who can push to GitHub can | |
| 35 | − | change a workflow that runs with g1t's secrets. | |
| 14 | + | The user-facing guide is `apps/docs/src/content/docs/guides/mirroring.md`. | |
| 15 | + | This is the design record. | |
| 36 | 16 | ||
| 37 | 17 | ## The rule | |
| 38 | 18 | ||
| 39 | − | **Every linked repository has exactly one leader at any moment. A write | |
| 40 | − | reaches the leader first, or it doesn't land. The leader's word is final.** | |
| 19 | + | **Every linked repository has exactly one leader. Work happens on the | |
| 20 | + | leader; the others follow.** g1t is never a second place where people work | |
| 21 | + | while the remote also leads, so there's nothing to merge back by surprise. | |
| 22 | + | Every state is an answer to "who leads right now?": | |
| 41 | 23 | ||
| 42 | − | Everything else follows from that rule: | |
| 43 | − | ||
| 44 | − | - *Two-way sync* doesn't need its own mode. When g1t follows, a push to g1t is | |
| 45 | − | forwarded to the leader *before* g1t's ref moves (write-through). When g1t | |
| 46 | − | leads, a fast-forward push on the other side is adopted. Both sides accept | |
| 47 | − | pushes, and there is always a tie-breaker. | |
| 48 | − | - *Conflicts* can only happen when the leader was unreachable and someone | |
| 49 | − | wrote anyway (failover), or when a follower took a write it couldn't | |
| 50 | − | forward. A conflict is never resolved by overwriting silently. | |
| 51 | − | - *Echo loops* are impossible. When an update's new sha equals the tip | |
| 52 | − | already held, the update is a no-op. That covers a webhook announcing our | |
| 53 | − | own push and a chain of g1t instances alike. | |
| 54 | − | ||
| 55 | − | ## Words (UI and docs) | |
| 56 | − | ||
| 57 | − | | State | Badge on the repository | Meaning | | |
| 24 | + | | State | Leader | On g1t | | |
| 58 | 25 | | --- | --- | --- | | |
| 59 | − | | No link | (none) | An ordinary g1t repository. *Import* is a one-time copy that leaves no link. | | |
| 60 | − | | **Mirror** | `Mirror of github.com/acme/web` | The remote leads and g1t follows. Pushes to g1t are forwarded to the remote. | | |
| 61 | − | | **Mirrored** | `Mirrored to github.com/acme/web` (+ more) | g1t leads and the remotes follow. A repository can have any number of these. | | |
| 62 | − | | **Failover** | `Mirror of github.com/acme/web · Failover` (amber) | The leader is unreachable, so g1t is temporarily accepting writes. They are replayed when the leader returns. | | |
| 63 | − | | **Reconciling** | `… · 2 branches diverged` (red) | Some branches moved on both sides and need a decision. Other branches keep syncing. | | |
| 64 | − | ||
| 65 | − | A repository is a Mirror of **at most one** remote, and it can also be | |
| 66 | − | Mirrored to others. Self-hosted g1t can be a Mirror of GitHub and Mirrored | |
| 67 | − | to g1t.sh, for example. Creating a link that would make a cycle is refused. | |
| 68 | − | For g1t-to-g1t links we can ask the other side for its leader chain. | |
| 69 | − | ||
| 70 | − | The existing modes map as follows: `mirror` → Mirror, `push` → Mirrored, | |
| 71 | − | `import` → no link. | |
| 72 | − | ||
| 73 | − | ## Pushes, case by case | |
| 74 | − | ||
| 75 | − | ### When g1t is a Mirror (the remote leads) | |
| 76 | − | ||
| 77 | − | | Where the push happens | What happens | | |
| 78 | − | | --- | --- | | |
| 79 | − | | On the remote | The webhook arrives (or a poll runs when there is no webhook), g1t fetches, and the ref moves. A force-push by the leader is followed, because the leader may rewrite history. The old tip is kept at `refs/g1t/replaced/<branch>/<time>` for 30 days, and the change shows in the activity feed. Nothing is lost silently. | | |
| 80 | − | | On g1t (person, agent, merge button, web edit) | **Write-through.** g1t receives the pack, pushes it to the leader with a compare-and-swap (expected old = our tip), and moves its own ref only when the leader accepts. If the leader refuses (branch protection, non-fast-forward, a hook), the push fails with the leader's own message passed through as `remote:` lines. Branch protection on the remote is enforced for free. | | |
| 81 | − | | On g1t, branch only on g1t | There are no g1t-only branches on a Mirror. Every branch is forwarded. This removes the current `Prune::Yes` data loss: g1t never holds anything the leader doesn't. | | |
| 82 | − | | On g1t, leader unreachable | Depends on the failover setting (below). The default refuses with: `acme/web is a mirror of github.com/acme/web, which isn't answering. Pushes resume when it's back, or turn on failover in Settings → Mirroring.` | | |
| 83 | − | ||
| 84 | − | The cost is latency: a push to a Mirror takes as long as a push to the | |
| 85 | − | remote plus our own write. That trade buys g1t's commits never diverging | |
| 86 | − | while the leader is up. The 40 MB pack relay limit in `mirror.rs` applies to | |
| 87 | − | forwarded pushes as well, and the error message has to say so. | |
| 88 | − | ||
| 89 | − | ### When g1t is Mirrored (g1t leads) | |
| 90 | − | ||
| 91 | − | | Where the push happens | What happens | | |
| 92 | − | | --- | --- | | |
| 93 | − | | On g1t | It's g1t's normal push with all of g1t's rules. After it lands, a `git.push` event queues a forward to each follower (this exists today). A follower that refuses (its own protection, for example) marks that ref **stuck** on that remote and shows it on the repository. Retries back off and never force. | | |
| 94 | − | | On the remote, fast-forward | **Default: adopt.** The webhook triggers a fetch, and the update goes through g1t's ref rules as if the pusher had pushed to g1t, with the pusher mapped to a g1t account (see Actors). If g1t's rules allow it, the ref moves and the update is forwarded to the other followers. If they refuse (a protected branch, say), the ref is marked **diverged**. | | |
| 95 | − | | On the remote, not a fast-forward | Marked **diverged**. g1t never force-pushes over it without a person's or agent's decision. | | |
| 96 | − | ||
| 97 | − | A setting covers pushes made directly on the remote: | |
| 98 | − | ||
| 99 | − | - **Adopt fast-forwards** (default) | |
| 100 | − | - **Overwrite them**: g1t force-pushes its tip and keeps the replaced | |
| 101 | − | remote tip under `refs/g1t/replaced/…`. This suits teams that consider the | |
| 102 | − | remote read-only. | |
| 103 | − | - **Lock the remote**: g1t creates a ruleset on the GitHub repository that | |
| 104 | − | lets only the g1t App update refs. This needs `administration: write` on | |
| 105 | − | the App, which we request only when someone picks this option. | |
| 106 | − | ||
| 107 | − | ### Diverged branches | |
| 108 | − | ||
| 109 | − | A diverged branch stops syncing and every other branch keeps going. Both | |
| 110 | − | tips are kept: g1t's at the branch, and the other side's at | |
| 111 | − | `refs/remotes/<remote>/<branch>`, which is browsable and diffable. The | |
| 112 | − | repository shows `main diverged from github.com/acme/web: 3 commits here, 1 | |
| 113 | − | there` with three actions: | |
| 26 | + | | `standby` | remote | Exact, read-only copy. Runs nothing (optional: keep CI warm). | | |
| 27 | + | | `ci` | remote (code) | The remote's workflows run on g1t; results stay on g1t for now. | | |
| 28 | + | | `takeover` | g1t, for now | Everything works. Deploying workflows wait for approval. | | |
| 29 | + | | `handing_back` | moving back | Read-only while refs move. | | |
| 30 | + | | moved to g1t | g1t | Not a mirror. g1t no longer tracks the remote, or keeps it as a follower. | | |
| 31 | + | | `following` / `stuck` | g1t | A follower remote, pushed to on every `git.push`. | | |
| 114 | 32 | ||
| 115 | − | 1. **Keep g1t's**, which force-pushes to the other side (the old tip is kept). | |
| 116 | − | 2. **Keep theirs**, which moves g1t's ref (the old tip is kept). | |
| 117 | − | 3. **Merge them**, which opens a pull request merging the other tip into the | |
| 118 | − | branch. One click hands it to @g1t, and it lands through the normal rules. | |
| 33 | + | Outside a takeover, every commit on a mirror's branches is already on the | |
| 34 | + | remote. So a standby mirror is safe to treat as a backup, and any | |
| 35 | + | difference between the two is a bug. | |
| 119 | 36 | ||
| 120 | − | ## Failover: GitHub is down, and I push on g1t | |
| 37 | + | ## Decisions taken | |
| 121 | 38 | ||
| 122 | − | Failover is a *state* of a Mirror, not a separate mode. The setting | |
| 123 | − | "When github.com/acme/web is unreachable" has three choices: | |
| 39 | + | 1. **A mirror is a standby by default.** No issues, pull requests, agents, | |
| 40 | + | workflows or deployments, so mirroring is just mirroring. Settings, | |
| 41 | + | rules, webhooks and reported checks stay open. | |
| 42 | + | 2. **Detection shows; it doesn't act.** Hosts are probed every minute. A | |
| 43 | + | host is unreachable after 3 failures over at least 2 minutes, and back | |
| 44 | + | after 3 successes over at least 5. By default this only turns the | |
| 45 | + | repository's dot amber and offers **Take over**. Telling owners in the | |
| 46 | + | inbox, and taking over automatically after N minutes, are opt-in | |
| 47 | + | settings per link. | |
| 48 | + | 3. **Take over any time**, not only during a detected outage. | |
| 49 | + | 4. **Hand back is a reviewed plan**, ref by ref, against the commit each | |
| 50 | + | ref had when the takeover began: | |
| 51 | + | - `push`: only g1t moved. | |
| 52 | + | - `fetch`: only the remote moved. | |
| 53 | + | - `pull_request`: the remote protects the branch; the commits go to | |
| 54 | + | `g1t/handback/<branch>` and g1t follows the remote's branch. | |
| 55 | + | - `diverged`: both moved; a person decides keep ours, keep theirs or | |
| 56 | + | pull request. | |
| 124 | 57 | ||
| 125 | − | - **Stop accepting pushes** (default for new links) | |
| 126 | − | - **Ask me**: a banner and an inbox item for repository admins offer | |
| 127 | − | **Start failover**. | |
| 128 | − | - **Fail over automatically** | |
| 58 | + | If anything is refused, g1t keeps the lead and says why. An automatic | |
| 59 | + | hand-back happens only for takeovers g1t started itself, and only when | |
| 60 | + | no ref has diverged. | |
| 61 | + | 5. **Nothing is lost silently.** When a pull would drop a commit (a | |
| 62 | + | force-push or deletion on the remote, or a keep-theirs decision), that | |
| 63 | + | commit is kept at `refs/g1t/replaced/<ref>/<ms>` for at least 30 days. | |
| 64 | + | 6. **CI failover is its own switch.** It runs `.github/workflows` as well | |
| 65 | + | as `.g1t/workflows`; g1t's wins a `name:`. | |
| 66 | + | 7. **Move to g1t** is permanent. The confirmation says g1t will no longer | |
| 67 | + | track the remote. Optionally the remote becomes a follower. | |
| 68 | + | 8. **Security.** | |
| 69 | + | - A push copied in that changes `.g1t/`, `.github/workflows/` or | |
| 70 | + | `.github/actions/` starts runs that wait for approval before they can | |
| 71 | + | use secrets. | |
| 72 | + | - Agents never move a repository to g1t, nor add, change or remove a | |
| 73 | + | remote. | |
| 129 | 74 | ||
| 130 | − | **Entering failover.** The leader is unreachable when forwarded writes fail | |
| 131 | − | with a timeout, a connection error or a 5xx, *and* a health probe of | |
| 132 | − | `info/refs` fails 3 times over 2 minutes. A provider status page alone isn't | |
| 133 | − | enough, because they lag and over-report. While in failover: | |
| 75 | + | ## Where it lives | |
| 134 | 76 | ||
| 135 | − | - g1t accepts pushes, merges and agent work on every branch. Branch rules | |
| 136 | − | still apply: g1t imports a read-only copy of the remote's protection when | |
| 137 | − | the link is made, refreshes it on every sync, and enforces it during | |
| 138 | − | failover. "Main needs a reviewed pull request" still holds while GitHub is | |
| 139 | − | down. | |
| 140 | − | - Each ref records its **base**, the last sha both sides agreed on. | |
| 141 | − | - The repository wears the amber Failover badge with the duration and the | |
| 142 | − | count of commits waiting to go back. | |
| 77 | + | - **`crates/contracts/src/mirrors.rs`** (and `packages/contracts/src/mirrors.ts`): | |
| 78 | + | - the model: `MirrorState`, `RepoMirror` on `Repo`, `Remote`, | |
| 79 | + | `MirrorSettings`; | |
| 80 | + | - the hand-back logic: `ref_action` and `HandbackPlan`; | |
| 81 | + | - `MirrorEvent`. | |
| 82 | + | - **repos** (`src/mirror.rs`, migration 0017 `repos.mirror`): | |
| 83 | + | - `set_mirror`; | |
| 84 | + | - `mirror`, which now also keeps replaced commits and announces pushes | |
| 85 | + | as `mirrored`; | |
| 86 | + | - `mirror_refs`, which returns g1t's side alone when there is no URL; | |
| 87 | + | - `mirror_apply`, which moves named refs with a compare-and-swap; | |
| 88 | + | - `read_only_refusal` at every write path: the git front door, the | |
| 89 | + | commit API, merges, catch-up, releases and pull-request copies. | |
| 90 | + | - **integrations** (`src/remotes.rs`, migration 0006 `remotes`, | |
| 91 | + | `remote_refs`, `remote_hosts`; cron every minute; `EVENTS` binding): | |
| 92 | + | - the links; | |
| 93 | + | - the provider adapters: `github` through the App, `g1t` and `git` with | |
| 94 | + | a sealed token, and polling every 5 minutes for hosts without webhooks; | |
| 95 | + | - takeover, CI, hand-back, move-in, health and the automatic levers. | |
| 96 | + | - `github_repos` mirror and push rows became remotes in the migration; | |
| 97 | + | the GitHub webhook's pushes go to remotes. | |
| 98 | + | - **work**: `retired::writable` refuses on a read-only mirror; | |
| 99 | + | `not_archived` is kept for settings, rulesets and commit checks. | |
| 100 | + | - **actions** (`src/mirrored.rs`): what runs per state, `.github` reading, | |
| 101 | + | holding deploys, approval for copied workflow changes. | |
| 102 | + | - **deployments**: production deploys only while g1t leads. | |
| 103 | + | - **events**: `mirror.*` in the inbox (only the people named in `notify`) | |
| 104 | + | and in the webhook catalogue. | |
| 105 | + | - **api** (`src/mirrors.rs`): | |
| 106 | + | - REST under `/repos/{owner}/{name}/mirror…`; | |
| 107 | + | - MCP `mirror_*` actions on the `repository` tool; | |
| 108 | + | - scopes: `repo:read` to read, `code:write` to sync, `repo:admin` for | |
| 109 | + | everything else. | |
| 110 | + | - **web**: the badge, banner, disabled states, clone note and | |
| 111 | + | **Settings → Mirroring**. | |
| 143 | 112 | ||
| 144 | − | **Leaving failover.** Once the probe succeeds 3 times over 5 minutes, each | |
| 145 | − | branch changed during failover is replayed: | |
| 113 | + | ## Not yet | |
| 146 | 114 | ||
| 147 | − | | Remote since base | g1t since base | Result | | |
| 148 | − | | --- | --- | --- | | |
| 149 | − | | unchanged | moved | Fast-forward push to the remote. | | |
| 150 | − | | moved | unchanged | Fetch, as normal. | | |
| 151 | − | | moved | moved, one contains the other | Fast-forward whichever side is behind. | | |
| 152 | − | | moved | moved, split | **Diverged** (above). | | |
| 153 | − | | (remote refuses: protected branch) | moved | **Replayed as a pull request on GitHub** from `g1t/failover/<branch>`, titled *Changes made on g1t while GitHub was unreachable*. g1t can't push straight to a protected main, and shouldn't. | | |
| 154 | − | ||
| 155 | − | The repository goes back to a plain Mirror when nothing is diverged or | |
| 156 | − | stuck. Until then it shows Reconciling. An admin can end failover by hand at | |
| 157 | − | any time, and the replay runs the same way. | |
| 158 | − | ||
| 159 | − | ## Workflows | |
| 160 | − | ||
| 161 | − | ### Which files run on g1t | |
| 162 | − | ||
| 163 | − | | Files | Default | Setting | | |
| 164 | − | | --- | --- | --- | | |
| 165 | − | | `.g1t/workflows` | Run on every push, whatever its origin | None needed. These are g1t's files: having them means you want them run. Workflows can filter on `g1t.event.origin` (`local` / `remote`). | | |
| 166 | − | | `.github/workflows` | **Off** | **Run GitHub's workflows on g1t: Off · While GitHub Actions is down · Always.** | | |
| 167 | − | ||
| 168 | − | This deliberately narrows the rule in `crates/actions/src/workflow.rs` ("g1t | |
| 169 | − | never reads `.github`"). g1t reads `.github/workflows` only for repositories | |
| 170 | − | linked to GitHub, and only when this setting is on. When both folders define | |
| 171 | − | the same `name:`, `.g1t` wins. | |
| 172 | − | ||
| 173 | − | ### "While GitHub Actions is down" | |
| 174 | − | ||
| 175 | − | This is the scenario from the request. The window opens when either of these | |
| 176 | − | happens: | |
| 177 | − | ||
| 178 | − | 1. A pushed commit gets no check suite on GitHub within 10 minutes. This | |
| 179 | − | needs `checks: read` on the App and is the most reliable signal, because it | |
| 180 | − | means GitHub really didn't run it. | |
| 181 | − | 2. Someone with admin on the repository clicks **GitHub Actions is down: run | |
| 182 | − | here** on the repository or on a single commit. A per-commit **Run on g1t** | |
| 183 | − | button is always available, in any setting. | |
| 184 | − | ||
| 185 | − | GitHub's status page is shown next to the banner for context. It never opens | |
| 186 | − | or closes the window by itself. | |
| 187 | − | ||
| 188 | − | The window closes once GitHub starts check suites again for new commits. Runs | |
| 189 | − | already started on g1t finish. | |
| 190 | − | ||
| 191 | − | While the window is open: | |
| 192 | − | ||
| 193 | − | - Pushes and pull requests on g1t, **including failover pushes**, run the | |
| 194 | − | `.github` workflows that match. | |
| 195 | − | - Commits pushed during the window that GitHub never ran are **backfilled**: | |
| 196 | − | g1t offers to run them in one click. | |
| 197 | − | - Results go back to GitHub as check runs named `g1t / <workflow> / | |
| 198 | − | <job>` (needs `checks: write`). People on GitHub see them, and GitHub's | |
| 199 | − | required-check rules can name them if the team chooses. | |
| 200 | − | ||
| 201 | − | **Deploys and other side effects.** A workflow running in two places can | |
| 202 | − | deploy twice. In "While down" mode, jobs that declare an `environment:` or | |
| 203 | − | use a secret named `*DEPLOY*`/`*TOKEN*` that only GitHub has are **held for | |
| 204 | − | approval** by default. The setting is "Side-effect jobs in GitHub's | |
| 205 | − | workflows: hold for approval (default) · run". In "Always" mode they run. | |
| 206 | − | That mode is for teams that have moved CI to g1t and keep GitHub for code | |
| 207 | − | review. | |
| 208 | − | ||
| 209 | − | **Secrets.** GitHub won't give out secret values. When the setting is turned | |
| 210 | − | on, g1t lists every `secrets.X` the `.github` workflows reference, ticks off | |
| 211 | − | the ones already set on the project, and blocks "Always" until each is set | |
| 212 | − | or marked "not needed". A run that hits a missing secret fails with | |
| 213 | − | `NPM_TOKEN is set on GitHub but not on g1t: add it in Project → Secrets`. | |
| 214 | − | It does not fail with an empty string. | |
| 215 | − | ||
| 216 | − | **After GitHub returns.** Failover commits replayed to GitHub start GitHub's | |
| 217 | − | own workflows there. That is expected, because GitHub's checks are what | |
| 218 | − | GitHub's rules ask for. g1t's check runs for the same sha are already posted, | |
| 219 | − | so reviewers see both. Deploy jobs held on g1t can be discarded once GitHub | |
| 220 | − | has deployed. | |
| 221 | − | ||
| 222 | − | ### Workflow changes that arrive from a remote | |
| 223 | − | ||
| 224 | − | Today a commit fetched from GitHub that edits `.g1t/workflows` runs with | |
| 225 | − | g1t's secrets, and its author never needed `workflow_files: write` on g1t. | |
| 226 | − | That gets fixed in step 1, before any of the rest: | |
| 227 | − | ||
| 228 | − | - A fetched push that changes workflow files, or `.github/workflows` while | |
| 229 | − | that setting is on, runs only if the pusher maps to a g1t account with | |
| 230 | − | `workflow_files: write` on the repository. | |
| 231 | − | - Otherwise its runs wait for approval: `Workflow changed on GitHub by | |
| 232 | − | @octo, who has no write access to workflows here. Approve run?` This is | |
| 233 | − | the same mechanism as runs from forks. | |
| 234 | − | ||
| 235 | − | ## Actors | |
| 236 | − | ||
| 237 | − | GitHub's push webhook names the pusher, and `github_accounts` already links | |
| 238 | − | GitHub users to g1t users. A fetched push is attributed to the linked g1t | |
| 239 | − | account, or to `github:<login>` (shown greyed out, not a g1t user) when there | |
| 240 | − | is none. Today such pushes have no actor. Webhooks, audit and the workflow | |
| 241 | − | `actor` all get the attributed value. | |
| 242 | − | ||
| 243 | − | ## What works on a Mirror, and what's disabled | |
| 244 | − | ||
| 245 | − | This table is also the source for the UI's disabled states. Every disabled | |
| 246 | − | control uses the shadcn Tooltip with the reason and the setting that would | |
| 247 | − | enable it. | |
| 248 | − | ||
| 249 | − | | | Mirror (remote leads) | Mirror in Failover | Mirrored (g1t leads) | | |
| 250 | − | | --- | --- | --- | --- | | |
| 251 | − | | Browse, clone, fetch, blame, search | ✓ | ✓ | ✓ | | |
| 252 | − | | Push branches and tags | ✓ forwarded to the remote | ✓ held, replayed later | ✓ | | |
| 253 | − | | Branch protection | The remote's (read-only copy shown) | The remote's copy, enforced by g1t | g1t's | | |
| 254 | − | | Pull requests | **The remote's.** Listed on g1t; *New pull request* and agents open them on the remote; *Merge* merges there through the API under its rules | g1t pull requests allowed. Each is replayed as a remote pull request when the remote returns | g1t's | | |
| 255 | − | | Merge queue, required g1t checks | Disabled: "Merges happen on GitHub, which leads this repository." | ✓ on g1t's copy of the rules | ✓ | | |
| 256 | − | | Issues, agents, plans | ✓ g1t's own. Agents push branches that are forwarded, and open pull requests on the remote | ✓ | ✓ | | |
| 257 | − | | `.g1t/workflows` | ✓ | ✓ | ✓ | | |
| 258 | − | | `.github/workflows` | Per setting | Per setting | Per setting (only if a follower is GitHub) | | |
| 259 | − | | Projects, deployments, previews, secrets | ✓ (the on-ramp) | ✓ | ✓ | | |
| 260 | − | | Releases | Read from the remote (later) | Held | g1t's, copied to followers (later) | | |
| 261 | − | | Rename, transfer | g1t side only. The link stays | Same | Same | | |
| 262 | − | | Delete branch, set default branch | Forwarded / read from the remote | Held | g1t's, forwarded | | |
| 263 | − | | Archive | Stops syncing. The link is kept and paused | Not allowed | Stops forwarding | | |
| 264 | − | | Unlink | Becomes an ordinary repository (choice: keep g1t-side failover commits) | Must reconcile first | Followers stop receiving | | |
| 265 | − | ||
| 266 | − | Other places that must show the state, not only the badge: | |
| 267 | − | ||
| 268 | − | - **Clone box:** `This is a mirror of github.com/acme/web. Pushes here are | |
| 269 | − | forwarded there.` | |
| 270 | − | - **Push output:** `remote: forwarded to github.com/acme/web (412 ms)`, so | |
| 271 | − | people learn the model the first time they push. | |
| 272 | − | - **Repository header:** the sync dot (`in sync · 40s ago`, `syncing`, | |
| 273 | − | `failover · 23m`, `2 diverged`, `stuck on gitlab.com/…`). It opens | |
| 274 | − | **Settings → Mirroring**, which lists every remote, each branch's state, | |
| 275 | − | the last 50 sync events and **Sync now**. | |
| 276 | − | - **Workspace and project lists:** a small mirror glyph with the leader's | |
| 277 | − | host. | |
| 278 | − | ||
| 279 | − | ## The provider port | |
| 280 | − | ||
| 281 | − | All of this lives behind one port, so GitLab, Bitbucket, plain git and other | |
| 282 | − | g1t instances are each an adapter. The Rust trait lives in | |
| 283 | − | `services/integrations`, because the connections live there: | |
| 284 | − | ||
| 285 | − | ```rust | |
| 286 | − | trait Remote { | |
| 287 | − | fn kind(&self) -> RemoteKind; // github | g1t | gitlab | bitbucket | git | |
| 288 | − | fn capabilities(&self) -> Capabilities; // webhooks, checks, lock, pull_requests, protection | |
| 289 | − | async fn endpoint(&self) -> Result<mirror::Endpoint>;// URL + fresh credential for mirror.rs | |
| 290 | − | async fn parse_event(&self, req: &Request) -> Result<Vec<RemoteRefChange>>; // verify + normalise | |
| 291 | − | async fn probe(&self) -> Health; // info/refs reachability | |
| 292 | − | async fn protection(&self) -> Result<Vec<RuleCopy>>; // read the remote's branch rules | |
| 293 | − | async fn lock(&self, on: bool) -> Result<()>; // optional | |
| 294 | − | async fn post_check(&self, sha: &str, run: &CheckRun) -> Result<()>; // optional | |
| 295 | − | async fn pull_requests(&self) -> Result<PullRequestPort>; // optional, step 7 | |
| 296 | − | } | |
| 297 | − | ``` | |
| 298 | − | ||
| 299 | − | - Each `Capabilities` flag that's off greys out the matching UI and setting. | |
| 300 | − | The UI never offers a lever the provider can't honour. | |
| 301 | − | - Providers without webhooks (plain `git`, or a self-hosted g1t behind a | |
| 302 | − | firewall) get a **poll** every 1 to 10 minutes with backoff, using | |
| 303 | − | `ls-remote` and comparing tips. The poll uses the same code path as the | |
| 304 | − | webhook. | |
| 305 | − | - **`g1t` adapter (self-hosted ↔ g1t.sh).** It authenticates with a | |
| 306 | − | fine-grained token on the other instance (`contents: write`, | |
| 307 | − | `webhooks: write`) and registers its own webhook there through our API. | |
| 308 | − | Because we control both ends, write-through works in either direction, and | |
| 309 | − | the cycle check can ask the other side for its chain. Issues and pull | |
| 310 | − | requests between g1t instances come later, over the public API. | |
| 311 | − | ||
| 312 | − | ## Data | |
| 313 | − | ||
| 314 | − | **integrations, `remotes`** (replaces `github_repos`; existing rows are | |
| 315 | − | migrated): | |
| 316 | − | - `id`, `repo_id`, `workspace`, `kind`, `url`, `role` (`leader` | | |
| 317 | − | `follower`), and `connection_id` (installation or token row). | |
| 318 | − | - `provider_ref`, provider-specific JSON (GitHub: `installation_id`, | |
| 319 | − | `github_repo_id`). | |
| 320 | − | - `settings` JSON: on-unreachable, remote-push policy, `.github` workflows, | |
| 321 | − | side-effect jobs. | |
| 322 | − | - `state` (`ok` | `failover` | `reconciling` | `paused` | `error`), | |
| 323 | − | `state_since`, `last_error`, `synced_at`. | |
| 324 | − | - A unique index on `(repo_id) WHERE role = 'leader'` enforces at most one | |
| 325 | − | leader. | |
| 326 | − | ||
| 327 | − | **repos, `ref_sync`** (per repository, inside the repository's store so it | |
| 328 | − | moves atomically with refs): | |
| 329 | − | - `remote_id`, `ref`, `base_sha` (last agreed), `remote_sha` (last seen | |
| 330 | − | there). | |
| 331 | − | - `state` (`ok` | `ahead` | `behind` | `held` | `diverged` | `stuck`), | |
| 332 | − | `updated_at`. | |
| 333 | − | ||
| 334 | − | **repos, repository row:** `mirror_of` (remote id or null), cached from | |
| 335 | − | integrations through a `remote.updated` event. The git front door, commit | |
| 336 | − | API and merge paths can then decide forward-or-refuse without an RPC. | |
| 337 | − | `lifecycle::archived_refusal` is the template for a matching | |
| 338 | − | `mirror::route(repo, ref)` used at the same call sites. | |
| 339 | − | ||
| 340 | − | **contracts, `GitPush`** gains `origin: { kind: "local" | "remote", | |
| 341 | − | remote_id?, provider?, pusher? }`. Actions, webhooks, audit and the inbox | |
| 342 | − | read it. `ProjectSource`'s unused `mirror` variant is dropped: a Mirror is | |
| 343 | − | still a hosted repository with a full copy, so its project stays `hosted`, as | |
| 344 | − | PLAN.md already decided. | |
| 345 | − | ||
| 346 | − | **Events:** `remote.linked`, `remote.updated`, `remote.unlinked`, | |
| 347 | − | `remote.failover_started`, `remote.failover_ended`, `ref.diverged`, | |
| 348 | − | `ref.reconciled`. These are in the public webhook catalogue, so people can | |
| 349 | − | build alerts on them. | |
| 350 | − | ||
| 351 | − | ## Build order | |
| 352 | − | ||
| 353 | − | 1. **Safety and the port.** The `Remote` trait with the GitHub adapter. Move | |
| 354 | − | `github_repos` to `remotes`. Add `ref_sync` with base shas. Add `origin` | |
| 355 | − | and the attributed actor on `GitPush`. Echo suppression by sha. Keep | |
| 356 | − | replaced tips instead of overwriting silently. Approval for workflow | |
| 357 | − | changes arriving from a remote. Badge, sync dot, and **Settings → | |
| 358 | − | Mirroring** (read-only state). | |
| 359 | − | 2. **Write-through Mirror.** Forward pushes from the git front door, the | |
| 360 | − | commit API, merges and agents. Pass the leader's refusals through. Apply | |
| 361 | − | the disabled-feature table in the UI. Read-only copy of the remote's | |
| 362 | − | protection. | |
| 363 | − | 3. **Mirrored with remote pushes handled.** Adopt / overwrite / lock, stuck | |
| 364 | − | refs, diverged refs with the three resolutions. | |
| 365 | − | 4. **Failover.** Probe, ask/automatic, enforcement of the protection copy, | |
| 366 | − | replay with the table above, pull-request replay for protected branches. | |
| 367 | − | 5. **GitHub's workflows on g1t.** The three-way setting, the | |
| 368 | − | missing-check-suite signal, *Run on g1t*, backfill, check runs back to | |
| 369 | − | GitHub, held side-effect jobs, the secrets checklist. | |
| 370 | − | 6. **g1t ↔ g1t.** The `g1t` adapter, polling, the cycle check, docs for | |
| 371 | − | self-hosted ↔ g1t.sh. | |
| 372 | − | 7. **Remote pull requests on Mirrors.** List, open, merge through the API, | |
| 373 | − | agents opening theirs there (already "Not yet" in PLAN.md step 5). | |
| 374 | − | 8. **GitLab, Bitbucket, plain git** adapters. | |
| 375 | − | ||
| 376 | − | Each step updates `guides/github.md` and a new `guides/mirroring.md` in the | |
| 377 | − | same change (docs standard). `guides/github.md`'s "anything pushed to the | |
| 378 | − | g1t copy directly is overwritten" goes away with step 1. | |
| 379 | − | ||
| 380 | − | ## Decisions to confirm | |
| 381 | − | ||
| 382 | − | 1. **Failover defaults to "Stop accepting pushes"** for new links, with | |
| 383 | − | "Ask me" one click away. Automatic failover is opt-in, because it creates | |
| 384 | − | work that has to be replayed. | |
| 385 | − | 2. **On a Mirror, pull requests live on the leader**, and g1t has no pull | |
| 386 | − | requests of its own there (except during failover). One place to merge | |
| 387 | − | avoids two review histories for one branch. Making g1t the leader is the | |
| 388 | − | way to get g1t's review and queue. | |
| 389 | − | 3. **App permissions are asked for when a feature needs them**: | |
| 390 | − | `checks: read/write` for step 5 and `administration: write` only for | |
| 391 | − | *Lock the remote*. They are not requested up front. | |
| 115 | + | - **Check runs posted back to GitHub** during CI failover. Needs | |
| 116 | + | `checks: write` on the App. | |
| 117 | + | - **Starting CI failover automatically** when GitHub starts no check suite | |
| 118 | + | for a pushed commit. Needs `checks: read`. | |
| 119 | + | - **Copying GitHub's branch protection** into g1t during a takeover. Today | |
| 120 | + | a takeover runs under g1t's own rules. | |
| 121 | + | - **Locking the remote** with a ruleset so only g1t pushes. Needs | |
| 122 | + | `administration: write`. | |
| 123 | + | - **GitHub's pull requests read into a mirror**, and agents opening theirs | |
| 124 | + | there. | |
| 125 | + | - **A working mirror** (write-through while GitHub leads). Deliberately | |
| 126 | + | left out; it could come back as one toggle on a standby mirror. | |
| 127 | + | - **GitLab and Bitbucket adapters**: one arm each in `remotes.rs` | |
| 128 | + | (`credential`, `protected`, `open_pull_request`), plus a webhook route. | |
| 129 | + | - **Taking in fast-forwards on a follower** skips g1t's branch rules on the | |
| 130 | + | copied push. A follower's pushes are already gated by the remote's own | |
| 131 | + | rules. |
| 1175 | 1175 | integrations' `github_repos`. Not yet: previews and statuses on GitHub's own | |
| 1176 | 1176 | pull requests, comments and pull requests copied, GitLab and Bitbucket. | |
| 1177 | 1177 | ||
| 1178 | + | **Mirroring, built 2026-10-08:** a mirror stands by as a read-only copy of | |
| 1179 | + | the remote that leads, until someone takes over on g1t, then hands back ref | |
| 1180 | + | by ref or moves it to g1t for good; CI failover runs the remote's | |
| 1181 | + | workflows on g1t. Links to GitHub, another g1t or any git host. Design and | |
| 1182 | + | what is not yet: [MIRRORING.md](MIRRORING.md). | |
| 1183 | + | ||
| 1178 | 1184 | ### People and search | |
| 1179 | 1185 | ||
| 1180 | 1186 | > **2026-10-04:** "pull up user profiles, using a /u/username prefix kinda |
| 119 | 119 | name: string; | |
| 120 | 120 | state: RemoteState; | |
| 121 | 121 | reachable: boolean; | |
| 122 | + | /** When g1t last copied from it or pushed to it. */ | |
| 123 | + | syncedAt?: string | null; | |
| 122 | 124 | }; | |
| 123 | 125 | ||
| 124 | 126 | export type MirrorAddInput = { |
| 535 | 535 | name: row.name.clone(), | |
| 536 | 536 | state: row.state(), | |
| 537 | 537 | reachable: hosts.get(&row.host()).is_none_or(|host| host.unreachable_since.is_none()), | |
| 538 | + | synced_at: row.synced_at.clone(), | |
| 538 | 539 | }) | |
| 539 | 540 | .collect()) | |
| 540 | 541 | } |