Skip to content

Commit

Mirroring over REST and MCP

/repos/{owner}/{name}/mirror: get a repository's remotes, the hand-back plan, take over, CI failover, hand back with decisions, move to g1t, sync, and add, update or remove remotes. The same as mirror_* actions of the MCP repository tool. Reading is repo:read, syncing code:write, the rest repo:admin; agents never move a repository to g1t or touch a remote's token. mirror.* events join the webhook catalogue.

syntaqxcommitted Parentb7a8890Browse files
12 files+835−30/12 viewed
+1−0
1515 mod deployments;
1616 mod deploy_keys;
1717 mod logs;
18+mod mirrors;
1819 mod mcp;
1920 mod notifications;
2021 mod oauth;
+372−0
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+}
+18−0
1010 use crate::about::AboutOp;
1111 use crate::artifacts::ArtifactsOp;
1212 use crate::deploy_keys::DeployKeysOp;
13+use crate::mirrors::MirrorsOp;
1314 use crate::deployments::DeploymentsOp;
1415 use crate::packages::PackagesOp;
1516 use crate::protection::ProtectionOp;
183184 ],
184185 ),
185186 (
187+ "Mirroring",
188+ "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.",
189+ &[
190+ Op::Mirrors(MirrorsOp::GetMirror),
191+ Op::Mirrors(MirrorsOp::GetHandBackPlan),
192+ Op::Mirrors(MirrorsOp::TakeOver),
193+ Op::Mirrors(MirrorsOp::SetCiFailover),
194+ Op::Mirrors(MirrorsOp::HandBack),
195+ Op::Mirrors(MirrorsOp::MoveToG1t),
196+ Op::Mirrors(MirrorsOp::SyncMirror),
197+ Op::Mirrors(MirrorsOp::AddRemote),
198+ Op::Mirrors(MirrorsOp::UpdateRemote),
199+ Op::Mirrors(MirrorsOp::RemoveRemote),
200+ ],
201+ ),
202+ (
186203 "Teams",
187204 "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.",
188205 &[
727744 Op::Tokens(op) => op.title(),
728745 Op::Artifacts(op) => op.title(),
729746 Op::DeployKeys(op) => op.title(),
747+ Op::Mirrors(op) => op.title(),
730748 Op::Packages(op) => op.title(),
731749 }
732750 }
+19−1
3030 use crate::about::AboutOp;
3131 use crate::artifacts::ArtifactsOp;
3232 use crate::deploy_keys::DeployKeysOp;
33+use crate::mirrors::MirrorsOp;
3334 use crate::deployments::DeploymentsOp;
3435 use crate::packages::PackagesOp;
3536 use crate::protection::ProtectionOp;
311312 Artifacts(ArtifactsOp),
312313 /// A repository's deploy keys: deploy_keys.rs.
313314 DeployKeys(DeployKeysOp),
315+ /// A repository's mirroring: its remotes, takeovers and hand-backs:
316+ /// mirrors.rs.
317+ Mirrors(MirrorsOp),
314318 /// A workspace's packages, their versions, deleting and restoring
315319 /// them, and who may use them: packages.rs.
316320 Packages(PackagesOp),
683687 }
684688
685689 impl Op {
686− pub const ALL: [Op; 315] = [
690+ pub const ALL: [Op; 325] = [
687691 Op::Whoami,
688692 Op::GetWorkspace,
689693 Op::CreateWorkspace,
962966 Op::DeployKeys(DeployKeysOp::GetDeployKey),
963967 Op::DeployKeys(DeployKeysOp::CreateDeployKey),
964968 Op::DeployKeys(DeployKeysOp::DeleteDeployKey),
969+ Op::Mirrors(MirrorsOp::GetMirror),
970+ Op::Mirrors(MirrorsOp::GetHandBackPlan),
971+ Op::Mirrors(MirrorsOp::TakeOver),
972+ Op::Mirrors(MirrorsOp::SetCiFailover),
973+ Op::Mirrors(MirrorsOp::HandBack),
974+ Op::Mirrors(MirrorsOp::MoveToG1t),
975+ Op::Mirrors(MirrorsOp::SyncMirror),
976+ Op::Mirrors(MirrorsOp::AddRemote),
977+ Op::Mirrors(MirrorsOp::UpdateRemote),
978+ Op::Mirrors(MirrorsOp::RemoveRemote),
965979 Op::Protection(ProtectionOp::UpdateEnvironment),
966980 Op::Protection(ProtectionOp::DeleteEnvironment),
967981 Op::Protection(ProtectionOp::GetPendingDeployments),
12061220 Op::Tokens(op) => op.name(),
12071221 Op::Artifacts(op) => op.name(),
12081222 Op::DeployKeys(op) => op.name(),
1223+ Op::Mirrors(op) => op.name(),
12091224 Op::Packages(op) => op.name(),
12101225 }
12111226 }
17521767 Op::Tokens(op) => op.description(),
17531768 Op::Artifacts(op) => op.description(),
17541769 Op::DeployKeys(op) => op.description(),
1770+ Op::Mirrors(op) => op.description(),
17551771 Op::Packages(op) => op.description(),
17561772 }
17571773 }
32163232 Op::Tokens(op) => op.input(),
32173233 Op::Artifacts(op) => op.input(),
32183234 Op::DeployKeys(op) => op.input(),
3235+ Op::Mirrors(op) => op.input(),
32193236 Op::Packages(op) => op.input(),
32203237 }
32213238 }
54995516 Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await,
55005517 Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await,
55015518 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
5519+ Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await,
55025520 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
55035521 Op::ReopenSecurityAlert => {
55045522 let changed: Outcome<AlertChange> = call(
+372−0
1208312083 }
1208412084 ]
1208512085 }
12086+ },
12087+ "get_mirror": {
12088+ "params": {
12089+ "owner": "acme",
12090+ "name": "web"
12091+ },
12092+ "response": {
12093+ "remotes": [
12094+ {
12095+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12096+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12097+ "repo": "acme/web",
12098+ "provider": "github",
12099+ "role": "leader",
12100+ "name": "github.com/acme/web",
12101+ "url": "https://github.com/acme/web",
12102+ "state": "standby",
12103+ "state_since": "2026-10-08T13:58:00.000Z",
12104+ "state_by": "syntaqx",
12105+ "reachable": true,
12106+ "unreachable_since": null,
12107+ "synced_at": "2026-10-08T13:57:42.000Z",
12108+ "last_error": null,
12109+ "settings": {
12110+ "notify": "banner",
12111+ "take_over_after": null,
12112+ "hand_back": "when_clean",
12113+ "keep_ci_warm": false,
12114+ "github_workflows": true,
12115+ "hold_deploys": true,
12116+ "remote_pushes": "adopt"
12117+ },
12118+ "created_at": "2026-10-05T10:00:00.000Z"
12119+ }
12120+ ],
12121+ "plan": null,
12122+ "can_manage": true
12123+ },
12124+ "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."
12125+ },
12126+ "get_hand_back_plan": {
12127+ "params": {
12128+ "owner": "acme",
12129+ "name": "web"
12130+ },
12131+ "response": {
12132+ "remotes": [
12133+ {
12134+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12135+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12136+ "repo": "acme/web",
12137+ "provider": "github",
12138+ "role": "leader",
12139+ "name": "github.com/acme/web",
12140+ "url": "https://github.com/acme/web",
12141+ "state": "takeover",
12142+ "state_since": "2026-10-08T13:58:00.000Z",
12143+ "state_by": "syntaqx",
12144+ "reachable": true,
12145+ "unreachable_since": null,
12146+ "synced_at": "2026-10-08T13:57:42.000Z",
12147+ "last_error": null,
12148+ "settings": {
12149+ "notify": "banner",
12150+ "take_over_after": null,
12151+ "hand_back": "when_clean",
12152+ "keep_ci_warm": false,
12153+ "github_workflows": true,
12154+ "hold_deploys": true,
12155+ "remote_pushes": "adopt"
12156+ },
12157+ "created_at": "2026-10-05T10:00:00.000Z"
12158+ }
12159+ ],
12160+ "plan": {
12161+ "refs": [
12162+ {
12163+ "ref": "refs/heads/fix/login-timeout",
12164+ "base": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4",
12165+ "ours": "c47f2b8e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a",
12166+ "theirs": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4",
12167+ "action": "push",
12168+ "decision": null
12169+ },
12170+ {
12171+ "ref": "refs/heads/main",
12172+ "base": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d",
12173+ "ours": "e01d9a37f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1",
12174+ "theirs": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d",
12175+ "action": "pull_request",
12176+ "decision": null
12177+ },
12178+ {
12179+ "ref": "refs/heads/docs/readme",
12180+ "base": "7f3a1c2d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b",
12181+ "ours": "5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c",
12182+ "theirs": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
12183+ "action": "diverged",
12184+ "decision": null
12185+ }
12186+ ],
12187+ "reachable": true,
12188+ "ready": false
12189+ },
12190+ "can_manage": true
12191+ },
12192+ "notes": "Refs that did not move on either side are left out. `ready` stays false until `docs/readme` has a decision."
12193+ },
12194+ "take_over_mirror": {
12195+ "params": {
12196+ "owner": "acme",
12197+ "name": "web"
12198+ },
12199+ "response": {
12200+ "remotes": [
12201+ {
12202+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12203+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12204+ "repo": "acme/web",
12205+ "provider": "github",
12206+ "role": "leader",
12207+ "name": "github.com/acme/web",
12208+ "url": "https://github.com/acme/web",
12209+ "state": "takeover",
12210+ "state_since": "2026-10-08T13:58:00.000Z",
12211+ "state_by": "syntaqx",
12212+ "reachable": false,
12213+ "unreachable_since": "2026-10-08T14:04:00.000Z",
12214+ "synced_at": "2026-10-08T13:57:42.000Z",
12215+ "last_error": null,
12216+ "settings": {
12217+ "notify": "banner",
12218+ "take_over_after": null,
12219+ "hand_back": "when_clean",
12220+ "keep_ci_warm": false,
12221+ "github_workflows": true,
12222+ "hold_deploys": true,
12223+ "remote_pushes": "adopt"
12224+ },
12225+ "created_at": "2026-10-05T10:00:00.000Z"
12226+ }
12227+ ],
12228+ "plan": null,
12229+ "can_manage": true
12230+ }
12231+ },
12232+ "set_ci_failover": {
12233+ "params": {
12234+ "owner": "acme",
12235+ "name": "web"
12236+ },
12237+ "request": {
12238+ "on": true
12239+ },
12240+ "response": {
12241+ "remotes": [
12242+ {
12243+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12244+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12245+ "repo": "acme/web",
12246+ "provider": "github",
12247+ "role": "leader",
12248+ "name": "github.com/acme/web",
12249+ "url": "https://github.com/acme/web",
12250+ "state": "ci",
12251+ "state_since": "2026-10-08T13:58:00.000Z",
12252+ "state_by": "syntaqx",
12253+ "reachable": true,
12254+ "unreachable_since": null,
12255+ "synced_at": "2026-10-08T13:57:42.000Z",
12256+ "last_error": null,
12257+ "settings": {
12258+ "notify": "banner",
12259+ "take_over_after": null,
12260+ "hand_back": "when_clean",
12261+ "keep_ci_warm": false,
12262+ "github_workflows": true,
12263+ "hold_deploys": true,
12264+ "remote_pushes": "adopt"
12265+ },
12266+ "created_at": "2026-10-05T10:00:00.000Z"
12267+ }
12268+ ],
12269+ "plan": null,
12270+ "can_manage": true
12271+ }
12272+ },
12273+ "hand_back_mirror": {
12274+ "params": {
12275+ "owner": "acme",
12276+ "name": "web"
12277+ },
12278+ "request": {
12279+ "decisions": {
12280+ "refs/heads/docs/readme": "pull_request"
12281+ }
12282+ },
12283+ "response": {
12284+ "remotes": [
12285+ {
12286+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12287+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12288+ "repo": "acme/web",
12289+ "provider": "github",
12290+ "role": "leader",
12291+ "name": "github.com/acme/web",
12292+ "url": "https://github.com/acme/web",
12293+ "state": "standby",
12294+ "state_since": "2026-10-08T13:58:00.000Z",
12295+ "state_by": "syntaqx",
12296+ "reachable": true,
12297+ "unreachable_since": null,
12298+ "synced_at": "2026-10-08T13:57:42.000Z",
12299+ "last_error": null,
12300+ "settings": {
12301+ "notify": "banner",
12302+ "take_over_after": null,
12303+ "hand_back": "when_clean",
12304+ "keep_ci_warm": false,
12305+ "github_workflows": true,
12306+ "hold_deploys": true,
12307+ "remote_pushes": "adopt"
12308+ },
12309+ "created_at": "2026-10-05T10:00:00.000Z"
12310+ }
12311+ ],
12312+ "plan": null,
12313+ "can_manage": true,
12314+ "notes": [
12315+ "main: opened https://github.com/acme/web/pull/1290",
12316+ "docs/readme: opened https://github.com/acme/web/pull/1291"
12317+ ]
12318+ },
12319+ "notes": "Branch names work as keys too: `{ \"docs/readme\": \"keep_theirs\" }`."
12320+ },
12321+ "move_mirror_to_g1t": {
12322+ "params": {
12323+ "owner": "acme",
12324+ "name": "web"
12325+ },
12326+ "request": {
12327+ "keep_remote_updated": false
12328+ },
12329+ "response": {
12330+ "remotes": [],
12331+ "plan": null,
12332+ "can_manage": true
12333+ },
12334+ "notes": "With `keep_remote_updated: true` the remote stays in `remotes` as a follower, and g1t pushes to it from then on."
12335+ },
12336+ "sync_mirror": {
12337+ "params": {
12338+ "owner": "acme",
12339+ "name": "web"
12340+ },
12341+ "response": {
12342+ "remotes": [
12343+ {
12344+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12345+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12346+ "repo": "acme/web",
12347+ "provider": "github",
12348+ "role": "leader",
12349+ "name": "github.com/acme/web",
12350+ "url": "https://github.com/acme/web",
12351+ "state": "standby",
12352+ "state_since": "2026-10-08T13:58:00.000Z",
12353+ "state_by": "syntaqx",
12354+ "reachable": true,
12355+ "unreachable_since": null,
12356+ "synced_at": "2026-10-08T13:57:42.000Z",
12357+ "last_error": null,
12358+ "settings": {
12359+ "notify": "banner",
12360+ "take_over_after": null,
12361+ "hand_back": "when_clean",
12362+ "keep_ci_warm": false,
12363+ "github_workflows": true,
12364+ "hold_deploys": true,
12365+ "remote_pushes": "adopt"
12366+ },
12367+ "created_at": "2026-10-05T10:00:00.000Z"
12368+ }
12369+ ],
12370+ "plan": null,
12371+ "can_manage": true
12372+ }
12373+ },
12374+ "add_mirror_remote": {
12375+ "params": {
12376+ "owner": "acme",
12377+ "name": "web"
12378+ },
12379+ "request": {
12380+ "provider": "g1t",
12381+ "role": "follower",
12382+ "url": "https://git.acme.internal/acme/web.git",
12383+ "username": "deploy",
12384+ "token": "g1t_…"
12385+ },
12386+ "response": {
12387+ "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n",
12388+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12389+ "repo": "acme/web",
12390+ "provider": "g1t",
12391+ "role": "follower",
12392+ "name": "git.acme.internal/acme/web",
12393+ "url": "https://git.acme.internal/acme/web",
12394+ "state": "following",
12395+ "state_since": "2026-10-08T13:58:00.000Z",
12396+ "state_by": "syntaqx",
12397+ "reachable": true,
12398+ "unreachable_since": null,
12399+ "synced_at": "2026-10-08T13:57:42.000Z",
12400+ "last_error": null,
12401+ "settings": {
12402+ "notify": "banner",
12403+ "take_over_after": null,
12404+ "hand_back": "when_clean",
12405+ "keep_ci_warm": false,
12406+ "github_workflows": true,
12407+ "hold_deploys": true,
12408+ "remote_pushes": "adopt"
12409+ },
12410+ "created_at": "2026-10-05T10:00:00.000Z"
12411+ },
12412+ "notes": "The token is never returned. A remote that refused it, or could not be reached, is still added, with `last_error` saying why."
12413+ },
12414+ "update_mirror_remote": {
12415+ "params": {
12416+ "owner": "acme",
12417+ "name": "web",
12418+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m"
12419+ },
12420+ "request": {
12421+ "notify": "inbox",
12422+ "take_over_after": 30
12423+ },
12424+ "response": {
12425+ "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12426+ "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12427+ "repo": "acme/web",
12428+ "provider": "github",
12429+ "role": "leader",
12430+ "name": "github.com/acme/web",
12431+ "url": "https://github.com/acme/web",
12432+ "state": "standby",
12433+ "state_since": "2026-10-08T13:58:00.000Z",
12434+ "state_by": "syntaqx",
12435+ "reachable": true,
12436+ "unreachable_since": null,
12437+ "synced_at": "2026-10-08T13:57:42.000Z",
12438+ "last_error": null,
12439+ "settings": {
12440+ "notify": "inbox",
12441+ "take_over_after": 30,
12442+ "hand_back": "when_clean",
12443+ "keep_ci_warm": false,
12444+ "github_workflows": true,
12445+ "hold_deploys": true,
12446+ "remote_pushes": "adopt"
12447+ },
12448+ "created_at": "2026-10-05T10:00:00.000Z"
12449+ }
12450+ },
12451+ "remove_mirror_remote": {
12452+ "params": {
12453+ "owner": "acme",
12454+ "name": "web",
12455+ "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n"
12456+ },
12457+ "response": true
1208612458 }
1208712459 }
+12−0
55 use crate::about::AboutOp;
66 use crate::artifacts::ArtifactsOp;
77 use crate::deploy_keys::DeployKeysOp;
8+use crate::mirrors::MirrorsOp;
89 use crate::deployments::DeploymentsOp;
910 use crate::packages::PackagesOp;
1011 use crate::protection::ProtectionOp;
8081 route("POST", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::CreateDeployKey), &[]),
8182 route("GET", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::GetDeployKey), &[]),
8283 route("DELETE", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), &[]),
84+ // Mirroring: a repository's remotes, takeovers and hand-backs.
85+ route("GET", "/repos/:owner/:name/mirror", Op::Mirrors(MirrorsOp::GetMirror), &[]),
86+ route("GET", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::GetHandBackPlan), &[]),
87+ route("POST", "/repos/:owner/:name/mirror/take-over", Op::Mirrors(MirrorsOp::TakeOver), &[]),
88+ route("POST", "/repos/:owner/:name/mirror/ci", Op::Mirrors(MirrorsOp::SetCiFailover), &[]),
89+ route("POST", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::HandBack), &[]),
90+ route("POST", "/repos/:owner/:name/mirror/move-to-g1t", Op::Mirrors(MirrorsOp::MoveToG1t), &[]),
91+ route("POST", "/repos/:owner/:name/mirror/sync", Op::Mirrors(MirrorsOp::SyncMirror), &[]),
92+ route("POST", "/repos/:owner/:name/mirror/remotes", Op::Mirrors(MirrorsOp::AddRemote), &[]),
93+ route("PATCH", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::UpdateRemote), &[]),
94+ route("DELETE", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::RemoveRemote), &[]),
8395 // Your notifications: threads, marking them, and what you subscribe
8496 // to and watch. GitHub's addresses, with g1t's saved and snoozed.
8597 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")]),
+12−1
2121 use crate::about::AboutOp;
2222 use crate::artifacts::ArtifactsOp;
2323 use crate::deploy_keys::DeployKeysOp;
24+use crate::mirrors::MirrorsOp;
2425 use crate::deployments::DeploymentsOp;
2526 use crate::packages::PackagesOp;
2627 use crate::protection::ProtectionOp;
6768 Tool {
6869 name: "repository",
6970 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`.",
7172 default_action: None,
7273 actions: &[
7374 a("list", Op::ListRepos, "Repositories you can see"),
114115 a("rename_branch", Op::RenameBranch, "Rename a branch"),
115116 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
116117 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"),
117128 a("archive", Op::ArchiveRepo, "Make it read-only"),
118129 a("unarchive", Op::UnarchiveRepo, "Make it writable again"),
119130 a("set_visibility", Op::SetRepoVisibility, "Make it public or private"),
+0−0

Binary or large file; its contents are not shown.

+6−0
409409 "get_deploy_key",
410410 "create_deploy_key",
411411 "delete_deploy_key",
412+ // Moving a repository to g1t for good, and a remote's token and
413+ // levers, are a person's to decide.
414+ "move_mirror_to_g1t",
415+ "add_mirror_remote",
416+ "update_mirror_remote",
417+ "remove_mirror_remote",
412418 // Teams: who is in which, and what they reach, is for people.
413419 "create_team",
414420 "update_team",
+12−0
10081008 ("get_deploy_key", Scope::AccessRead),
10091009 ("create_deploy_key", Scope::AccessAdmin),
10101010 ("delete_deploy_key", Scope::AccessAdmin),
1011+ // Mirroring: a repository's links to other hosts. Reading them is
1012+ // reading the repository; syncing writes code; the rest is an admin's.
1013+ ("get_mirror", Scope::RepoRead),
1014+ ("sync_mirror", Scope::CodeWrite),
1015+ ("get_hand_back_plan", Scope::RepoAdmin),
1016+ ("take_over_mirror", Scope::RepoAdmin),
1017+ ("set_ci_failover", Scope::RepoAdmin),
1018+ ("hand_back_mirror", Scope::RepoAdmin),
1019+ ("move_mirror_to_g1t", Scope::RepoAdmin),
1020+ ("add_mirror_remote", Scope::RepoAdmin),
1021+ ("update_mirror_remote", Scope::RepoAdmin),
1022+ ("remove_mirror_remote", Scope::RepoAdmin),
10111023 // Webhooks.
10121024 ("list_webhooks", Scope::WebhooksRead),
10131025 ("list_webhook_deliveries", Scope::WebhooksRead),
+1−1
1515 use crate::{User, Viewer};
1616
1717 /// Every event a webhook can be sent, in the order people are shown them.
18−pub const EVENT_TYPES: [&str; 101] = [
18+pub const EVENT_TYPES: [&str; 105] = [
1919 "git.push",
2020 "branch.renamed",
2121 "repo.created",
+10−0
477477 ["get_deploy_key", "access:read"],
478478 ["create_deploy_key", "access:admin"],
479479 ["delete_deploy_key", "access:admin"],
480+ ["get_mirror", "repo:read"],
481+ ["sync_mirror", "code:write"],
482+ ["get_hand_back_plan", "repo:admin"],
483+ ["take_over_mirror", "repo:admin"],
484+ ["set_ci_failover", "repo:admin"],
485+ ["hand_back_mirror", "repo:admin"],
486+ ["move_mirror_to_g1t", "repo:admin"],
487+ ["add_mirror_remote", "repo:admin"],
488+ ["update_mirror_remote", "repo:admin"],
489+ ["remove_mirror_remote", "repo:admin"],
480490 ["list_webhooks", "webhooks:read"],
481491 ["list_webhook_deliveries", "webhooks:read"],
482492 ["create_webhook", "webhooks:admin"],