Skip to content
376 linesCodeBlameRaw
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
9use g1t_contracts::mirrors::{
10 MirrorActArgs, MirrorAddArgs, MirrorCiArgs, MirrorHandBackArgs, MirrorMoveInArgs, MirrorRemoveArgs, MirrorSettings,
11 MirrorSettingsArgs, MirrorViewArgs, RefDecision, RemoteProvider, RemoteRole,
12};
13use g1t_contracts::repos::{GetArgs, Repo};
14use g1t_contracts::{FailureCode, Outcome, Viewer};
15use serde_json::{Value, json};
16use std::collections::BTreeMap;
17use worker::Result;
18
19use crate::operations::{Services, repo_path};
20
21/// One operation on a repository's mirroring.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum MirrorsOp {
24 GetMirror,
25 GetHandBackPlan,
26 TakeOver,
27 SetCiFailover,
28 HandBack,
29 MoveToG1t,
30 SyncMirror,
31 AddRemote,
32 UpdateRemote,
33 RemoveRemote,
34}
35
36impl 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`, `checks_problem` (why g1t could not report checks to the remote, such as the GitHub App lacking the Commit statuses permission; null once a report goes through) and its `settings`. `can_manage` says whether you may change them. A mirror standing by is read-only on g1t and runs nothing. Needs read access.",
87 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`, `report_checks` (a GitHub mirror: report the statuses g1t sets on a commit, such as workflow runs and deploys, to the commit on GitHub as `g1t / <context>` while g1t runs its workflows; on by default), and for a follower `remote_pushes` (`adopt` fast-forwards made there, or `overwrite` them). Needs the Admin role; agents' tokens are refused.",
95 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 "report_checks": { "type": "boolean" },
150 "remote_pushes": { "type": "string", "enum": ["adopt", "overwrite"] },
151 }),
152 &["repo", "id"],
153 ),
154 MirrorsOp::RemoveRemote => (json!({ "repo": repo, "id": id }), &["repo", "id"]),
155 };
156 json!({ "type": "object", "properties": properties, "required": required })
157 }
158}
159
160fn text(input: &Value, key: &str) -> String {
161 input[key].as_str().map(str::trim).unwrap_or_default().to_owned()
162}
163
164/// A boolean as sent: JSON, or a word from a form.
165fn flag(value: &Value) -> Option<bool> {
166 match value {
167 Value::Bool(flag) => Some(*flag),
168 Value::String(word) => match word.trim().to_ascii_lowercase().as_str() {
169 "true" | "1" => Some(true),
170 "false" | "0" => Some(false),
171 _ => None,
172 },
173 _ => None,
174 }
175}
176
177/// `settings` with what `input` changes, in `snake_case` as sent.
178pub fn settings_from(mut settings: MirrorSettings, input: &Value) -> std::result::Result<MirrorSettings, String> {
179 let word = |key: &str| input.get(key).filter(|value| !value.is_null()).map(|value| value.as_str().unwrap_or_default());
180 if let Some(notify) = word("notify") {
181 settings.notify = serde_json::from_value(json!(notify)).map_err(|_| "notify is banner or inbox.")?;
182 }
183 if let Some(value) = input.get("take_over_after") {
184 settings.take_over_after = match value {
185 Value::Null => None,
186 value => Some(value.as_u64().map(|minutes| minutes as u32).ok_or("take_over_after is a number of minutes, or null.")?),
187 };
188 }
189 if let Some(hand_back) = word("hand_back") {
190 settings.hand_back = serde_json::from_value(json!(hand_back)).map_err(|_| "hand_back is when_clean or ask.")?;
191 }
192 if let Some(remote_pushes) = word("remote_pushes") {
193 settings.remote_pushes = serde_json::from_value(json!(remote_pushes)).map_err(|_| "remote_pushes is adopt or overwrite.")?;
194 }
195 for (key, field) in [
196 ("keep_ci_warm", &mut settings.keep_ci_warm),
197 ("github_workflows", &mut settings.github_workflows),
198 ("hold_deploys", &mut settings.hold_deploys),
199 ("report_checks", &mut settings.report_checks),
200 ] {
201 if let Some(value) = input.get(key).filter(|value| !value.is_null()) {
202 *field = flag(value).ok_or_else(|| format!("{key} is true or false."))?;
203 }
204 }
205 Ok(settings)
206}
207
208/// `decisions` as sent: ref to `keep_ours`, `keep_theirs` or `pull_request`.
209pub fn decisions_from(input: &Value) -> std::result::Result<BTreeMap<String, RefDecision>, String> {
210 let Some(decisions) = input.get("decisions").filter(|value| !value.is_null()) else {
211 return Ok(BTreeMap::new());
212 };
213 let Some(decisions) = decisions.as_object() else {
214 return Err("decisions is an object from ref to keep_ours, keep_theirs or pull_request.".to_owned());
215 };
216 decisions
217 .iter()
218 .map(|(git_ref, decision)| {
219 let decision = serde_json::from_value(decision.clone())
220 .map_err(|_| format!("{git_ref}: say keep_ours, keep_theirs or pull_request."))?;
221 let git_ref = if git_ref.starts_with("refs/") { git_ref.clone() } else { format!("refs/heads/{git_ref}") };
222 Ok((git_ref, decision))
223 })
224 .collect()
225}
226
227pub async fn run(op: MirrorsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
228 let Some(path) = repo_path(input) else {
229 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
230 };
231 let found: Outcome<Repo> = g1t_kit::call(&services.repos, "get", &GetArgs { path, viewer: viewer.clone() }).await?;
232 let repo = match found {
233 Outcome::Ok(repo) => repo,
234 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
235 };
236 let integrations = &services.integrations;
237 if op == MirrorsOp::GetMirror {
238 return g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await;
239 }
240 let Some(actor) = viewer.clone() else {
241 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to change a repository's mirroring."));
242 };
243 let act = || MirrorActArgs { actor: actor.clone(), repo_id: repo.id.clone() };
244 let id = text(input, "id");
245 if matches!(op, MirrorsOp::UpdateRemote | MirrorsOp::RemoveRemote) && id.is_empty() {
246 return Ok(Outcome::fail(FailureCode::Invalid, "Name the remote by its id (rmt_…), from get_mirror."));
247 }
248 match op {
249 MirrorsOp::GetMirror => unreachable!("answered above"),
250 MirrorsOp::GetHandBackPlan => g1t_kit::call(integrations, "mirror_hand_back_plan", &act()).await,
251 MirrorsOp::TakeOver => g1t_kit::call(integrations, "mirror_take_over", &act()).await,
252 MirrorsOp::SyncMirror => g1t_kit::call(integrations, "mirror_sync", &act()).await,
253 MirrorsOp::SetCiFailover => {
254 let Some(on) = flag(&input["on"]) else {
255 return Ok(Outcome::fail(FailureCode::Invalid, "Say on: true to start CI failover, or false to end it."));
256 };
257 g1t_kit::call(integrations, "mirror_ci", &MirrorCiArgs { actor, repo_id: repo.id, on }).await
258 }
259 MirrorsOp::HandBack => {
260 let decisions = match decisions_from(input) {
261 Ok(decisions) => decisions,
262 Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
263 };
264 g1t_kit::call(integrations, "mirror_hand_back", &MirrorHandBackArgs { actor, repo_id: repo.id, decisions }).await
265 }
266 MirrorsOp::MoveToG1t => {
267 let keep_remote_updated = match &input["keep_remote_updated"] {
268 Value::Null => false,
269 value => match flag(value) {
270 Some(keep) => keep,
271 None => return Ok(Outcome::fail(FailureCode::Invalid, "keep_remote_updated is true or false.")),
272 },
273 };
274 g1t_kit::call(integrations, "mirror_move_in", &MirrorMoveInArgs { actor, repo_id: repo.id, keep_remote_updated }).await
275 }
276 MirrorsOp::AddRemote => {
277 let provider = match text(input, "provider").as_str() {
278 "g1t" => RemoteProvider::G1t,
279 "git" => RemoteProvider::Git,
280 "github" => {
281 return Ok(Outcome::fail(
282 FailureCode::Invalid,
283 "GitHub repositories are linked by importing them through g1t's GitHub App.",
284 ));
285 }
286 _ => return Ok(Outcome::fail(FailureCode::Invalid, "provider is g1t or git.")),
287 };
288 let role = match text(input, "role").as_str() {
289 "leader" => RemoteRole::Leader,
290 "follower" => RemoteRole::Follower,
291 _ => return Ok(Outcome::fail(FailureCode::Invalid, "role is follower (g1t leads) or leader (the remote leads).")),
292 };
293 let username = Some(text(input, "username")).filter(|name| !name.is_empty());
294 let token = Some(text(input, "token")).filter(|token| !token.is_empty());
295 let args = MirrorAddArgs { actor, repo_id: repo.id, provider, role, url: text(input, "url"), username, token };
296 g1t_kit::call(integrations, "mirror_add", &args).await
297 }
298 MirrorsOp::UpdateRemote => {
299 let view: Outcome<g1t_contracts::mirrors::MirrorView> =
300 g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?;
301 let view = match view {
302 Outcome::Ok(view) => view,
303 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
304 };
305 let Some(remote) = view.remotes.into_iter().find(|remote| remote.id == id) else {
306 return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's."));
307 };
308 let settings = match settings_from(remote.settings, input) {
309 Ok(settings) => settings,
310 Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
311 };
312 g1t_kit::call(integrations, "mirror_settings", &MirrorSettingsArgs { actor, remote_id: id, settings }).await
313 }
314 MirrorsOp::RemoveRemote => {
315 let view: Outcome<g1t_contracts::mirrors::MirrorView> =
316 g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?;
317 if !matches!(&view, Outcome::Ok(view) if view.remotes.iter().any(|remote| remote.id == id)) {
318 return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's."));
319 }
320 g1t_kit::call(integrations, "mirror_remove", &MirrorRemoveArgs { actor, remote_id: id }).await
321 }
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use g1t_contracts::credentials::NEVER;
329 use g1t_contracts::mirrors::{HandBack, Notify, RemotePushes};
330 use g1t_contracts::scopes::{Level, scope_for};
331
332 #[test]
333 fn each_operation_is_described_and_scoped() {
334 for op in MirrorsOp::ALL {
335 assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Mirrors(op)), "{}", op.name());
336 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
337 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
338 let level = scope_for(op.name()).unwrap_or_else(|| panic!("{} has no scope", op.name())).level();
339 let expected = match op {
340 MirrorsOp::GetMirror => Level::Read,
341 MirrorsOp::SyncMirror => Level::Write,
342 _ => Level::Admin,
343 };
344 assert_eq!(level, expected, "{}", op.name());
345 }
346 for op in [MirrorsOp::MoveToG1t, MirrorsOp::AddRemote, MirrorsOp::UpdateRemote, MirrorsOp::RemoveRemote] {
347 assert!(NEVER.contains(&op.name()), "agents never {}", op.name());
348 }
349 }
350
351 #[test]
352 fn settings_change_only_what_is_sent() {
353 let base = MirrorSettings::default();
354 let changed = settings_from(base.clone(), &json!({ "notify": "inbox", "take_over_after": 30, "keep_ci_warm": "true" })).unwrap();
355 assert_eq!(changed.notify, Notify::Inbox);
356 assert_eq!(changed.take_over_after, Some(30));
357 assert!(changed.keep_ci_warm);
358 assert_eq!(changed.hand_back, HandBack::WhenClean);
359 assert_eq!(changed.remote_pushes, RemotePushes::Adopt);
360 assert!(changed.report_checks, "checks go to GitHub unless turned off");
361 assert!(!settings_from(base.clone(), &json!({ "report_checks": false })).unwrap().report_checks);
362 let cleared = settings_from(changed, &json!({ "take_over_after": null })).unwrap();
363 assert_eq!(cleared.take_over_after, None);
364 assert!(settings_from(base.clone(), &json!({ "notify": "pager" })).is_err());
365 assert!(settings_from(base, &json!({ "hold_deploys": "maybe" })).is_err());
366 }
367
368 #[test]
369 fn decisions_name_refs_or_branches() {
370 let decisions = decisions_from(&json!({ "decisions": { "docs": "keep_theirs", "refs/tags/v1": "keep_ours" } })).unwrap();
371 assert_eq!(decisions.get("refs/heads/docs"), Some(&RefDecision::KeepTheirs));
372 assert_eq!(decisions.get("refs/tags/v1"), Some(&RefDecision::KeepOurs));
373 assert!(decisions_from(&json!({ "decisions": { "docs": "merge" } })).is_err());
374 assert!(decisions_from(&json!({})).unwrap().is_empty());
375 }
376}