Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'mirroring' into artifacts-mode | 1 | //! Mirroring: a repository kept in step with copies of it on other hosts. |
| 2 | //! | |
| 3 | //! Every linked repository has exactly one leader, where work happens; the | |
| 4 | //! others follow it. A **mirror** follows a remote that leads (GitHub, | |
| 5 | //! another g1t, any git host). While it stands by it is an exact, read-only | |
| 6 | //! copy that runs nothing. Someone can **take over**: g1t leads for a | |
| 7 | //! while, then **hands back**, sending what was done to the remote. A | |
| 8 | //! repository g1t leads can be **mirrored to** any number of followers. | |
| 9 | //! | |
| 10 | //! The integrations service keeps the links (`remotes`) and decides; the | |
| 11 | //! repos service keeps each repository's [`RepoMirror`], so pushes and | |
| 12 | //! merges are refused or allowed without asking anyone. | |
| A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so. | 13 | //! |
| 14 | //! What a mirror offers on g1t is one rule, [`RepoMirror::offers`], by | |
| 15 | //! state (`mirrorOffers` in the TypeScript contracts): | |
| 16 | //! | |
| 17 | //! | Area | Standing by | CI failover | Taken over | Handing back | | |
| 18 | //! | --- | --- | --- | --- | --- | | |
| 19 | //! | `Code`: files, commits, branches, tags, compare, releases (read) | ✓ | ✓ | ✓ | ✓ | | |
| 20 | //! | `Security`: what scanning found, read-only | ✓ | ✓ | ✓ | ✓ | | |
| 21 | //! | `Insights`, `Settings` (mirroring settings included) | ✓ | ✓ | ✓ | ✓ | | |
| 22 | //! | `Workflows`: runs on g1t | with Keep CI warm | ✓ | ✓ | – | | |
| 23 | //! | `Issues`, `Pulls`, `Agents`, `Deployments` | – | – | ✓ | – | | |
| 24 | //! | `Updates`: security and dependency updates, upkeep, anything that opens a branch or a pull request on its own | – | – | ✓ | – | | |
| 25 | //! | |
| 26 | //! The sidebar lists only what is offered, a page not offered says so in | |
| 27 | //! place of its list or form, every service refuses the writes, and g1t's | |
| 28 | //! own automation checks the same rule before it opens anything. | |
| Merge branch 'mirroring' into artifacts-mode | 29 | |
| 30 | use std::collections::BTreeMap; | |
| 31 | ||
| 32 | use serde::{Deserialize, Serialize}; | |
| 33 | ||
| 34 | use crate::User; | |
| 35 | ||
| 36 | /// Where a mirror stands with the remote it follows. | |
| 37 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 38 | #[serde(rename_all = "snake_case")] | |
| 39 | pub enum MirrorState { | |
| 40 | /// A quiet copy: it follows every push and runs nothing. | |
| 41 | #[default] | |
| 42 | Standby, | |
| 43 | /// The remote keeps the code; g1t runs its workflows (CI failover). | |
| 44 | Ci, | |
| 45 | /// g1t leads for now: pushes, pull requests, agents and workflows work. | |
| 46 | Takeover, | |
| 47 | /// Sending what was done during a takeover back to the remote. The | |
| 48 | /// repository is read-only until it is done. | |
| 49 | HandingBack, | |
| 50 | } | |
| 51 | ||
| 52 | impl MirrorState { | |
| 53 | pub fn as_str(self) -> &'static str { | |
| 54 | match self { | |
| 55 | MirrorState::Standby => "standby", | |
| 56 | MirrorState::Ci => "ci", | |
| 57 | MirrorState::Takeover => "takeover", | |
| 58 | MirrorState::HandingBack => "handing_back", | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | pub fn parse(text: &str) -> Option<MirrorState> { | |
| 63 | Some(match text { | |
| 64 | "standby" => MirrorState::Standby, | |
| 65 | "ci" => MirrorState::Ci, | |
| 66 | "takeover" => MirrorState::Takeover, | |
| 67 | "handing_back" => MirrorState::HandingBack, | |
| 68 | _ => return None, | |
| 69 | }) | |
| 70 | } | |
| 71 | ||
| 72 | /// Whether g1t leads, so the repository takes writes. | |
| 73 | pub fn leads(self) -> bool { | |
| 74 | self == MirrorState::Takeover | |
| 75 | } | |
| 76 | ||
| 77 | /// Whether g1t copies the remote's pushes in. | |
| 78 | pub fn follows(self) -> bool { | |
| 79 | matches!(self, MirrorState::Standby | MirrorState::Ci) | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | /// A repository's tie to the remote it mirrors, as the repos service keeps | |
| 84 | /// it on [`crate::repos::Repo`]. Absent for a repository that leads. | |
| 85 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 86 | #[serde(rename_all = "camelCase")] | |
| 87 | pub struct RepoMirror { | |
| 88 | pub state: MirrorState, | |
| 89 | /// The remote, for people: `github.com/acme/web`. | |
| 90 | pub remote: String, | |
| 91 | /// Its web address. | |
| 92 | pub url: String, | |
| 93 | /// RFC 3339: when it entered this state. | |
| 94 | pub since: String, | |
| 95 | /// Run `.g1t/workflows` on pushes copied in while it stands by. | |
| 96 | #[serde(default)] | |
| 97 | pub warm: bool, | |
| 98 | /// Run `.github/workflows` as well, in CI failover and during a takeover. | |
| 99 | #[serde(default)] | |
| 100 | pub github_workflows: bool, | |
| 101 | /// Hold jobs that name an `environment:` for approval, in CI failover | |
| 102 | /// and during a takeover, so nothing deploys twice. | |
| 103 | #[serde(default)] | |
| 104 | pub hold_deploys: bool, | |
| 105 | } | |
| 106 | ||
| 107 | impl RepoMirror { | |
| 108 | /// Whether the repository takes pushes, merges, issues and agents. | |
| 109 | pub fn writable(&self) -> bool { | |
| 110 | self.state.leads() | |
| 111 | } | |
| A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so. | 112 | |
| 113 | /// Whether the repository offers `area` now: the table in this | |
| 114 | /// module's comment. A mirror g1t has taken over offers everything; | |
| 115 | /// one standing by or handing back is a read-only copy (code, what | |
| 116 | /// security scanning found, insights, settings). Workflows run in CI | |
| 117 | /// failover, and while standing by only when the link keeps CI warm. | |
| 118 | pub fn offers(&self, area: RepoArea) -> bool { | |
| 119 | if self.writable() || area.read_only() { | |
| 120 | return true; | |
| 121 | } | |
| 122 | match area { | |
| 123 | RepoArea::Workflows => self.state == MirrorState::Ci || (self.state == MirrorState::Standby && self.warm), | |
| 124 | _ => false, | |
| 125 | } | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | /// The parts of a repository a mirror may or may not offer: the sidebar's | |
| 130 | /// pages, and `Updates` for what g1t does on its own (security and | |
| 131 | /// dependency updates, upkeep, anything that opens a branch or a pull | |
| 132 | /// request without being asked). | |
| 133 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 134 | #[serde(rename_all = "snake_case")] | |
| 135 | pub enum RepoArea { | |
| 136 | Code, | |
| 137 | Issues, | |
| 138 | Pulls, | |
| 139 | Agents, | |
| 140 | Workflows, | |
| 141 | Deployments, | |
| 142 | Security, | |
| 143 | Insights, | |
| 144 | Settings, | |
| 145 | Updates, | |
| Merge branch 'mirroring' into artifacts-mode | 146 | } |
| 147 | ||
| A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so. | 148 | impl RepoArea { |
| 149 | pub const ALL: [RepoArea; 10] = [ | |
| 150 | RepoArea::Code, | |
| 151 | RepoArea::Issues, | |
| 152 | RepoArea::Pulls, | |
| 153 | RepoArea::Agents, | |
| 154 | RepoArea::Workflows, | |
| 155 | RepoArea::Deployments, | |
| 156 | RepoArea::Security, | |
| 157 | RepoArea::Insights, | |
| 158 | RepoArea::Settings, | |
| 159 | RepoArea::Updates, | |
| 160 | ]; | |
| 161 | ||
| 162 | /// Whether every mirror offers it, whatever its state: an exact copy, read. | |
| 163 | pub fn read_only(self) -> bool { | |
| 164 | matches!(self, RepoArea::Code | RepoArea::Security | RepoArea::Insights | RepoArea::Settings) | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | /// Whether a repository with `mirror` offers `area`: everything when it | |
| 169 | /// leads, else [`RepoMirror::offers`]. | |
| 170 | pub fn offers(mirror: Option<&RepoMirror>, area: RepoArea) -> bool { | |
| 171 | mirror.is_none_or(|mirror| mirror.offers(area)) | |
| 172 | } | |
| 173 | ||
| 174 | /// Why g1t's own updates wait on a mirror: the sentence the security pages | |
| 175 | /// and the service share (`mirrorUpdatesWait` in the TypeScript contracts). | |
| 176 | pub fn updates_wait_message(namespace: &str, name: &str, mirror: &RepoMirror) -> String { | |
| 177 | format!( | |
| 178 | "Security updates wait until {namespace}/{name} is taken over or moved to g1t. It is a mirror of {remote}, so nothing opens a branch or a pull request here on its own.", | |
| 179 | remote = mirror.remote | |
| 180 | ) | |
| 181 | } | |
| 182 | ||
| Merge branch 'mirroring' into artifacts-mode | 183 | /// Why a mirror refuses a write, for people and for `git push`. |
| 184 | pub fn mirror_message(namespace: &str, name: &str, mirror: &RepoMirror) -> String { | |
| 185 | match mirror.state { | |
| 186 | MirrorState::HandingBack => format!( | |
| 187 | "{namespace}/{name} is handing back to {}. It takes changes again once that is done.", | |
| 188 | mirror.remote | |
| 189 | ), | |
| 190 | _ => format!( | |
| 191 | "{namespace}/{name} is a mirror of {remote}, so it is read-only here. Push to {remote}, or take over in Settings → Mirroring to work on g1t.", | |
| 192 | remote = mirror.remote | |
| 193 | ), | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | /// The kinds of host a remote can be. Each is an adapter in integrations | |
| 198 | /// (`src/remotes.rs`); a new host is one more arm. | |
| 199 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 200 | #[serde(rename_all = "snake_case")] | |
| 201 | pub enum RemoteProvider { | |
| 202 | /// Through g1t's GitHub App. | |
| 203 | Github, | |
| 204 | /// Another g1t: g1t.sh, or one run by its owner. | |
| 205 | G1t, | |
| 206 | /// Any git host over HTTPS, with a username and token. | |
| 207 | Git, | |
| 208 | } | |
| 209 | ||
| 210 | impl RemoteProvider { | |
| 211 | pub fn as_str(self) -> &'static str { | |
| 212 | match self { | |
| 213 | RemoteProvider::Github => "github", | |
| 214 | RemoteProvider::G1t => "g1t", | |
| 215 | RemoteProvider::Git => "git", | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | pub fn parse(text: &str) -> Option<RemoteProvider> { | |
| 220 | Some(match text { | |
| 221 | "github" => RemoteProvider::Github, | |
| 222 | "g1t" => RemoteProvider::G1t, | |
| 223 | "git" => RemoteProvider::Git, | |
| 224 | _ => return None, | |
| 225 | }) | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | /// Which side leads. | |
| 230 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 231 | #[serde(rename_all = "snake_case")] | |
| 232 | pub enum RemoteRole { | |
| 233 | /// The remote leads; the repository on g1t is its mirror. | |
| 234 | Leader, | |
| 235 | /// g1t leads; the remote is kept in step with it. | |
| 236 | Follower, | |
| 237 | } | |
| 238 | ||
| 239 | impl RemoteRole { | |
| 240 | pub fn as_str(self) -> &'static str { | |
| 241 | match self { | |
| 242 | RemoteRole::Leader => "leader", | |
| 243 | RemoteRole::Follower => "follower", | |
| 244 | } | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | /// A link's state. A leader is `standby`, `ci`, `takeover` or | |
| 249 | /// `handing_back` (see [`MirrorState`]); a follower is `following`, or | |
| 250 | /// `stuck` when the remote refused what g1t sent. | |
| 251 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 252 | #[serde(rename_all = "snake_case")] | |
| 253 | pub enum RemoteState { | |
| 254 | Standby, | |
| 255 | Ci, | |
| 256 | Takeover, | |
| 257 | HandingBack, | |
| 258 | Following, | |
| 259 | Stuck, | |
| 260 | } | |
| 261 | ||
| 262 | impl RemoteState { | |
| 263 | pub fn as_str(self) -> &'static str { | |
| 264 | match self { | |
| 265 | RemoteState::Standby => "standby", | |
| 266 | RemoteState::Ci => "ci", | |
| 267 | RemoteState::Takeover => "takeover", | |
| 268 | RemoteState::HandingBack => "handing_back", | |
| 269 | RemoteState::Following => "following", | |
| 270 | RemoteState::Stuck => "stuck", | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | pub fn parse(text: &str) -> RemoteState { | |
| 275 | match text { | |
| 276 | "ci" => RemoteState::Ci, | |
| 277 | "takeover" => RemoteState::Takeover, | |
| 278 | "handing_back" => RemoteState::HandingBack, | |
| 279 | "following" => RemoteState::Following, | |
| 280 | "stuck" => RemoteState::Stuck, | |
| 281 | _ => RemoteState::Standby, | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | /// The mirror state of a leader in this state. | |
| 286 | pub fn mirror(self) -> Option<MirrorState> { | |
| 287 | MirrorState::parse(self.as_str()) | |
| 288 | } | |
| 289 | } | |
| 290 | ||
| 291 | /// Who hears that a remote stopped answering. Nobody is woken up unless | |
| 292 | /// they asked to be. | |
| 293 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 294 | #[serde(rename_all = "snake_case")] | |
| 295 | pub enum Notify { | |
| 296 | /// The repository shows it, and that is all. | |
| 297 | #[default] | |
| 298 | Banner, | |
| 299 | /// Also an inbox item for the repository's admins. | |
| 300 | Inbox, | |
| 301 | } | |
| 302 | ||
| 303 | /// When a takeover is handed back once the remote answers again. | |
| 304 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 305 | #[serde(rename_all = "snake_case")] | |
| 306 | pub enum HandBack { | |
| 307 | /// Only when someone hands it back. | |
| 308 | Ask, | |
| 309 | /// On its own when every branch goes back without a decision. | |
| 310 | #[default] | |
| 311 | WhenClean, | |
| 312 | } | |
| 313 | ||
| 314 | /// What a follower does about pushes made on the remote itself. | |
| 315 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 316 | #[serde(rename_all = "snake_case")] | |
| 317 | pub enum RemotePushes { | |
| 318 | /// Fast-forwards are taken in; anything else is shown as diverged. | |
| 319 | #[default] | |
| 320 | Adopt, | |
| 321 | /// g1t's branches are pushed over them (what they pointed at is kept). | |
| 322 | Overwrite, | |
| 323 | } | |
| 324 | ||
| 325 | /// The levers on a link. Every one defaults to doing nothing on its own. | |
| 326 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 327 | #[serde(rename_all = "camelCase", default)] | |
| 328 | pub struct MirrorSettings { | |
| 329 | pub notify: Notify, | |
| 330 | /// Take over on its own once the remote has not answered for this many | |
| 331 | /// minutes. `None`: only when someone takes over. | |
| 332 | pub take_over_after: Option<u32>, | |
| 333 | pub hand_back: HandBack, | |
| 334 | /// Run `.g1t/workflows` on pushes copied in while standing by. | |
| 335 | pub keep_ci_warm: bool, | |
| 336 | /// Run `.github/workflows` in CI failover and during a takeover. | |
| 337 | pub github_workflows: bool, | |
| 338 | /// Hold deploy jobs for approval in CI failover and during a takeover. | |
| 339 | pub hold_deploys: bool, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 340 | /// Report the statuses g1t sets on a commit (workflow runs, deploys) |
| 341 | /// to the GitHub commit, while g1t runs workflows for the mirror: CI | |
| 342 | /// failover, a takeover, or CI kept warm. GitHub links only. | |
| 343 | pub report_checks: bool, | |
| Merge branch 'mirroring' into artifacts-mode | 344 | /// For followers. |
| 345 | pub remote_pushes: RemotePushes, | |
| 346 | } | |
| 347 | ||
| 348 | impl Default for MirrorSettings { | |
| 349 | fn default() -> Self { | |
| 350 | MirrorSettings { | |
| 351 | notify: Notify::Banner, | |
| 352 | take_over_after: None, | |
| 353 | hand_back: HandBack::WhenClean, | |
| 354 | keep_ci_warm: false, | |
| 355 | github_workflows: true, | |
| 356 | hold_deploys: true, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 357 | report_checks: true, |
| Merge branch 'mirroring' into artifacts-mode | 358 | remote_pushes: RemotePushes::Adopt, |
| 359 | } | |
| 360 | } | |
| 361 | } | |
| 362 | ||
| 363 | /// The shortest and longest wait a person may set before an automatic | |
| 364 | /// takeover. | |
| 365 | pub const TAKE_OVER_AFTER_MINUTES: (u32, u32) = (5, 24 * 60); | |
| 366 | ||
| 367 | /// A repository's link to a remote. | |
| 368 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 369 | #[serde(rename_all = "camelCase")] | |
| 370 | pub struct Remote { | |
| 371 | pub id: String, | |
| 372 | pub repo_id: String, | |
| 373 | /// `workspace/name` on g1t. | |
| 374 | pub repo: String, | |
| 375 | pub provider: RemoteProvider, | |
| 376 | pub role: RemoteRole, | |
| 377 | /// For people: `github.com/acme/web`. | |
| 378 | pub name: String, | |
| 379 | /// Its web address. | |
| 380 | pub url: String, | |
| 381 | pub state: RemoteState, | |
| 382 | pub state_since: String, | |
| 383 | /// Who put it in this state: a username, or `g1t` when it was automatic. | |
| 384 | pub state_by: Option<String>, | |
| 385 | /// Whether its host answers. A remote that refuses g1t's credential | |
| 386 | /// still answers: that is [`Remote::last_error`]. | |
| 387 | pub reachable: bool, | |
| 388 | pub unreachable_since: Option<String>, | |
| 389 | pub synced_at: Option<String>, | |
| 390 | pub last_error: Option<String>, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 391 | /// Why g1t could not report checks to the remote, until it next can: |
| 392 | /// the GitHub App lacks the Commit statuses permission, say. | |
| 393 | #[serde(default)] | |
| 394 | pub checks_problem: Option<String>, | |
| Merge branch 'mirroring' into artifacts-mode | 395 | pub settings: MirrorSettings, |
| 396 | pub created_at: String, | |
| 397 | } | |
| 398 | ||
| 399 | /// What happens to one ref when a takeover is handed back. | |
| 400 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 401 | #[serde(rename_all = "snake_case")] | |
| 402 | pub enum RefAction { | |
| 403 | /// Nothing moved on either side, or both moved to the same commit. | |
| 404 | Same, | |
| 405 | /// Only g1t moved: pushed to the remote. | |
| 406 | Push, | |
| 407 | /// Only the remote moved: copied in. | |
| 408 | Fetch, | |
| 409 | /// g1t moved, but the remote protects the branch: sent as a pull | |
| 410 | /// request from `g1t/handback/<branch>`, and g1t follows the remote. | |
| 411 | PullRequest, | |
| 412 | /// Both moved, apart. Waits for a [`RefDecision`]. | |
| 413 | Diverged, | |
| 414 | } | |
| 415 | ||
| 416 | /// A person's decision for a diverged ref. | |
| 417 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 418 | #[serde(rename_all = "snake_case")] | |
| 419 | pub enum RefDecision { | |
| 420 | /// g1t's commit is pushed over the remote's. | |
| 421 | KeepOurs, | |
| 422 | /// The remote's commit is taken; g1t's is kept under `refs/g1t/replaced/`. | |
| 423 | KeepTheirs, | |
| 424 | /// g1t's commits go to the remote as a pull request. | |
| 425 | PullRequest, | |
| 426 | } | |
| 427 | ||
| 428 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 429 | #[serde(rename_all = "camelCase")] | |
| 430 | pub struct RefPlan { | |
| 431 | #[serde(rename = "ref")] | |
| 432 | pub git_ref: String, | |
| 433 | /// The commit both sides agreed on when the takeover began. | |
| 434 | pub base: Option<String>, | |
| 435 | pub ours: Option<String>, | |
| 436 | pub theirs: Option<String>, | |
| 437 | pub action: RefAction, | |
| 438 | /// For a diverged ref, what someone decided. | |
| 439 | pub decision: Option<RefDecision>, | |
| 440 | } | |
| 441 | ||
| 442 | /// What handing a takeover back would do, ref by ref. | |
| 443 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 444 | #[serde(rename_all = "camelCase")] | |
| 445 | pub struct HandbackPlan { | |
| 446 | pub refs: Vec<RefPlan>, | |
| 447 | /// Whether the remote answered, so the plan reflects it. | |
| 448 | pub reachable: bool, | |
| 449 | /// Whether it can go back now: the remote answers and every diverged | |
| 450 | /// ref has a decision. | |
| 451 | pub ready: bool, | |
| 452 | } | |
| 453 | ||
| 454 | /// Decides one ref of a hand-back. `base` is what both sides agreed on when | |
| 455 | /// the takeover began. A ref only g1t moved goes back by push, or by pull | |
| 456 | /// request when the remote protects it. | |
| 457 | pub fn ref_action(base: Option<&str>, ours: Option<&str>, theirs: Option<&str>, protected: bool) -> RefAction { | |
| 458 | if ours == theirs { | |
| 459 | return RefAction::Same; | |
| 460 | } | |
| 461 | let ours_moved = ours != base; | |
| 462 | let theirs_moved = theirs != base; | |
| 463 | match (ours_moved, theirs_moved) { | |
| 464 | (false, _) => RefAction::Fetch, | |
| 465 | (true, false) if protected && ours.is_some() && theirs.is_some() => RefAction::PullRequest, | |
| 466 | (true, false) => RefAction::Push, | |
| 467 | (true, true) => RefAction::Diverged, | |
| 468 | } | |
| 469 | } | |
| 470 | ||
| 471 | impl HandbackPlan { | |
| 472 | pub fn new(refs: Vec<RefPlan>, reachable: bool) -> Self { | |
| 473 | let ready = reachable | |
| 474 | && refs | |
| 475 | .iter() | |
| 476 | .all(|plan| plan.action != RefAction::Diverged || plan.decision.is_some()); | |
| 477 | HandbackPlan { refs, reachable, ready } | |
| 478 | } | |
| 479 | ||
| 480 | /// Whether it needs nobody: no ref is diverged. | |
| 481 | pub fn clean(&self) -> bool { | |
| 482 | self.reachable && !self.refs.iter().any(|plan| plan.action == RefAction::Diverged) | |
| 483 | } | |
| 484 | } | |
| 485 | ||
| 486 | /// Everything about a repository's links, for its pages and API. | |
| 487 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 488 | #[serde(rename_all = "camelCase")] | |
| 489 | pub struct MirrorView { | |
| 490 | pub remotes: Vec<Remote>, | |
| 491 | /// While a takeover is on or being handed back. | |
| 492 | pub plan: Option<HandbackPlan>, | |
| 493 | /// Whether the viewer may change the links and take over. | |
| 494 | pub can_manage: bool, | |
| 495 | /// What the last action did that people should know: pull requests it | |
| 496 | /// opened, branches it could not bring up to date. | |
| 497 | #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 498 | pub notes: Vec<String>, | |
| 499 | } | |
| 500 | ||
| 501 | /// A repository's links in brief, for lists. | |
| 502 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 503 | #[serde(rename_all = "camelCase")] | |
| 504 | pub struct RemoteBrief { | |
| 505 | pub repo_id: String, | |
| 506 | pub role: RemoteRole, | |
| 507 | pub name: String, | |
| 508 | pub state: RemoteState, | |
| 509 | pub reachable: bool, | |
| 510 | /// When g1t last copied from it or pushed to it. | |
| 511 | #[serde(default)] | |
| 512 | pub synced_at: Option<String>, | |
| 513 | } | |
| 514 | ||
| 515 | /// The payload of the `mirror.*` events: `mirror.unreachable` (a mirror's | |
| 516 | /// remote stopped answering), `mirror.reachable` (it answers again), | |
| 517 | /// `mirror.state_changed` (stood by, CI failover, taken over, handing back, | |
| 518 | /// handed back) and `mirror.moved_in` (moved to g1t for good). | |
| 519 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 520 | #[serde(rename_all = "camelCase")] | |
| 521 | pub struct MirrorEvent { | |
| 522 | pub repo_id: String, | |
| 523 | /// `workspace/name`. | |
| 524 | pub repo: String, | |
| 525 | pub remote_id: String, | |
| 526 | /// `github.com/acme/web`. | |
| 527 | pub remote: String, | |
| 528 | /// A `RemoteState` as text; absent once moved in. | |
| 529 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 530 | pub state: Option<String>, | |
| 531 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 532 | pub from: Option<String>, | |
| 533 | /// Who did it: a username, or `g1t` when it happened on its own. | |
| 534 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 535 | pub by: Option<String>, | |
| 536 | /// What happened, for people. | |
| 537 | pub title: String, | |
| 538 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 539 | pub detail: Option<String>, | |
| 540 | /// Usernames to tell in their inbox (the workspace's owners, when the | |
| 541 | /// link's settings ask for it). | |
| 542 | #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 543 | pub notify: Vec<String>, | |
| 544 | /// The repository's mirroring settings page. | |
| 545 | pub link: String, | |
| 546 | } | |
| 547 | ||
| 548 | // --- Methods of the integrations service ---------------------------------- | |
| 549 | ||
| 550 | /// `mirror_view`: a repository's links, as the viewer may see them. | |
| 551 | #[derive(Debug, Serialize, Deserialize)] | |
| 552 | #[serde(rename_all = "camelCase")] | |
| 553 | pub struct MirrorViewArgs { | |
| 554 | pub viewer: Option<User>, | |
| 555 | pub repo_id: String, | |
| 556 | } | |
| 557 | ||
| 558 | /// `mirror_briefs`: the links of these repositories. Returns `[RemoteBrief]`. | |
| 559 | #[derive(Debug, Serialize, Deserialize)] | |
| 560 | #[serde(rename_all = "camelCase")] | |
| 561 | pub struct MirrorBriefsArgs { | |
| 562 | pub repo_ids: Vec<String>, | |
| 563 | } | |
| 564 | ||
| 565 | /// `mirror_take_over`, `mirror_hand_back_plan`, `mirror_sync`: one | |
| 566 | /// repository's mirror. Return `Outcome<MirrorView>`. | |
| 567 | #[derive(Debug, Serialize, Deserialize)] | |
| 568 | #[serde(rename_all = "camelCase")] | |
| 569 | pub struct MirrorActArgs { | |
| 570 | pub actor: User, | |
| 571 | pub repo_id: String, | |
| 572 | } | |
| 573 | ||
| 574 | /// `mirror_ci`: starts or ends CI failover. Returns `Outcome<MirrorView>`. | |
| 575 | #[derive(Debug, Serialize, Deserialize)] | |
| 576 | #[serde(rename_all = "camelCase")] | |
| 577 | pub struct MirrorCiArgs { | |
| 578 | pub actor: User, | |
| 579 | pub repo_id: String, | |
| 580 | pub on: bool, | |
| 581 | } | |
| 582 | ||
| 583 | /// `mirror_hand_back`: sends a takeover back, with decisions for diverged | |
| 584 | /// refs. Refused while a diverged ref has none. Returns | |
| 585 | /// `Outcome<MirrorView>`. | |
| 586 | #[derive(Debug, Serialize, Deserialize)] | |
| 587 | #[serde(rename_all = "camelCase")] | |
| 588 | pub struct MirrorHandBackArgs { | |
| 589 | pub actor: User, | |
| 590 | pub repo_id: String, | |
| 591 | #[serde(default)] | |
| 592 | pub decisions: BTreeMap<String, RefDecision>, | |
| 593 | } | |
| 594 | ||
| 595 | /// `mirror_settings`: changes a link's levers. Returns `Outcome<Remote>`. | |
| 596 | #[derive(Debug, Serialize, Deserialize)] | |
| 597 | #[serde(rename_all = "camelCase")] | |
| 598 | pub struct MirrorSettingsArgs { | |
| 599 | pub actor: User, | |
| 600 | pub remote_id: String, | |
| 601 | pub settings: MirrorSettings, | |
| 602 | } | |
| 603 | ||
| 604 | /// `mirror_add`: links a repository to a remote on another g1t or any git | |
| 605 | /// host. GitHub links are made by `github_import`. A leader can only be | |
| 606 | /// added to an empty repository, which is then filled from it. Returns | |
| 607 | /// `Outcome<Remote>`. | |
| 608 | #[derive(Debug, Serialize, Deserialize)] | |
| 609 | #[serde(rename_all = "camelCase")] | |
| 610 | pub struct MirrorAddArgs { | |
| 611 | pub actor: User, | |
| 612 | pub repo_id: String, | |
| 613 | pub provider: RemoteProvider, | |
| 614 | pub role: RemoteRole, | |
| 615 | /// The remote's https clone address. | |
| 616 | pub url: String, | |
| 617 | #[serde(default)] | |
| 618 | pub username: Option<String>, | |
| 619 | /// A token for it. Kept sealed; never shown again. | |
| 620 | #[serde(default)] | |
| 621 | pub token: Option<String>, | |
| 622 | } | |
| 623 | ||
| 624 | /// `mirror_move_in`: moves a mirror to g1t for good. The repository stops | |
| 625 | /// being a mirror and g1t stops tracking the remote: pushes made there no | |
| 626 | /// longer come here. Allowed while it stands by, in CI failover, or during | |
| 627 | /// a takeover (what g1t holds is kept as it is, nothing is handed back). | |
| 628 | /// With `keep_remote_updated`, the remote becomes a follower instead of | |
| 629 | /// being unlinked: g1t pushes to it from then on. Returns | |
| 630 | /// `Outcome<MirrorView>`. | |
| 631 | #[derive(Debug, Serialize, Deserialize)] | |
| 632 | #[serde(rename_all = "camelCase")] | |
| 633 | pub struct MirrorMoveInArgs { | |
| 634 | pub actor: User, | |
| 635 | pub repo_id: String, | |
| 636 | #[serde(default)] | |
| 637 | pub keep_remote_updated: bool, | |
| 638 | } | |
| 639 | ||
| 640 | /// `mirror_remove`: unlinks a remote. A mirror becomes an ordinary | |
| 641 | /// repository with what it has. Refused during a takeover. Returns | |
| 642 | /// `Outcome<bool>`. | |
| 643 | #[derive(Debug, Serialize, Deserialize)] | |
| 644 | #[serde(rename_all = "camelCase")] | |
| 645 | pub struct MirrorRemoveArgs { | |
| 646 | pub actor: User, | |
| 647 | pub remote_id: String, | |
| 648 | } | |
| 649 | ||
| 650 | #[cfg(test)] | |
| 651 | mod tests { | |
| 652 | use super::*; | |
| 653 | ||
| 654 | #[test] | |
| 655 | fn hand_back_decides_each_ref_from_the_base() { | |
| 656 | let (a, b, c) = (Some("a"), Some("b"), Some("c")); | |
| 657 | assert_eq!(ref_action(a, a, a, false), RefAction::Same); | |
| 658 | assert_eq!(ref_action(a, b, b, false), RefAction::Same, "both moved to the same commit"); | |
| 659 | assert_eq!(ref_action(a, b, a, false), RefAction::Push); | |
| 660 | assert_eq!(ref_action(a, b, a, true), RefAction::PullRequest, "a protected branch goes as a pull request"); | |
| 661 | assert_eq!(ref_action(a, a, b, false), RefAction::Fetch); | |
| 662 | assert_eq!(ref_action(a, b, c, false), RefAction::Diverged); | |
| 663 | assert_eq!(ref_action(None, b, None, true), RefAction::Push, "a new branch is pushed"); | |
| 664 | assert_eq!(ref_action(a, None, a, false), RefAction::Push, "a deleted branch is deleted there"); | |
| 665 | assert_eq!(ref_action(None, None, b, false), RefAction::Fetch, "a branch made there is copied in"); | |
| 666 | } | |
| 667 | ||
| 668 | #[test] | |
| 669 | fn a_plan_is_ready_once_every_diverged_ref_is_decided() { | |
| 670 | let diverged = |decision| RefPlan { | |
| 671 | git_ref: "refs/heads/docs".into(), | |
| 672 | base: Some("a".into()), | |
| 673 | ours: Some("b".into()), | |
| 674 | theirs: Some("c".into()), | |
| 675 | action: RefAction::Diverged, | |
| 676 | decision, | |
| 677 | }; | |
| 678 | let plan = HandbackPlan::new(vec![diverged(None)], true); | |
| 679 | assert!(!plan.ready && !plan.clean()); | |
| 680 | let plan = HandbackPlan::new(vec![diverged(Some(RefDecision::KeepTheirs))], true); | |
| 681 | assert!(plan.ready && !plan.clean()); | |
| 682 | assert!(!HandbackPlan::new(Vec::new(), false).ready, "not while the remote is away"); | |
| 683 | assert!(HandbackPlan::new(Vec::new(), true).clean()); | |
| 684 | } | |
| 685 | ||
| 686 | #[test] | |
| 687 | fn only_a_takeover_takes_writes() { | |
| 688 | let mut mirror = RepoMirror { remote: "github.com/acme/web".into(), ..RepoMirror::default() }; | |
| 689 | for (state, writable) in [ | |
| 690 | (MirrorState::Standby, false), | |
| 691 | (MirrorState::Ci, false), | |
| 692 | (MirrorState::Takeover, true), | |
| 693 | (MirrorState::HandingBack, false), | |
| 694 | ] { | |
| 695 | mirror.state = state; | |
| 696 | assert_eq!(mirror.writable(), writable, "{state:?}"); | |
| 697 | assert_eq!(MirrorState::parse(state.as_str()), Some(state)); | |
| 698 | assert_eq!(RemoteState::parse(state.as_str()).mirror(), Some(state)); | |
| 699 | } | |
| 700 | assert!(mirror_message("acme", "web", &RepoMirror { remote: "github.com/acme/web".into(), ..RepoMirror::default() }) | |
| 701 | .contains("is a mirror of github.com/acme/web")); | |
| 702 | } | |
| 703 | ||
| 704 | #[test] | |
| A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so. | 705 | fn a_mirror_offers_a_read_only_copy_until_it_is_taken_over() { |
| 706 | let mirror = |state| RepoMirror { state, remote: "github.com/acme/web".into(), ..RepoMirror::default() }; | |
| 707 | let read = [RepoArea::Code, RepoArea::Security, RepoArea::Insights, RepoArea::Settings]; | |
| 708 | let work = [RepoArea::Issues, RepoArea::Pulls, RepoArea::Agents, RepoArea::Deployments, RepoArea::Updates]; | |
| 709 | for state in [MirrorState::Standby, MirrorState::Ci, MirrorState::HandingBack] { | |
| 710 | for area in read { | |
| 711 | assert!(mirror(state).offers(area), "{state:?} {area:?}"); | |
| 712 | } | |
| 713 | for area in work { | |
| 714 | assert!(!mirror(state).offers(area), "{state:?} {area:?}"); | |
| 715 | } | |
| 716 | } | |
| 717 | for area in RepoArea::ALL { | |
| 718 | assert!(mirror(MirrorState::Takeover).offers(area), "{area:?}"); | |
| 719 | assert!(offers(None, area), "a repository that leads offers {area:?}"); | |
| 720 | } | |
| 721 | // Workflows: CI failover and takeovers, and a standby kept warm. | |
| 722 | assert!(!mirror(MirrorState::Standby).offers(RepoArea::Workflows)); | |
| 723 | assert!(RepoMirror { warm: true, ..mirror(MirrorState::Standby) }.offers(RepoArea::Workflows)); | |
| 724 | assert!(mirror(MirrorState::Ci).offers(RepoArea::Workflows)); | |
| 725 | assert!(!mirror(MirrorState::HandingBack).offers(RepoArea::Workflows)); | |
| 726 | assert!(!RepoMirror { warm: true, ..mirror(MirrorState::HandingBack) }.offers(RepoArea::Workflows)); | |
| 727 | let message = updates_wait_message("acme", "web", &mirror(MirrorState::Standby)); | |
| 728 | assert!(message.starts_with("Security updates wait until acme/web is taken over or moved to g1t.")); | |
| 729 | assert!(message.contains("github.com/acme/web")); | |
| 730 | } | |
| 731 | ||
| 732 | #[test] | |
| Merge branch 'mirroring' into artifacts-mode | 733 | fn settings_default_to_doing_nothing_on_their_own() { |
| 734 | let settings: MirrorSettings = serde_json::from_str("{}").unwrap(); | |
| 735 | assert_eq!(settings, MirrorSettings::default()); | |
| 736 | assert_eq!(settings.notify, Notify::Banner); | |
| 737 | assert_eq!(settings.take_over_after, None); | |
| 738 | assert!(!settings.keep_ci_warm); | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 739 | assert!(settings.report_checks, "checks are reported whenever g1t runs workflows, unless turned off"); |
| Merge branch 'mirroring' into artifacts-mode | 740 | } |
| 741 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.