Merge branch 'mirroring' into artifacts-mode
| 16 | 16 | mod deploy_keys; | |
| 17 | 17 | mod limits; | |
| 18 | 18 | mod logs; | |
| 19 | + | mod mirrors; | |
| 19 | 20 | mod mcp; | |
| 20 | 21 | mod notifications; | |
| 21 | 22 | mod oauth; |
| 1 | + | //! Mirroring over REST and MCP: a repository's links to copies of it on | |
| 2 | + | //! other hosts (see `g1t_contracts::mirrors`), at | |
| 3 | + | //! `/repos/{owner}/{name}/mirror…`, and as the MCP `mirror` tool. | |
| 4 | + | //! | |
| 5 | + | //! The integrations service keeps the links and decides who may change | |
| 6 | + | //! them: the Admin role on the repository; syncing needs push. Agents never | |
| 7 | + | //! move a repository to g1t or handle a remote's token. | |
| 8 | + | ||
| 9 | + | use g1t_contracts::mirrors::{ | |
| 10 | + | MirrorActArgs, MirrorAddArgs, MirrorCiArgs, MirrorHandBackArgs, MirrorMoveInArgs, MirrorRemoveArgs, MirrorSettings, | |
| 11 | + | MirrorSettingsArgs, MirrorViewArgs, RefDecision, RemoteProvider, RemoteRole, | |
| 12 | + | }; | |
| 13 | + | use g1t_contracts::repos::{GetArgs, Repo}; | |
| 14 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 15 | + | use serde_json::{Value, json}; | |
| 16 | + | use std::collections::BTreeMap; | |
| 17 | + | use worker::Result; | |
| 18 | + | ||
| 19 | + | use crate::operations::{Services, repo_path}; | |
| 20 | + | ||
| 21 | + | /// One operation on a repository's mirroring. | |
| 22 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 23 | + | pub enum MirrorsOp { | |
| 24 | + | GetMirror, | |
| 25 | + | GetHandBackPlan, | |
| 26 | + | TakeOver, | |
| 27 | + | SetCiFailover, | |
| 28 | + | HandBack, | |
| 29 | + | MoveToG1t, | |
| 30 | + | SyncMirror, | |
| 31 | + | AddRemote, | |
| 32 | + | UpdateRemote, | |
| 33 | + | RemoveRemote, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl MirrorsOp { | |
| 37 | + | /// Every one: `Op::ALL` lists each as `Op::Mirrors(…)`, which a test | |
| 38 | + | /// checks against this. | |
| 39 | + | #[cfg(test)] | |
| 40 | + | pub const ALL: [MirrorsOp; 10] = [ | |
| 41 | + | MirrorsOp::GetMirror, | |
| 42 | + | MirrorsOp::GetHandBackPlan, | |
| 43 | + | MirrorsOp::TakeOver, | |
| 44 | + | MirrorsOp::SetCiFailover, | |
| 45 | + | MirrorsOp::HandBack, | |
| 46 | + | MirrorsOp::MoveToG1t, | |
| 47 | + | MirrorsOp::SyncMirror, | |
| 48 | + | MirrorsOp::AddRemote, | |
| 49 | + | MirrorsOp::UpdateRemote, | |
| 50 | + | MirrorsOp::RemoveRemote, | |
| 51 | + | ]; | |
| 52 | + | ||
| 53 | + | pub fn name(self) -> &'static str { | |
| 54 | + | match self { | |
| 55 | + | MirrorsOp::GetMirror => "get_mirror", | |
| 56 | + | MirrorsOp::GetHandBackPlan => "get_hand_back_plan", | |
| 57 | + | MirrorsOp::TakeOver => "take_over_mirror", | |
| 58 | + | MirrorsOp::SetCiFailover => "set_ci_failover", | |
| 59 | + | MirrorsOp::HandBack => "hand_back_mirror", | |
| 60 | + | MirrorsOp::MoveToG1t => "move_mirror_to_g1t", | |
| 61 | + | MirrorsOp::SyncMirror => "sync_mirror", | |
| 62 | + | MirrorsOp::AddRemote => "add_mirror_remote", | |
| 63 | + | MirrorsOp::UpdateRemote => "update_mirror_remote", | |
| 64 | + | MirrorsOp::RemoveRemote => "remove_mirror_remote", | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// For the API reference. | |
| 69 | + | pub fn title(self) -> &'static str { | |
| 70 | + | match self { | |
| 71 | + | MirrorsOp::GetMirror => "Get a repository's mirroring", | |
| 72 | + | MirrorsOp::GetHandBackPlan => "Get the hand-back plan", | |
| 73 | + | MirrorsOp::TakeOver => "Take over a mirror", | |
| 74 | + | MirrorsOp::SetCiFailover => "Start or end CI failover", | |
| 75 | + | MirrorsOp::HandBack => "Hand a takeover back", | |
| 76 | + | MirrorsOp::MoveToG1t => "Move a mirror to g1t", | |
| 77 | + | MirrorsOp::SyncMirror => "Sync a repository's remotes", | |
| 78 | + | MirrorsOp::AddRemote => "Add a remote", | |
| 79 | + | MirrorsOp::UpdateRemote => "Update a remote's settings", | |
| 80 | + | MirrorsOp::RemoveRemote => "Remove a remote", | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | pub fn description(self) -> &'static str { | |
| 85 | + | match self { | |
| 86 | + | MirrorsOp::GetMirror => "A repository's links to other hosts. `remotes` lists each with its `role` (`leader`: the remote leads and this repository is its mirror; `follower`: g1t leads and the remote is kept in step), `provider` (`github`, `g1t` or `git`), `name` (`github.com/acme/web`), `state` (a mirror is `standby`, `ci`, `takeover` or `handing_back`; a follower is `following` or `stuck`), `reachable` and `unreachable_since`, `synced_at`, `last_error` and its `settings`. `can_manage` says whether you may change them. A mirror standing by is read-only on g1t and runs nothing. Needs read access.", | |
| 87 | + | MirrorsOp::GetHandBackPlan => "What handing a takeover back would do, ref by ref, in `plan.refs`: each with `base` (where it was when the takeover began), `ours`, `theirs` and `action`: `push` (only g1t moved), `fetch` (only the remote moved), `pull_request` (the remote protects the branch: g1t's commits go to `g1t/handback/<branch>` there as a pull request, and g1t follows the remote's branch) or `diverged` (both moved: it needs a decision). `plan.reachable` is false while the remote does not answer; `plan.ready` once it answers and every diverged ref has a decision. Asks the remote, so it takes a moment. Needs the Admin role.", | |
| 88 | + | MirrorsOp::TakeOver => "Take over a mirror: g1t leads it for now, so pushes, pull requests, issues, agents and workflows work on g1t, whether or not the remote is answering. Each ref's commit is recorded as where the takeover began. Workflows that deploy wait for approval, unless the link's settings say otherwise. End it with hand_back_mirror, or keep it with move_mirror_to_g1t. Needs the Admin role.", | |
| 89 | + | MirrorsOp::SetCiFailover => "Start (`on: true`) or end (`on: false`) CI failover on a mirror standing by: the remote keeps the code, and g1t runs its workflows, `.github/workflows` as well as `.g1t/workflows`, on each push copied in. Workflows that deploy wait for approval unless the link's settings say otherwise. Needs the Admin role.", | |
| 90 | + | MirrorsOp::HandBack => "Hand a takeover back to the remote, as get_hand_back_plan describes, with `decisions` for diverged refs: an object from ref (`refs/heads/docs`) to `keep_ours` (g1t's commit is pushed over the remote's), `keep_theirs` (the remote's is taken; g1t's is kept under `refs/g1t/replaced/`) or `pull_request`. Refused while the remote does not answer or a diverged ref has no decision. The repository is read-only while it goes back; if anything is refused, g1t keeps the lead and says why. On success the mirror stands by again, and `notes` lists the pull requests opened. Needs the Admin role.", | |
| 91 | + | MirrorsOp::MoveToG1t => "Move a mirror to g1t for good: it stops being a mirror and g1t no longer tracks the remote, so pushes made there no longer come here. Allowed while it stands by, in CI failover, or during a takeover, whose work stays as it is. With `keep_remote_updated: true` the remote becomes a follower instead of being unlinked, and g1t pushes to it from then on. Needs the Admin role; agents' tokens are refused.", | |
| 92 | + | MirrorsOp::SyncMirror => "Bring a repository's remotes in step now: a mirror standing by or in CI failover copies the remote's branches and tags in; each follower is pushed g1t's (which also clears a follower that was stuck by pushing over what changed there). Needs push access.", | |
| 93 | + | MirrorsOp::AddRemote => "Link a repository to a remote on another g1t or any git host over HTTPS (GitHub repositories are linked by importing them through g1t's GitHub App). `role` `follower`: g1t leads and pushes to it. `role` `leader`: this repository becomes its mirror; only an empty repository can, and it is filled from the remote. `url` is its https clone address, `token` a token that can read and push (kept sealed, never returned), `username` the user it is sent as. Needs the Admin role; agents' tokens are refused.", | |
| 94 | + | MirrorsOp::UpdateRemote => "Change a remote's `settings`, all optional: `notify` (`banner`, or `inbox` to also tell the workspace's owners), `take_over_after` (minutes, 5 to 1440, after which g1t takes over on its own while the remote does not answer; null for never), `hand_back` (`when_clean`: an automatic takeover goes back on its own once every ref goes back without a decision; `ask`), `keep_ci_warm` (run `.g1t/workflows` on pushes copied in while standing by), `github_workflows` (run `.github/workflows` in CI failover and takeovers), `hold_deploys`, and for a follower `remote_pushes` (`adopt` fast-forwards made there, or `overwrite` them). Needs the Admin role; agents' tokens are refused.", | |
| 95 | + | MirrorsOp::RemoveRemote => "Unlink a remote. A mirror becomes an ordinary repository with what it has; a follower is no longer pushed to. Refused during a takeover: hand it back or move it to g1t first. Needs the Admin role; agents' tokens are refused.", | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | pub fn input(self) -> Value { | |
| 100 | + | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 101 | + | let id = json!({ "type": "string", "description": "The remote's id (rmt_…), from get_mirror." }); | |
| 102 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 103 | + | MirrorsOp::GetMirror | MirrorsOp::GetHandBackPlan | MirrorsOp::TakeOver | MirrorsOp::SyncMirror => { | |
| 104 | + | (json!({ "repo": repo }), &["repo"]) | |
| 105 | + | } | |
| 106 | + | MirrorsOp::SetCiFailover => ( | |
| 107 | + | json!({ "repo": repo, "on": { "type": "boolean", "description": "True to start CI failover, false to end it." } }), | |
| 108 | + | &["repo", "on"], | |
| 109 | + | ), | |
| 110 | + | MirrorsOp::HandBack => ( | |
| 111 | + | json!({ | |
| 112 | + | "repo": repo, | |
| 113 | + | "decisions": { | |
| 114 | + | "type": "object", | |
| 115 | + | "description": "For each diverged ref, by its full name: keep_ours, keep_theirs or pull_request.", | |
| 116 | + | "additionalProperties": { "type": "string", "enum": ["keep_ours", "keep_theirs", "pull_request"] }, | |
| 117 | + | }, | |
| 118 | + | }), | |
| 119 | + | &["repo"], | |
| 120 | + | ), | |
| 121 | + | MirrorsOp::MoveToG1t => ( | |
| 122 | + | json!({ | |
| 123 | + | "repo": repo, | |
| 124 | + | "keep_remote_updated": { "type": "boolean", "description": "True to keep pushing to the remote from g1t; false (the default) to stop tracking it." }, | |
| 125 | + | }), | |
| 126 | + | &["repo"], | |
| 127 | + | ), | |
| 128 | + | MirrorsOp::AddRemote => ( | |
| 129 | + | json!({ | |
| 130 | + | "repo": repo, | |
| 131 | + | "provider": { "type": "string", "enum": ["g1t", "git"], "description": "Another g1t (g1t.sh or your own), or any git host." }, | |
| 132 | + | "role": { "type": "string", "enum": ["follower", "leader"], "description": "follower: g1t leads and pushes to it. leader: this empty repository becomes its mirror." }, | |
| 133 | + | "url": { "type": "string", "description": "Its https clone address, such as https://g1t.sh/acme/web.git." }, | |
| 134 | + | "username": { "type": "string", "description": "The user the token is sent as. x-access-token when left out." }, | |
| 135 | + | "token": { "type": "string", "description": "A token that can read and push. Kept sealed; never returned." }, | |
| 136 | + | }), | |
| 137 | + | &["repo", "provider", "role", "url", "token"], | |
| 138 | + | ), | |
| 139 | + | MirrorsOp::UpdateRemote => ( | |
| 140 | + | json!({ | |
| 141 | + | "repo": repo, | |
| 142 | + | "id": id, | |
| 143 | + | "notify": { "type": "string", "enum": ["banner", "inbox"] }, | |
| 144 | + | "take_over_after": { "type": ["integer", "null"], "minimum": 5, "maximum": 1440 }, | |
| 145 | + | "hand_back": { "type": "string", "enum": ["when_clean", "ask"] }, | |
| 146 | + | "keep_ci_warm": { "type": "boolean" }, | |
| 147 | + | "github_workflows": { "type": "boolean" }, | |
| 148 | + | "hold_deploys": { "type": "boolean" }, | |
| 149 | + | "remote_pushes": { "type": "string", "enum": ["adopt", "overwrite"] }, | |
| 150 | + | }), | |
| 151 | + | &["repo", "id"], | |
| 152 | + | ), | |
| 153 | + | MirrorsOp::RemoveRemote => (json!({ "repo": repo, "id": id }), &["repo", "id"]), | |
| 154 | + | }; | |
| 155 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | fn text(input: &Value, key: &str) -> String { | |
| 160 | + | input[key].as_str().map(str::trim).unwrap_or_default().to_owned() | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// A boolean as sent: JSON, or a word from a form. | |
| 164 | + | fn flag(value: &Value) -> Option<bool> { | |
| 165 | + | match value { | |
| 166 | + | Value::Bool(flag) => Some(*flag), | |
| 167 | + | Value::String(word) => match word.trim().to_ascii_lowercase().as_str() { | |
| 168 | + | "true" | "1" => Some(true), | |
| 169 | + | "false" | "0" => Some(false), | |
| 170 | + | _ => None, | |
| 171 | + | }, | |
| 172 | + | _ => None, | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// `settings` with what `input` changes, in `snake_case` as sent. | |
| 177 | + | pub fn settings_from(mut settings: MirrorSettings, input: &Value) -> std::result::Result<MirrorSettings, String> { | |
| 178 | + | let word = |key: &str| input.get(key).filter(|value| !value.is_null()).map(|value| value.as_str().unwrap_or_default()); | |
| 179 | + | if let Some(notify) = word("notify") { | |
| 180 | + | settings.notify = serde_json::from_value(json!(notify)).map_err(|_| "notify is banner or inbox.")?; | |
| 181 | + | } | |
| 182 | + | if let Some(value) = input.get("take_over_after") { | |
| 183 | + | settings.take_over_after = match value { | |
| 184 | + | Value::Null => None, | |
| 185 | + | value => Some(value.as_u64().map(|minutes| minutes as u32).ok_or("take_over_after is a number of minutes, or null.")?), | |
| 186 | + | }; | |
| 187 | + | } | |
| 188 | + | if let Some(hand_back) = word("hand_back") { | |
| 189 | + | settings.hand_back = serde_json::from_value(json!(hand_back)).map_err(|_| "hand_back is when_clean or ask.")?; | |
| 190 | + | } | |
| 191 | + | if let Some(remote_pushes) = word("remote_pushes") { | |
| 192 | + | settings.remote_pushes = serde_json::from_value(json!(remote_pushes)).map_err(|_| "remote_pushes is adopt or overwrite.")?; | |
| 193 | + | } | |
| 194 | + | for (key, field) in [ | |
| 195 | + | ("keep_ci_warm", &mut settings.keep_ci_warm), | |
| 196 | + | ("github_workflows", &mut settings.github_workflows), | |
| 197 | + | ("hold_deploys", &mut settings.hold_deploys), | |
| 198 | + | ] { | |
| 199 | + | if let Some(value) = input.get(key).filter(|value| !value.is_null()) { | |
| 200 | + | *field = flag(value).ok_or_else(|| format!("{key} is true or false."))?; | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | Ok(settings) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// `decisions` as sent: ref to `keep_ours`, `keep_theirs` or `pull_request`. | |
| 207 | + | pub fn decisions_from(input: &Value) -> std::result::Result<BTreeMap<String, RefDecision>, String> { | |
| 208 | + | let Some(decisions) = input.get("decisions").filter(|value| !value.is_null()) else { | |
| 209 | + | return Ok(BTreeMap::new()); | |
| 210 | + | }; | |
| 211 | + | let Some(decisions) = decisions.as_object() else { | |
| 212 | + | return Err("decisions is an object from ref to keep_ours, keep_theirs or pull_request.".to_owned()); | |
| 213 | + | }; | |
| 214 | + | decisions | |
| 215 | + | .iter() | |
| 216 | + | .map(|(git_ref, decision)| { | |
| 217 | + | let decision = serde_json::from_value(decision.clone()) | |
| 218 | + | .map_err(|_| format!("{git_ref}: say keep_ours, keep_theirs or pull_request."))?; | |
| 219 | + | let git_ref = if git_ref.starts_with("refs/") { git_ref.clone() } else { format!("refs/heads/{git_ref}") }; | |
| 220 | + | Ok((git_ref, decision)) | |
| 221 | + | }) | |
| 222 | + | .collect() | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | pub async fn run(op: MirrorsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 226 | + | let Some(path) = repo_path(input) else { | |
| 227 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 228 | + | }; | |
| 229 | + | let found: Outcome<Repo> = g1t_kit::call(&services.repos, "get", &GetArgs { path, viewer: viewer.clone() }).await?; | |
| 230 | + | let repo = match found { | |
| 231 | + | Outcome::Ok(repo) => repo, | |
| 232 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 233 | + | }; | |
| 234 | + | let integrations = &services.integrations; | |
| 235 | + | if op == MirrorsOp::GetMirror { | |
| 236 | + | return g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await; | |
| 237 | + | } | |
| 238 | + | let Some(actor) = viewer.clone() else { | |
| 239 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to change a repository's mirroring.")); | |
| 240 | + | }; | |
| 241 | + | let act = || MirrorActArgs { actor: actor.clone(), repo_id: repo.id.clone() }; | |
| 242 | + | let id = text(input, "id"); | |
| 243 | + | if matches!(op, MirrorsOp::UpdateRemote | MirrorsOp::RemoveRemote) && id.is_empty() { | |
| 244 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the remote by its id (rmt_…), from get_mirror.")); | |
| 245 | + | } | |
| 246 | + | match op { | |
| 247 | + | MirrorsOp::GetMirror => unreachable!("answered above"), | |
| 248 | + | MirrorsOp::GetHandBackPlan => g1t_kit::call(integrations, "mirror_hand_back_plan", &act()).await, | |
| 249 | + | MirrorsOp::TakeOver => g1t_kit::call(integrations, "mirror_take_over", &act()).await, | |
| 250 | + | MirrorsOp::SyncMirror => g1t_kit::call(integrations, "mirror_sync", &act()).await, | |
| 251 | + | MirrorsOp::SetCiFailover => { | |
| 252 | + | let Some(on) = flag(&input["on"]) else { | |
| 253 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Say on: true to start CI failover, or false to end it.")); | |
| 254 | + | }; | |
| 255 | + | g1t_kit::call(integrations, "mirror_ci", &MirrorCiArgs { actor, repo_id: repo.id, on }).await | |
| 256 | + | } | |
| 257 | + | MirrorsOp::HandBack => { | |
| 258 | + | let decisions = match decisions_from(input) { | |
| 259 | + | Ok(decisions) => decisions, | |
| 260 | + | Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)), | |
| 261 | + | }; | |
| 262 | + | g1t_kit::call(integrations, "mirror_hand_back", &MirrorHandBackArgs { actor, repo_id: repo.id, decisions }).await | |
| 263 | + | } | |
| 264 | + | MirrorsOp::MoveToG1t => { | |
| 265 | + | let keep_remote_updated = match &input["keep_remote_updated"] { | |
| 266 | + | Value::Null => false, | |
| 267 | + | value => match flag(value) { | |
| 268 | + | Some(keep) => keep, | |
| 269 | + | None => return Ok(Outcome::fail(FailureCode::Invalid, "keep_remote_updated is true or false.")), | |
| 270 | + | }, | |
| 271 | + | }; | |
| 272 | + | g1t_kit::call(integrations, "mirror_move_in", &MirrorMoveInArgs { actor, repo_id: repo.id, keep_remote_updated }).await | |
| 273 | + | } | |
| 274 | + | MirrorsOp::AddRemote => { | |
| 275 | + | let provider = match text(input, "provider").as_str() { | |
| 276 | + | "g1t" => RemoteProvider::G1t, | |
| 277 | + | "git" => RemoteProvider::Git, | |
| 278 | + | "github" => { | |
| 279 | + | return Ok(Outcome::fail( | |
| 280 | + | FailureCode::Invalid, | |
| 281 | + | "GitHub repositories are linked by importing them through g1t's GitHub App.", | |
| 282 | + | )); | |
| 283 | + | } | |
| 284 | + | _ => return Ok(Outcome::fail(FailureCode::Invalid, "provider is g1t or git.")), | |
| 285 | + | }; | |
| 286 | + | let role = match text(input, "role").as_str() { | |
| 287 | + | "leader" => RemoteRole::Leader, | |
| 288 | + | "follower" => RemoteRole::Follower, | |
| 289 | + | _ => return Ok(Outcome::fail(FailureCode::Invalid, "role is follower (g1t leads) or leader (the remote leads).")), | |
| 290 | + | }; | |
| 291 | + | let username = Some(text(input, "username")).filter(|name| !name.is_empty()); | |
| 292 | + | let token = Some(text(input, "token")).filter(|token| !token.is_empty()); | |
| 293 | + | let args = MirrorAddArgs { actor, repo_id: repo.id, provider, role, url: text(input, "url"), username, token }; | |
| 294 | + | g1t_kit::call(integrations, "mirror_add", &args).await | |
| 295 | + | } | |
| 296 | + | MirrorsOp::UpdateRemote => { | |
| 297 | + | let view: Outcome<g1t_contracts::mirrors::MirrorView> = | |
| 298 | + | g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?; | |
| 299 | + | let view = match view { | |
| 300 | + | Outcome::Ok(view) => view, | |
| 301 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 302 | + | }; | |
| 303 | + | let Some(remote) = view.remotes.into_iter().find(|remote| remote.id == id) else { | |
| 304 | + | return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's.")); | |
| 305 | + | }; | |
| 306 | + | let settings = match settings_from(remote.settings, input) { | |
| 307 | + | Ok(settings) => settings, | |
| 308 | + | Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)), | |
| 309 | + | }; | |
| 310 | + | g1t_kit::call(integrations, "mirror_settings", &MirrorSettingsArgs { actor, remote_id: id, settings }).await | |
| 311 | + | } | |
| 312 | + | MirrorsOp::RemoveRemote => { | |
| 313 | + | let view: Outcome<g1t_contracts::mirrors::MirrorView> = | |
| 314 | + | g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?; | |
| 315 | + | if !matches!(&view, Outcome::Ok(view) if view.remotes.iter().any(|remote| remote.id == id)) { | |
| 316 | + | return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's.")); | |
| 317 | + | } | |
| 318 | + | g1t_kit::call(integrations, "mirror_remove", &MirrorRemoveArgs { actor, remote_id: id }).await | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[cfg(test)] | |
| 324 | + | mod tests { | |
| 325 | + | use super::*; | |
| 326 | + | use g1t_contracts::credentials::NEVER; | |
| 327 | + | use g1t_contracts::mirrors::{HandBack, Notify, RemotePushes}; | |
| 328 | + | use g1t_contracts::scopes::{Level, scope_for}; | |
| 329 | + | ||
| 330 | + | #[test] | |
| 331 | + | fn each_operation_is_described_and_scoped() { | |
| 332 | + | for op in MirrorsOp::ALL { | |
| 333 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Mirrors(op)), "{}", op.name()); | |
| 334 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 335 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 336 | + | let level = scope_for(op.name()).unwrap_or_else(|| panic!("{} has no scope", op.name())).level(); | |
| 337 | + | let expected = match op { | |
| 338 | + | MirrorsOp::GetMirror => Level::Read, | |
| 339 | + | MirrorsOp::SyncMirror => Level::Write, | |
| 340 | + | _ => Level::Admin, | |
| 341 | + | }; | |
| 342 | + | assert_eq!(level, expected, "{}", op.name()); | |
| 343 | + | } | |
| 344 | + | for op in [MirrorsOp::MoveToG1t, MirrorsOp::AddRemote, MirrorsOp::UpdateRemote, MirrorsOp::RemoveRemote] { | |
| 345 | + | assert!(NEVER.contains(&op.name()), "agents never {}", op.name()); | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[test] | |
| 350 | + | fn settings_change_only_what_is_sent() { | |
| 351 | + | let base = MirrorSettings::default(); | |
| 352 | + | let changed = settings_from(base.clone(), &json!({ "notify": "inbox", "take_over_after": 30, "keep_ci_warm": "true" })).unwrap(); | |
| 353 | + | assert_eq!(changed.notify, Notify::Inbox); | |
| 354 | + | assert_eq!(changed.take_over_after, Some(30)); | |
| 355 | + | assert!(changed.keep_ci_warm); | |
| 356 | + | assert_eq!(changed.hand_back, HandBack::WhenClean); | |
| 357 | + | assert_eq!(changed.remote_pushes, RemotePushes::Adopt); | |
| 358 | + | let cleared = settings_from(changed, &json!({ "take_over_after": null })).unwrap(); | |
| 359 | + | assert_eq!(cleared.take_over_after, None); | |
| 360 | + | assert!(settings_from(base.clone(), &json!({ "notify": "pager" })).is_err()); | |
| 361 | + | assert!(settings_from(base, &json!({ "hold_deploys": "maybe" })).is_err()); | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | #[test] | |
| 365 | + | fn decisions_name_refs_or_branches() { | |
| 366 | + | let decisions = decisions_from(&json!({ "decisions": { "docs": "keep_theirs", "refs/tags/v1": "keep_ours" } })).unwrap(); | |
| 367 | + | assert_eq!(decisions.get("refs/heads/docs"), Some(&RefDecision::KeepTheirs)); | |
| 368 | + | assert_eq!(decisions.get("refs/tags/v1"), Some(&RefDecision::KeepOurs)); | |
| 369 | + | assert!(decisions_from(&json!({ "decisions": { "docs": "merge" } })).is_err()); | |
| 370 | + | assert!(decisions_from(&json!({})).unwrap().is_empty()); | |
| 371 | + | } | |
| 372 | + | } |
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | 11 | use crate::artifacts::ArtifactsOp; | |
| 12 | 12 | use crate::deploy_keys::DeployKeysOp; | |
| 13 | + | use crate::mirrors::MirrorsOp; | |
| 13 | 14 | use crate::deployments::DeploymentsOp; | |
| 14 | 15 | use crate::packages::PackagesOp; | |
| 15 | 16 | use crate::protection::ProtectionOp; | |
| ⋯ | |||
| 186 | 187 | ], | |
| 187 | 188 | ), | |
| 188 | 189 | ( | |
| 190 | + | "Mirroring", | |
| 191 | + | "A repository's links to copies of it on other hosts: a mirror stands by as a read-only copy of a remote that leads, until someone takes over on g1t and later hands back, or moves it to g1t for good. A repository g1t leads can be mirrored to other hosts.", | |
| 192 | + | &[ | |
| 193 | + | Op::Mirrors(MirrorsOp::GetMirror), | |
| 194 | + | Op::Mirrors(MirrorsOp::GetHandBackPlan), | |
| 195 | + | Op::Mirrors(MirrorsOp::TakeOver), | |
| 196 | + | Op::Mirrors(MirrorsOp::SetCiFailover), | |
| 197 | + | Op::Mirrors(MirrorsOp::HandBack), | |
| 198 | + | Op::Mirrors(MirrorsOp::MoveToG1t), | |
| 199 | + | Op::Mirrors(MirrorsOp::SyncMirror), | |
| 200 | + | Op::Mirrors(MirrorsOp::AddRemote), | |
| 201 | + | Op::Mirrors(MirrorsOp::UpdateRemote), | |
| 202 | + | Op::Mirrors(MirrorsOp::RemoveRemote), | |
| 203 | + | ], | |
| 204 | + | ), | |
| 205 | + | ( | |
| 189 | 206 | "Teams", | |
| 190 | 207 | "Groups of a workspace's members: given a role on repositories together, mentioned together as @workspace/team, and asked to review together. Any member may create a team; the workspace's owners and the team's maintainers manage it.", | |
| 191 | 208 | &[ | |
| ⋯ | |||
| 733 | 750 | Op::Tokens(op) => op.title(), | |
| 734 | 751 | Op::Artifacts(op) => op.title(), | |
| 735 | 752 | Op::DeployKeys(op) => op.title(), | |
| 753 | + | Op::Mirrors(op) => op.title(), | |
| 736 | 754 | Op::Packages(op) => op.title(), | |
| 737 | 755 | } | |
| 738 | 756 | } | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | 31 | use crate::artifacts::ArtifactsOp; | |
| 32 | 32 | use crate::deploy_keys::DeployKeysOp; | |
| 33 | + | use crate::mirrors::MirrorsOp; | |
| 33 | 34 | use crate::deployments::DeploymentsOp; | |
| 34 | 35 | use crate::packages::PackagesOp; | |
| 35 | 36 | use crate::protection::ProtectionOp; | |
| ⋯ | |||
| 314 | 315 | Artifacts(ArtifactsOp), | |
| 315 | 316 | /// A repository's deploy keys: deploy_keys.rs. | |
| 316 | 317 | DeployKeys(DeployKeysOp), | |
| 318 | + | /// A repository's mirroring: its remotes, takeovers and hand-backs: | |
| 319 | + | /// mirrors.rs. | |
| 320 | + | Mirrors(MirrorsOp), | |
| 317 | 321 | /// A workspace's packages, their versions, deleting and restoring | |
| 318 | 322 | /// them, and who may use them: packages.rs. | |
| 319 | 323 | Packages(PackagesOp), | |
| ⋯ | |||
| 686 | 690 | } | |
| 687 | 691 | ||
| 688 | 692 | impl Op { | |
| 689 | − | pub const ALL: [Op; 318] = [ | |
| 693 | + | pub const ALL: [Op; 328] = [ | |
| 690 | 694 | Op::Whoami, | |
| 691 | 695 | Op::GetWorkspace, | |
| 692 | 696 | Op::CreateWorkspace, | |
| ⋯ | |||
| 968 | 972 | Op::DeployKeys(DeployKeysOp::GetDeployKey), | |
| 969 | 973 | Op::DeployKeys(DeployKeysOp::CreateDeployKey), | |
| 970 | 974 | Op::DeployKeys(DeployKeysOp::DeleteDeployKey), | |
| 975 | + | Op::Mirrors(MirrorsOp::GetMirror), | |
| 976 | + | Op::Mirrors(MirrorsOp::GetHandBackPlan), | |
| 977 | + | Op::Mirrors(MirrorsOp::TakeOver), | |
| 978 | + | Op::Mirrors(MirrorsOp::SetCiFailover), | |
| 979 | + | Op::Mirrors(MirrorsOp::HandBack), | |
| 980 | + | Op::Mirrors(MirrorsOp::MoveToG1t), | |
| 981 | + | Op::Mirrors(MirrorsOp::SyncMirror), | |
| 982 | + | Op::Mirrors(MirrorsOp::AddRemote), | |
| 983 | + | Op::Mirrors(MirrorsOp::UpdateRemote), | |
| 984 | + | Op::Mirrors(MirrorsOp::RemoveRemote), | |
| 971 | 985 | Op::Protection(ProtectionOp::UpdateEnvironment), | |
| 972 | 986 | Op::Protection(ProtectionOp::DeleteEnvironment), | |
| 973 | 987 | Op::Protection(ProtectionOp::GetPendingDeployments), | |
| ⋯ | |||
| 1215 | 1229 | Op::Tokens(op) => op.name(), | |
| 1216 | 1230 | Op::Artifacts(op) => op.name(), | |
| 1217 | 1231 | Op::DeployKeys(op) => op.name(), | |
| 1232 | + | Op::Mirrors(op) => op.name(), | |
| 1218 | 1233 | Op::Packages(op) => op.name(), | |
| 1219 | 1234 | } | |
| 1220 | 1235 | } | |
| ⋯ | |||
| 1770 | 1785 | Op::Tokens(op) => op.description(), | |
| 1771 | 1786 | Op::Artifacts(op) => op.description(), | |
| 1772 | 1787 | Op::DeployKeys(op) => op.description(), | |
| 1788 | + | Op::Mirrors(op) => op.description(), | |
| 1773 | 1789 | Op::Packages(op) => op.description(), | |
| 1774 | 1790 | } | |
| 1775 | 1791 | } | |
| ⋯ | |||
| 3254 | 3270 | Op::Tokens(op) => op.input(), | |
| 3255 | 3271 | Op::Artifacts(op) => op.input(), | |
| 3256 | 3272 | Op::DeployKeys(op) => op.input(), | |
| 3273 | + | Op::Mirrors(op) => op.input(), | |
| 3257 | 3274 | Op::Packages(op) => op.input(), | |
| 3258 | 3275 | } | |
| 3259 | 3276 | } | |
| ⋯ | |||
| 4355 | 4372 | is_private: input["private"].as_bool() == Some(true), | |
| 4356 | 4373 | import_url: optional_text(input, "import_url"), | |
| 4357 | 4374 | import_token: None, | |
| 4375 | + | mirror: None, | |
| 4358 | 4376 | }, | |
| 4359 | 4377 | ) | |
| 4360 | 4378 | .await | |
| ⋯ | |||
| 5572 | 5590 | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 5573 | 5591 | Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await, | |
| 5574 | 5592 | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5593 | + | Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await, | |
| 5575 | 5594 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 5576 | 5595 | Op::ReopenSecurityAlert => { | |
| 5577 | 5596 | let changed: Outcome<AlertChange> = call( | |
| 12221 | 12221 | } | |
| 12222 | 12222 | ] | |
| 12223 | 12223 | } | |
| 12224 | + | }, | |
| 12225 | + | "get_mirror": { | |
| 12226 | + | "params": { | |
| 12227 | + | "owner": "acme", | |
| 12228 | + | "name": "web" | |
| 12229 | + | }, | |
| 12230 | + | "response": { | |
| 12231 | + | "remotes": [ | |
| 12232 | + | { | |
| 12233 | + | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 12234 | + | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 12235 | + | "repo": "acme/web", | |
| 12236 | + | "provider": "github", | |
| 12237 | + | "role": "leader", | |
| 12238 | + | "name": "github.com/acme/web", | |
| 12239 | + | "url": "https://github.com/acme/web", | |
| 12240 | + | "state": "standby", | |
| 12241 | + | "state_since": "2026-10-08T13:58:00.000Z", | |
| 12242 | + | "state_by": "syntaqx", | |
| 12243 | + | "reachable": true, | |
| 12244 | + | "unreachable_since": null, | |
| 12245 | + | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12246 | + | "last_error": null, | |
| 12247 | + | "settings": { | |
| 12248 | + | "notify": "banner", | |
| 12249 | + | "take_over_after": null, | |
| 12250 | + | "hand_back": "when_clean", | |
| 12251 | + | "keep_ci_warm": false, | |
| 12252 | + | "github_workflows": true, | |
| 12253 | + | "hold_deploys": true, | |
| 12254 | + | "remote_pushes": "adopt" | |
| 12255 | + | }, | |
| 12256 | + | "created_at": "2026-10-05T10:00:00.000Z" | |
| 12257 | + | } | |
| 12258 | + | ], | |
| 12259 | + | "plan": null, | |
| 12260 | + | "can_manage": true | |
| 12261 | + | }, | |
| 12262 | + | "notes": "A repository that leads lists its followers instead, each `role: follower` with `state` `following` or `stuck`. A mirror standing by is read-only on g1t: pushes, merges, issues, pull requests and agents are refused, and nothing runs. `reachable` is false once the remote's host has failed three checks over two minutes; by default that is only shown, and nothing happens on its own." | |
| 12263 | + | }, | |
| 12264 | + | "get_hand_back_plan": { | |
| 12265 | + | "params": { | |
| 12266 | + | "owner": "acme", | |
| 12267 | + | "name": "web" | |
| 12268 | + | }, | |
| 12269 | + | "response": { | |
| 12270 | + | "remotes": [ | |
| 12271 | + | { | |
| 12272 | + | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 12273 | + | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 12274 | + | "repo": "acme/web", | |
| 12275 | + | "provider": "github", | |
| 12276 | + | "role": "leader", | |
| 12277 | + | "name": "github.com/acme/web", | |
| 12278 | + | "url": "https://github.com/acme/web", | |
| 12279 | + | "state": "takeover", | |
| 12280 | + | "state_since": "2026-10-08T13:58:00.000Z", | |
| 12281 | + | "state_by": "syntaqx", | |
| 12282 | + | "reachable": true, | |
| 12283 | + | "unreachable_since": null, | |
| 12284 | + | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12285 | + | "last_error": null, | |
| 12286 | + | "settings": { | |
| 12287 | + | "notify": "banner", | |
| 12288 | + | "take_over_after": null, | |
| 12289 | + | "hand_back": "when_clean", | |
| 12290 | + | "keep_ci_warm": false, | |
| 12291 | + | "github_workflows": true, | |
| 12292 | + | "hold_deploys": true, | |
| 12293 | + | "remote_pushes": "adopt" | |
| 12294 | + | }, | |
| 12295 | + | "created_at": "2026-10-05T10:00:00.000Z" | |
| 12296 | + | } | |
| 12297 | + | ], | |
| 12298 | + | "plan": { | |
| 12299 | + | "refs": [ | |
| 12300 | + | { | |
| 12301 | + | "ref": "refs/heads/fix/login-timeout", | |
| 12302 | + | "base": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4", | |
| 12303 | + | "ours": "c47f2b8e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a", | |
| 12304 | + | "theirs": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4", | |
| 12305 | + | "action": "push", | |
| 12306 | + | "decision": null | |
| 12307 | + | }, | |
| 12308 | + | { | |
| 12309 | + | "ref": "refs/heads/main", | |
| 12310 | + | "base": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d", | |
| 12311 | + | "ours": "e01d9a37f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1", | |
| 12312 | + | "theirs": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d", | |
| 12313 | + | "action": "pull_request", | |
| 12314 | + | "decision": null | |
| 12315 | + | }, | |
| 12316 | + | { | |
| 12317 | + | "ref": "refs/heads/docs/readme", | |
| 12318 | + | "base": "7f3a1c2d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b", | |
| 12319 | + | "ours": "5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c", | |
| 12320 | + | "theirs": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 12321 | + | "action": "diverged", | |
| 12322 | + | "decision": null | |
| 12323 | + | } | |
| 12324 | + | ], | |
| 12325 | + | "reachable": true, | |
| 12326 | + | "ready": false | |
| 12327 | + | }, | |
| 12328 | + | "can_manage": true | |
| 12329 | + | }, | |
| 12330 | + | "notes": "Refs that did not move on either side are left out. `ready` stays false until `docs/readme` has a decision." | |
| 12331 | + | }, | |
| 12332 | + | "take_over_mirror": { | |
| 12333 | + | "params": { | |
| 12334 | + | "owner": "acme", | |
| 12335 | + | "name": "web" | |
| 12336 | + | }, | |
| 12337 | + | "response": { | |
| 12338 | + | "remotes": [ | |
| 12339 | + | { | |
| 12340 | + | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 12341 | + | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 12342 | + | "repo": "acme/web", | |
| 12343 | + | "provider": "github", | |
| 12344 | + | "role": "leader", | |
| 12345 | + | "name": "github.com/acme/web", | |
| 12346 | + | "url": "https://github.com/acme/web", | |
| 12347 | + | "state": "takeover", | |
| 12348 | + | "state_since": "2026-10-08T13:58:00.000Z", | |
| 12349 | + | "state_by": "syntaqx", | |
| 12350 | + | "reachable": false, | |
| 12351 | + | "unreachable_since": "2026-10-08T14:04:00.000Z", | |
| 12352 | + | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12353 | + | "last_error": null, | |
| 12354 | + | "settings": { | |
| 12355 | + | "notify": "banner", | |
| 12356 | + | "take_over_after": null, | |
| 12357 | + | "hand_back": "when_clean", | |
| 12358 | + | "keep_ci_warm": false, | |
| 12359 | + | "github_workflows": true, | |
| 12360 | + | "hold_deploys": true, | |
| 12361 | + | "remote_pushes": "adopt" | |
| 12362 | + | }, | |
| 12363 | + | "created_at": "2026-10-05T10:00:00.000Z" | |
| 12364 | + | } | |
| 12365 | + | ], | |
| 12366 | + | "plan": null, | |
| 12367 | + | "can_manage": true | |
| 12368 | + | } | |
| 12369 | + | }, | |
| 12370 | + | "set_ci_failover": { | |
| 12371 | + | "params": { | |
| 12372 | + | "owner": "acme", | |
| 12373 | + | "name": "web" | |
| 12374 | + | }, | |
| 12375 | + | "request": { | |
| 12376 | + | "on": true | |
| 12377 | + | }, | |
| 12378 | + | "response": { | |
| 12379 | + | "remotes": [ | |
| 12380 | + | { | |
| 12381 | + | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 12382 | + | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 12383 | + | "repo": "acme/web", | |
| 12384 | + | "provider": "github", | |
| 12385 | + | "role": "leader", | |
| 12386 | + | "name": "github.com/acme/web", | |
| 12387 | + | "url": "https://github.com/acme/web", | |
| 12388 | + | "state": "ci", | |
| 12389 | + | "state_since": "2026-10-08T13:58:00.000Z", | |
| 12390 | + | "state_by": "syntaqx", | |
| 12391 | + | "reachable": true, | |
| 12392 | + | "unreachable_since": null, | |
| 12393 | + | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12394 | + | "last_error": null, | |
| 12395 | + | "settings": { | |
| 12396 | + | "notify": "banner", | |
| 12397 | + | "take_over_after": null, | |
| 12398 | + | "hand_back": "when_clean", | |
| 12399 | + | "keep_ci_warm": false, | |
| 12400 | + | "github_workflows": true, | |
| 12401 | + | "hold_deploys": true, | |
| 12402 | + | "remote_pushes": "adopt" | |
| 12403 | + | }, | |
| 12404 | + | "created_at": "2026-10-05T10:00:00.000Z" | |
| 12405 | + | } | |
| 12406 | + | ], | |
| 12407 | + | "plan": null, | |
| 12408 | + | "can_manage": true | |
| 12409 | + | } | |
| 12410 | + | }, | |
| 12411 | + | "hand_back_mirror": { | |
| 12412 | + | "params": { | |
| 12413 | + | "owner": "acme", | |
| 12414 | + | "name": "web" | |
| 12415 | + | }, | |
| 12416 | + | "request": { | |
| 12417 | + | "decisions": { | |
| 12418 | + | "refs/heads/docs/readme": "pull_request" | |
| 12419 | + | } | |
| 12420 | + | }, | |
| 12421 | + | "response": { | |
| 12422 | + | "remotes": [ | |
| 12423 | + | { | |
| 12424 | + | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 12425 | + | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 12426 | + | "repo": "acme/web", | |
| 12427 | + | "provider": "github", | |
| 12428 | + | "role": "leader", | |
| 12429 | + | "name": "github.com/acme/web", | |
| 12430 | + | "url": "https://github.com/acme/web", | |
| 12431 | + | "state": "standby", | |
| 12432 | + | "state_since": "2026-10-08T13:58:00.000Z", | |
| 12433 | + | "state_by": "syntaqx", | |
| 12434 | + | "reachable": true, | |
| 12435 | + | "unreachable_since": null, | |
| 12436 | + | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12437 | + | "last_error": null, | |
| 12438 | + | "settings": { | |
| 12439 | + | "notify": "banner", | |
| 12440 | + | "take_over_after": null, | |
| 12441 | + | "hand_back": "when_clean", | |
| 12442 | + | "keep_ci_warm": false, | |
| 12443 | + | "github_workflows": true, | |
| 12444 | + | "hold_deploys": true, | |
| 12445 | + | "remote_pushes": "adopt" | |
| 12446 | + | }, | |
| 12447 | + | "created_at": "2026-10-05T10:00:00.000Z" | |
| 12448 | + | } | |
| 12449 | + | ], | |
| 12450 | + | "plan": null, | |
| 12451 | + | "can_manage": true, | |
| 12452 | + | "notes": [ | |
| 12453 | + | "main: opened https://github.com/acme/web/pull/1290", | |
| 12454 | + | "docs/readme: opened https://github.com/acme/web/pull/1291" | |
| 12455 | + | ] | |
| 12456 | + | }, | |
| 12457 | + | "notes": "Branch names work as keys too: `{ \"docs/readme\": \"keep_theirs\" }`." | |
| 12458 | + | }, | |
| 12459 | + | "move_mirror_to_g1t": { | |
| 12460 | + | "params": { | |
| 12461 | + | "owner": "acme", | |
| 12462 | + | "name": "web" | |
| 12463 | + | }, | |
| 12464 | + | "request": { | |
| 12465 | + | "keep_remote_updated": false | |
| 12466 | + | }, | |
| 12467 | + | "response": { | |
| 12468 | + | "remotes": [], | |
| 12469 | + | "plan": null, | |
| 12470 | + | "can_manage": true | |
| 12471 | + | }, | |
| 12472 | + | "notes": "With `keep_remote_updated: true` the remote stays in `remotes` as a follower, and g1t pushes to it from then on." | |
| 12473 | + | }, | |
| 12474 | + | "sync_mirror": { | |
| 12475 | + | "params": { | |
| 12476 | + | "owner": "acme", | |
| 12477 | + | "name": "web" | |
| 12478 | + | }, | |
| 12479 | + | "response": { | |
| 12480 | + | "remotes": [ | |
| 12481 | + | { | |
| 12482 | + | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 12483 | + | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 12484 | + | "repo": "acme/web", | |
| 12485 | + | "provider": "github", | |
| 12486 | + | "role": "leader", | |
| 12487 | + | "name": "github.com/acme/web", | |
| 12488 | + | "url": "https://github.com/acme/web", | |
| 12489 | + | "state": "standby", | |
| 12490 | + | "state_since": "2026-10-08T13:58:00.000Z", | |
| 12491 | + | "state_by": "syntaqx", | |
| 12492 | + | "reachable": true, | |
| 12493 | + | "unreachable_since": null, | |
| 12494 | + | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12495 | + | "last_error": null, | |
| 12496 | + | "settings": { | |
| 12497 | + | "notify": "banner", | |
| 12498 | + | "take_over_after": null, | |
| 12499 | + | "hand_back": "when_clean", | |
| 12500 | + | "keep_ci_warm": false, | |
| 12501 | + | "github_workflows": true, | |
| 12502 | + | "hold_deploys": true, | |
| 12503 | + | "remote_pushes": "adopt" | |
| 12504 | + | }, | |
| 12505 | + | "created_at": "2026-10-05T10:00:00.000Z" | |
| 12506 | + | } | |
| 12507 | + | ], | |
| 12508 | + | "plan": null, | |
| 12509 | + | "can_manage": true | |
| 12510 | + | } | |
| 12511 | + | }, | |
| 12512 | + | "add_mirror_remote": { | |
| 12513 | + | "params": { | |
| 12514 | + | "owner": "acme", | |
| 12515 | + | "name": "web" | |
| 12516 | + | }, | |
| 12517 | + | "request": { | |
| 12518 | + | "provider": "g1t", | |
| 12519 | + | "role": "follower", | |
| 12520 | + | "url": "https://git.acme.internal/acme/web.git", | |
| 12521 | + | "username": "deploy", | |
| 12522 | + | "token": "g1t_…" | |
| 12523 | + | }, | |
| 12524 | + | "response": { | |
| 12525 | + | "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n", | |
| 12526 | + | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 12527 | + | "repo": "acme/web", | |
| 12528 | + | "provider": "g1t", | |
| 12529 | + | "role": "follower", | |
| 12530 | + | "name": "git.acme.internal/acme/web", | |
| 12531 | + | "url": "https://git.acme.internal/acme/web", | |
| 12532 | + | "state": "following", | |
| 12533 | + | "state_since": "2026-10-08T13:58:00.000Z", | |
| 12534 | + | "state_by": "syntaqx", | |
| 12535 | + | "reachable": true, | |
| 12536 | + | "unreachable_since": null, | |
| 12537 | + | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12538 | + | "last_error": null, | |
| 12539 | + | "settings": { | |
| 12540 | + | "notify": "banner", | |
| 12541 | + | "take_over_after": null, | |
| 12542 | + | "hand_back": "when_clean", | |
| 12543 | + | "keep_ci_warm": false, | |
| 12544 | + | "github_workflows": true, | |
| 12545 | + | "hold_deploys": true, | |
| 12546 | + | "remote_pushes": "adopt" | |
| 12547 | + | }, | |
| 12548 | + | "created_at": "2026-10-05T10:00:00.000Z" | |
| 12549 | + | }, | |
| 12550 | + | "notes": "The token is never returned. A remote that refused it, or could not be reached, is still added, with `last_error` saying why." | |
| 12551 | + | }, | |
| 12552 | + | "update_mirror_remote": { | |
| 12553 | + | "params": { | |
| 12554 | + | "owner": "acme", | |
| 12555 | + | "name": "web", | |
| 12556 | + | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m" | |
| 12557 | + | }, | |
| 12558 | + | "request": { | |
| 12559 | + | "notify": "inbox", | |
| 12560 | + | "take_over_after": 30 | |
| 12561 | + | }, | |
| 12562 | + | "response": { | |
| 12563 | + | "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m", | |
| 12564 | + | "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn", | |
| 12565 | + | "repo": "acme/web", | |
| 12566 | + | "provider": "github", | |
| 12567 | + | "role": "leader", | |
| 12568 | + | "name": "github.com/acme/web", | |
| 12569 | + | "url": "https://github.com/acme/web", | |
| 12570 | + | "state": "standby", | |
| 12571 | + | "state_since": "2026-10-08T13:58:00.000Z", | |
| 12572 | + | "state_by": "syntaqx", | |
| 12573 | + | "reachable": true, | |
| 12574 | + | "unreachable_since": null, | |
| 12575 | + | "synced_at": "2026-10-08T13:57:42.000Z", | |
| 12576 | + | "last_error": null, | |
| 12577 | + | "settings": { | |
| 12578 | + | "notify": "inbox", | |
| 12579 | + | "take_over_after": 30, | |
| 12580 | + | "hand_back": "when_clean", | |
| 12581 | + | "keep_ci_warm": false, | |
| 12582 | + | "github_workflows": true, | |
| 12583 | + | "hold_deploys": true, | |
| 12584 | + | "remote_pushes": "adopt" | |
| 12585 | + | }, | |
| 12586 | + | "created_at": "2026-10-05T10:00:00.000Z" | |
| 12587 | + | } | |
| 12588 | + | }, | |
| 12589 | + | "remove_mirror_remote": { | |
| 12590 | + | "params": { | |
| 12591 | + | "owner": "acme", | |
| 12592 | + | "name": "web", | |
| 12593 | + | "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n" | |
| 12594 | + | }, | |
| 12595 | + | "response": true | |
| 12224 | 12596 | } | |
| 12225 | 12597 | } |
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | 6 | use crate::artifacts::ArtifactsOp; | |
| 7 | 7 | use crate::deploy_keys::DeployKeysOp; | |
| 8 | + | use crate::mirrors::MirrorsOp; | |
| 8 | 9 | use crate::deployments::DeploymentsOp; | |
| 9 | 10 | use crate::packages::PackagesOp; | |
| 10 | 11 | use crate::protection::ProtectionOp; | |
| ⋯ | |||
| 84 | 85 | route("POST", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::CreateDeployKey), &[]), | |
| 85 | 86 | route("GET", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::GetDeployKey), &[]), | |
| 86 | 87 | route("DELETE", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), &[]), | |
| 88 | + | // Mirroring: a repository's remotes, takeovers and hand-backs. | |
| 89 | + | route("GET", "/repos/:owner/:name/mirror", Op::Mirrors(MirrorsOp::GetMirror), &[]), | |
| 90 | + | route("GET", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::GetHandBackPlan), &[]), | |
| 91 | + | route("POST", "/repos/:owner/:name/mirror/take-over", Op::Mirrors(MirrorsOp::TakeOver), &[]), | |
| 92 | + | route("POST", "/repos/:owner/:name/mirror/ci", Op::Mirrors(MirrorsOp::SetCiFailover), &[]), | |
| 93 | + | route("POST", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::HandBack), &[]), | |
| 94 | + | route("POST", "/repos/:owner/:name/mirror/move-to-g1t", Op::Mirrors(MirrorsOp::MoveToG1t), &[]), | |
| 95 | + | route("POST", "/repos/:owner/:name/mirror/sync", Op::Mirrors(MirrorsOp::SyncMirror), &[]), | |
| 96 | + | route("POST", "/repos/:owner/:name/mirror/remotes", Op::Mirrors(MirrorsOp::AddRemote), &[]), | |
| 97 | + | route("PATCH", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::UpdateRemote), &[]), | |
| 98 | + | route("DELETE", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::RemoveRemote), &[]), | |
| 87 | 99 | // Your notifications: threads, marking them, and what you subscribe | |
| 88 | 100 | // to and watch. GitHub's addresses, with g1t's saved and snoozed. | |
| 89 | 101 | route("GET", "/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]), | |
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | 22 | use crate::artifacts::ArtifactsOp; | |
| 23 | 23 | use crate::deploy_keys::DeployKeysOp; | |
| 24 | + | use crate::mirrors::MirrorsOp; | |
| 24 | 25 | use crate::deployments::DeploymentsOp; | |
| 25 | 26 | use crate::packages::PackagesOp; | |
| 26 | 27 | use crate::protection::ProtectionOp; | |
| ⋯ | |||
| 67 | 68 | Tool { | |
| 68 | 69 | name: "repository", | |
| 69 | 70 | title: "Repositories", | |
| 70 | − | description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them, and publish releases. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 71 | + | description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them, publish releases, and look after their mirroring (take a mirror over, hand it back, or move it to g1t for good). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 71 | 72 | default_action: None, | |
| 72 | 73 | actions: &[ | |
| 73 | 74 | a("list", Op::ListRepos, "Repositories you can see"), | |
| ⋯ | |||
| 114 | 115 | a("rename_branch", Op::RenameBranch, "Rename a branch"), | |
| 115 | 116 | a("rename", Op::RenameRepo, "Rename it; old addresses redirect"), | |
| 116 | 117 | a("transfer", Op::TransferRepo, "Move it to another workspace you own"), | |
| 118 | + | a("mirror", Op::Mirrors(MirrorsOp::GetMirror), "Its remotes: what it mirrors or is mirrored to"), | |
| 119 | + | a("mirror_hand_back_plan", Op::Mirrors(MirrorsOp::GetHandBackPlan), "What handing a takeover back would do, ref by ref"), | |
| 120 | + | a("mirror_take_over", Op::Mirrors(MirrorsOp::TakeOver), "Make g1t lead a mirror for now"), | |
| 121 | + | a("mirror_ci", Op::Mirrors(MirrorsOp::SetCiFailover), "Run a mirror's workflows on g1t (on), or stop (off)"), | |
| 122 | + | a("mirror_hand_back", Op::Mirrors(MirrorsOp::HandBack), "Send a takeover back, deciding diverged refs"), | |
| 123 | + | a("mirror_move_to_g1t", Op::Mirrors(MirrorsOp::MoveToG1t), "Stop tracking the remote; g1t leads for good"), | |
| 124 | + | a("mirror_sync", Op::Mirrors(MirrorsOp::SyncMirror), "Bring its remotes in step now"), | |
| 125 | + | a("mirror_add_remote", Op::Mirrors(MirrorsOp::AddRemote), "Link another g1t or git host"), | |
| 126 | + | a("mirror_update_remote", Op::Mirrors(MirrorsOp::UpdateRemote), "Change a remote's settings"), | |
| 127 | + | a("mirror_remove_remote", Op::Mirrors(MirrorsOp::RemoveRemote), "Unlink a remote"), | |
| 117 | 128 | a("archive", Op::ArchiveRepo, "Make it read-only"), | |
| 118 | 129 | a("unarchive", Op::UnarchiveRepo, "Make it writable again"), | |
| 119 | 130 | a("set_visibility", Op::SetRepoVisibility, "Make it public or private"), | |
| 159 | 159 | items: [ | |
| 160 | 160 | { label: 'Accounts and sign-in', slug: 'guides/authentication' }, | |
| 161 | 161 | { label: 'GitHub', slug: 'guides/github' }, | |
| 162 | + | { label: 'Mirroring', slug: 'guides/mirroring' }, | |
| 162 | 163 | { label: 'Workspaces and tokens', slug: 'guides/workspaces' }, | |
| 163 | 164 | { label: 'Access and roles', slug: 'guides/access-and-roles' }, | |
| 164 | 165 | { 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. |
| 87 | 87 | | Choice | What happens | Where you push | | |
| 88 | 88 | | --- | --- | --- | | |
| 89 | 89 | | **Import** | Copied once. The g1t repository is then its own. | g1t | | |
| 90 | − | | **Mirror** | Copied, then every push to GitHub is fetched into g1t within seconds. Branches deleted on GitHub are deleted on g1t. | GitHub | | |
| 91 | − | | **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 | | |
| 90 | + | | **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 | | |
| 91 | + | | **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 | | |
| 92 | 92 | ||
| 93 | − | A mirror is a copy that follows GitHub: anything pushed to the g1t copy | |
| 94 | − | directly is overwritten at the next sync. Deployments, previews, checks | |
| 95 | − | and agents run on the copy as on any project. | |
| 93 | + | A standby mirror is a backup you can switch to: if GitHub is down, or | |
| 94 | + | whenever you want to work on g1t for a while, **take over** in its | |
| 95 | + | **Settings → Mirroring**, and **hand back** when you're done. You can also | |
| 96 | + | run its GitHub workflows on g1t while GitHub's don't run, or move it to g1t | |
| 97 | + | for good. See [mirroring](/guides/mirroring/). | |
| 96 | 98 | ||
| 97 | 99 | ### What comes across | |
| 98 | 100 | ||
| ⋯ | |||
| 105 | 107 | | Issue numbers | New numbers on g1t; each issue links to the original | | |
| 106 | 108 | | Comments on issues | No | | |
| 107 | 109 | | Pull requests | No. Their branches come across; open pull requests stay on GitHub. | | |
| 108 | − | | GitHub Actions workflows | Yes, as files. Rename `.github` to `.g1t` to run them on g1t: see [GitHub Actions](/guides/actions/). | | |
| 110 | + | | 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/). | | |
| 109 | 111 | | Releases, wikis, Git LFS objects | No | | |
| 110 | 112 | ||
| 111 | 113 | A repository is copied in one piece of at most 40 MB, after compression. | |
| ⋯ | |||
| 113 | 115 | ||
| 114 | 116 | ### Keep a mirror in step | |
| 115 | 117 | ||
| 116 | − | The repository's overview shows where it came from and when it last | |
| 117 | − | synced. **Sync now** copies at once; **Stop mirroring** (or **Stop | |
| 118 | − | pushing**) ends the tie and leaves the g1t repository as it is. A mirror | |
| 119 | − | also stops, keeping its copy, when: | |
| 118 | + | The repository's **Settings → Mirroring** shows the link: its state, | |
| 119 | + | whether GitHub is answering, and when it last synced. **Sync now** copies | |
| 120 | + | at once. **Take over**, **Hand back**, **Move to g1t** and **Remove** are | |
| 121 | + | there too: see [mirroring](/guides/mirroring/). A standby mirror stops, | |
| 122 | + | keeping its copy, when: | |
| 120 | 123 | ||
| 121 | 124 | - the repository is deleted on GitHub, | |
| 122 | 125 | - the app is uninstalled from its GitHub account, or | |
| 123 | 126 | - a workspace owner removes that GitHub account from the workspace. | |
| 124 | 127 | ||
| 125 | − | If GitHub stops letting the app see a repository, its overview says so; | |
| 126 | − | add it back to the installation on GitHub to carry on. Renaming or | |
| 128 | + | If GitHub stops letting the app see a repository, its settings say so; add | |
| 129 | + | it back to the installation on GitHub to carry on. Renaming or | |
| 127 | 130 | transferring a repository on GitHub keeps the mirror working. | |
| 128 | 131 | ||
| 129 | 132 | ## What g1t's GitHub App can do | |
| 130 | 133 | ||
| 131 | 134 | | Permission | Access | Why | | |
| 132 | 135 | | --- | --- | --- | | |
| 133 | − | | Contents | Read and write | Read: clone the repositories you choose. Write: only for **Move to g1t**, to push. | | |
| 134 | − | | Metadata | Read | Required by GitHub for every app: names and visibility. | | |
| 136 | + | | Contents | Read and write | Read: clone the repositories you choose. Write: to push for **Move to g1t**, and to hand a takeover back. | | |
| 137 | + | | Metadata | Read | Required by GitHub for every app: names, visibility, and whether a branch is protected. | | |
| 135 | 138 | | Issues | Read | Copy issues when you ask. | | |
| 139 | + | | 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. | | |
| 136 | 140 | | Email addresses (account) | Read | Find your verified email when you sign in. | | |
| 137 | 141 | ||
| 138 | 142 | 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. |
Binary or large file; its contents are not shown.
| 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 | + | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.