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.
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1 | //! A repository's lifecycle after it is made: renaming it, archiving it, |
| 2 | //! making it public or private, changing or renaming its default branch, | |
| 3 | //! and deleting it with a window to restore it. | |
| 4 | //! | |
| 5 | //! Deleting is soft. The row gets `deleted_at` and `purge_after` | |
| 6 | //! ([`RESTORE_DAYS`] on), every read in the registry leaves it out, git | |
| 7 | //! refuses it, and `repo.deleted` tells every service to stop what runs for | |
| 8 | //! it and hide it. Restoring clears the columns (`repo.restored`). Purging, | |
| 9 | //! by an owner from the Recently deleted list or by the hourly sweep once | |
| 10 | //! `purge_after` has passed, removes the git data from the store, then the | |
| 11 | //! rows (its pull requests' working copies with it) and its redirects, and | |
| 12 | //! announces `repo.purged`, on which services drop what they keep for it. | |
| 13 | //! Until then its name stays taken, so a restore always has its path back. | |
| 14 | //! | |
| 15 | //! A rename is a path change like a transfer: the old path is kept in | |
| 16 | //! `repo_redirects`, the git store key never changes, the tokens of agents | |
| 17 | //! at work on it are moved with identity, and `repo.renamed` is handled by | |
| 18 | //! services with the same helper as `repo.transferred` | |
| 19 | //! (`g1t_kit::transfer`). | |
| 20 | ||
| 21 | use g1t_contracts::audit::{ | |
| 22 | AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface, | |
| 23 | }; | |
| 24 | use g1t_contracts::events::{ | |
| 25 | BranchRenamed, NewEvent, RepoArchived, RepoDefaultBranchChanged, RepoDeleted, RepoPurged, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 26 | RepoRenamed, RepoRestored, RepoUpdated, RepoVisibilityChanged, WorkspaceDeleted, |
| 27 | WorkspaceDeleting, WorkspaceRestored, | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 28 | }; |
| 29 | use g1t_contracts::identity::TransferRepoScopesArgs; | |
| 30 | use g1t_contracts::repos::{ | |
| The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days. | 31 | ArchiveArgs, Branch, CreateBranchArgs, DeleteArgs, DeletedArgs, DeletedRepo, DeletedRepoArgs, |
| 32 | PurgeDueArgs, RESTORE_DAYS, RemoveBranchArgs, RenameArgs, RenameBranchArgs, Repo, RepoPath, | |
| 33 | RepoStatus, ResolveBranchArgs, | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 34 | SetDefaultBranchArgs, SetVisibilityArgs, StatusByIdArgs, archived_message, |
| 35 | is_valid_branch_name, | |
| 36 | }; | |
| 37 | use g1t_contracts::access::{self, Capability, RepoRole}; | |
| 38 | use g1t_contracts::time::rfc3339; | |
| 39 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, is_valid_repo_name, new_id}; | |
| 40 | use g1t_kit::now_ms; | |
| 41 | use serde::Deserialize; | |
| 42 | use worker::Result; | |
| 43 | use worker::wasm_bindgen::JsValue; | |
| 44 | ||
| 45 | use crate::registry::{Registry, remember_store, store_key}; | |
| 46 | use crate::store::{GitRepo, GitStore, Scope}; | |
| 47 | use crate::{Repos, SOURCE, UNVERIFIED, git_ops, land, not_found}; | |
| 48 | ||
| 49 | use crate::land::EMPTY_PACK; | |
| 50 | ||
| 51 | /// How many pull request working copies follow a change of the default | |
| 52 | /// branch (newest first). Older ones keep the branch they were made with. | |
| 53 | const FORKS_FOLLOWING: u32 = 100; | |
| 54 | ||
| 55 | /// How many deleted repositories one sweep purges. | |
| 56 | const PURGES_PER_SWEEP: u32 = 25; | |
| 57 | ||
| 58 | type Refusal = (FailureCode, String); | |
| 59 | ||
| 60 | /// Who is asking, as far as an owner's or an admin's action cares. | |
| 61 | #[derive(Clone, Copy, Debug)] | |
| 62 | pub struct Asker { | |
| 63 | /// A person, not a workspace's or an agent's token. | |
| 64 | pub person: bool, | |
| 65 | pub verified: bool, | |
| 66 | /// Their role in the repository's workspace. | |
| 67 | pub role: Option<Role>, | |
| 68 | /// Their role on the repository itself (see g1t_contracts::access). | |
| 69 | /// None where only the workspace is known, as for deleted ones. | |
| 70 | pub repo_role: Option<RepoRole>, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 71 | /// The workspace's member privileges, as their membership carries them. |
| 72 | pub privileges: g1t_contracts::MemberPrivileges, | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 73 | } |
| 74 | ||
| 75 | impl Asker { | |
| 76 | pub fn of(user: &User, namespace: &str) -> Self { | |
| 77 | Asker { | |
| 78 | person: user.kind == PrincipalKind::User, | |
| 79 | verified: user.verified, | |
| 80 | role: user.role_in(&namespace.to_lowercase()), | |
| 81 | repo_role: None, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 82 | privileges: user.privileges_in(&namespace.to_lowercase()), |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 83 | } |
| 84 | } | |
| 85 | ||
| 86 | /// The asker, with their role on `repo`. | |
| 87 | pub fn on(user: &User, repo: &Repo) -> Self { | |
| 88 | Asker { | |
| 89 | repo_role: crate::registry::role(repo, &Some(user.clone())), | |
| 90 | ..Asker::of(user, &repo.namespace) | |
| 91 | } | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | /// Whether `asker` may `what` ("rename", "archive"...) a repository of | |
| 96 | /// `namespace` that takes `capability`: a verified person with the role | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 97 | /// the permission table asks (Admin), and for changing visibility, |
| 98 | /// transferring and deleting, an owner of the workspace as well, unless its | |
| 99 | /// member privileges let its members with Admin do it. | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 100 | pub fn admin_only(asker: Asker, namespace: &str, what: &str, capability: Capability) -> std::result::Result<(), Refusal> { |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 101 | let admins_may = asker.role == Some(Role::Member) |
| 102 | && asker.repo_role == Some(RepoRole::Admin) | |
| 103 | && !access::owner_only(capability, &asker.privileges); | |
| 104 | if access::OWNER_ONLY.contains(&capability) && !admins_may { | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 105 | // Someone who can see the repository is told why, not that it is missing. |
| 106 | if asker.role.is_none() && asker.repo_role.is_some() && asker.person { | |
| 107 | return Err(( | |
| 108 | FailureCode::Forbidden, | |
| 109 | format!("Only an owner of {namespace} can {what} its repositories."), | |
| 110 | )); | |
| 111 | } | |
| 112 | return owner_only(asker, namespace, what); | |
| 113 | } | |
| 114 | if asker.role.is_none() && asker.repo_role.is_none() { | |
| 115 | return Err((FailureCode::NotFound, "Repository not found.".into())); | |
| 116 | } | |
| 117 | if !asker.person { | |
| 118 | return Err(( | |
| 119 | FailureCode::Forbidden, | |
| 120 | format!("Only a person can {what} a repository. Sign in, or use a personal access token."), | |
| 121 | )); | |
| 122 | } | |
| 123 | if !asker.repo_role.is_some_and(|role| access::allows(role, capability)) { | |
| 124 | return Err(( | |
| 125 | FailureCode::Forbidden, | |
| 126 | format!( | |
| 127 | "You need the {} role on a repository of {namespace} to {what} it.", | |
| 128 | access::least_role(capability).label() | |
| 129 | ), | |
| 130 | )); | |
| 131 | } | |
| 132 | if !asker.verified { | |
| 133 | return Err((FailureCode::Forbidden, UNVERIFIED.into())); | |
| 134 | } | |
| 135 | Ok(()) | |
| 136 | } | |
| 137 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 138 | /// Making a repository public or private is, as far as the workspace's |
| 139 | /// member privileges go, creating one of that kind: a member may only make | |
| 140 | /// it what they may create. | |
| 141 | pub fn visibility_refusal(actor: &User, repo: &Repo, private: bool) -> Option<String> { | |
| 142 | if repo.is_private == private { | |
| 143 | return None; | |
| 144 | } | |
| 145 | let role = actor.role_in(&repo.namespace.to_lowercase())?; | |
| 146 | actor.privileges_in(&repo.namespace.to_lowercase()).creation_refusal(role, private, &repo.namespace) | |
| 147 | } | |
| 148 | ||
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 149 | /// Whether `asker` may `what` ("delete", "rename"...) a repository of |
| 150 | /// `namespace`: a verified person who owns the workspace. | |
| 151 | pub fn owner_only(asker: Asker, namespace: &str, what: &str) -> std::result::Result<(), Refusal> { | |
| 152 | if asker.role.is_none() { | |
| 153 | return Err((FailureCode::NotFound, "Repository not found.".into())); | |
| 154 | } | |
| 155 | if !asker.person { | |
| 156 | return Err(( | |
| 157 | FailureCode::Forbidden, | |
| 158 | format!("Only a person can {what} a repository. Sign in, or use a personal access token."), | |
| 159 | )); | |
| 160 | } | |
| 161 | if asker.role != Some(Role::Owner) { | |
| 162 | return Err(( | |
| 163 | FailureCode::Forbidden, | |
| 164 | format!("Only an owner of {namespace} can {what} its repositories."), | |
| 165 | )); | |
| 166 | } | |
| 167 | if !asker.verified { | |
| 168 | return Err((FailureCode::Forbidden, UNVERIFIED.into())); | |
| 169 | } | |
| 170 | Ok(()) | |
| 171 | } | |
| 172 | ||
| 173 | /// Whether what was typed to confirm names the repository: its full name, | |
| 174 | /// `namespace/name`, in any case. | |
| 175 | pub fn confirmed(path: &RepoPath, typed: &str) -> bool { | |
| 176 | typed.trim().to_lowercase() == format!("{}/{}", path.namespace, path.name).to_lowercase() | |
| 177 | } | |
| 178 | ||
| 179 | fn confirm_refusal(path: &RepoPath) -> Refusal { | |
| 180 | ( | |
| 181 | FailureCode::Invalid, | |
| 182 | format!("Type {}/{} to confirm.", path.namespace, path.name), | |
| 183 | ) | |
| 184 | } | |
| 185 | ||
| 186 | /// When a repository deleted at `now_ms` is purged. | |
| 187 | pub fn purge_after(now_ms: u64) -> String { | |
| 188 | rfc3339(now_ms + RESTORE_DAYS * 86_400_000) | |
| 189 | } | |
| 190 | ||
| 191 | /// Whether a repository to be purged at `purge_after` can still be | |
| 192 | /// restored at `now` (both RFC 3339, which compare as text). | |
| 193 | pub fn restorable(purge_after: &str, now: &str) -> bool { | |
| 194 | now < purge_after | |
| 195 | } | |
| 196 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 197 | /// Whether a workspace's repositories may go with it: never a protected |
| 198 | /// workspace's (`protected` is `g1t_contracts::identity::protected_names`), | |
| 199 | /// however the event came to be published. | |
| 200 | pub fn may_go_with_workspace(namespace: &str, protected: &[String]) -> std::result::Result<(), String> { | |
| 201 | if protected.iter().any(|name| name.eq_ignore_ascii_case(namespace)) { | |
| 202 | return Err(g1t_contracts::identity::protected_refusal(namespace)); | |
| 203 | } | |
| 204 | Ok(()) | |
| 205 | } | |
| 206 | ||
| 207 | /// Whether a deleted repository comes back when the workspace | |
| 208 | /// `workspace_id` is restored: only if it went with that workspace | |
| 209 | /// (`deleted_with`), not if it was deleted on its own before. | |
| 210 | pub fn comes_back_with(deleted_with: Option<&str>, workspace_id: &str) -> bool { | |
| 211 | deleted_with == Some(workspace_id) | |
| 212 | } | |
| 213 | ||
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 214 | /// Where a repository is in its life, from its row. |
| 215 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 216 | pub enum State { | |
| 217 | Active, | |
| 218 | Archived, | |
| 219 | /// Deleted, and restorable until purged. | |
| 220 | Deleted, | |
| 221 | /// Deleted, and due to be purged by the next sweep. | |
| 222 | Due, | |
| 223 | } | |
| 224 | ||
| 225 | pub fn state(archived_at: Option<&str>, deleted: Option<(&str, &str)>, now: &str) -> State { | |
| 226 | match deleted { | |
| 227 | Some((_, purge_after)) if !restorable(purge_after, now) => State::Due, | |
| 228 | Some(_) => State::Deleted, | |
| 229 | None if archived_at.is_some() => State::Archived, | |
| 230 | None => State::Active, | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | /// What holds a name in a workspace. | |
| 235 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 236 | pub enum Held { | |
| 237 | Free, | |
| 238 | ByRepo, | |
| 239 | /// A repository deleted but not yet purged. | |
| 240 | ByDeleted, | |
| 241 | } | |
| 242 | ||
| 243 | /// The name a repository at `current` can be renamed to, tidied, or why | |
| 244 | /// not. | |
| 245 | pub fn new_name(namespace: &str, current: &str, wanted: &str, held: Held) -> std::result::Result<String, Refusal> { | |
| 246 | let name = wanted.trim().to_lowercase(); | |
| 247 | if !is_valid_repo_name(&name) { | |
| 248 | return Err(( | |
| 249 | FailureCode::Invalid, | |
| 250 | "Use letters, digits, dots, hyphens and underscores only.".into(), | |
| 251 | )); | |
| 252 | } | |
| 253 | if name == current { | |
| 254 | return Err((FailureCode::Invalid, format!("It is already called {name}."))); | |
| 255 | } | |
| 256 | match held { | |
| 257 | Held::Free => Ok(name), | |
| 258 | Held::ByRepo => Err(( | |
| 259 | FailureCode::Conflict, | |
| 260 | format!("{namespace} already has a repository named {name}."), | |
| 261 | )), | |
| 262 | Held::ByDeleted => Err(( | |
| 263 | FailureCode::Conflict, | |
| 264 | format!( | |
| 265 | "{namespace}/{name} was deleted recently and can still be restored. Restore it and rename it, or delete it permanently from the workspace's Recently deleted list first." | |
| 266 | ), | |
| 267 | )), | |
| 268 | } | |
| 269 | } | |
| 270 | ||
| 271 | /// Why a write to `repo` is refused because it is archived, if it is. | |
| 272 | pub fn archived_refusal(repo: &Repo) -> Option<Refusal> { | |
| 273 | repo.archived() | |
| 274 | .then(|| (FailureCode::Forbidden, archived_message(&repo.namespace, &repo.name))) | |
| 275 | } | |
| 276 | ||
| Merge branch 'mirroring' into artifacts-mode | 277 | /// Why a repository takes no pushes, merges or new work now: archived, or |
| 278 | /// a mirror that is not taken over (see `g1t_contracts::mirrors`). | |
| 279 | /// Settings stay changeable on a mirror; only an archived repository | |
| 280 | /// refuses those (`archived_refusal`). | |
| 281 | pub fn read_only_refusal(repo: &Repo) -> Option<Refusal> { | |
| 282 | repo.read_only_reason().map(|message| (FailureCode::Forbidden, message)) | |
| 283 | } | |
| 284 | ||
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 285 | /// Everything that decides whether a repository may go private or public. |
| 286 | #[derive(Debug, Default)] | |
| 287 | pub struct VisibilityFacts { | |
| 288 | pub to_private: bool, | |
| 289 | /// The workspace is on no plan, so its private storage is capped. | |
| 290 | pub free: bool, | |
| 291 | /// What its private repositories hold now. | |
| 292 | pub private_bytes: i64, | |
| 293 | /// What this repository holds. | |
| 294 | pub bytes: i64, | |
| 295 | /// What a free workspace's private repositories may hold. | |
| 296 | pub free_private_bytes: i64, | |
| 297 | } | |
| 298 | ||
| 299 | /// Whether the visibility change `facts` describe may happen. | |
| 300 | pub fn visibility_check(namespace: &str, facts: &VisibilityFacts) -> std::result::Result<(), Refusal> { | |
| 301 | if facts.to_private | |
| 302 | && facts.free | |
| 303 | && git_ops::storage_full(facts.private_bytes + facts.bytes, facts.free_private_bytes) | |
| 304 | { | |
| 305 | return Err(( | |
| 306 | FailureCode::PaymentRequired, | |
| 307 | format!( | |
| 308 | "{namespace}'s private repositories would hold {:.2} GB, more than the {:.0} GB a free workspace has. Start the g1t plan in {namespace}, or keep the repository public.", | |
| 309 | (facts.private_bytes + facts.bytes) as f64 / 1e9, | |
| 310 | facts.free_private_bytes as f64 / 1e9, | |
| 311 | ), | |
| 312 | )); | |
| 313 | } | |
| 314 | Ok(()) | |
| 315 | } | |
| 316 | ||
| 317 | /// Whether `from` can be renamed to `to` in a repository whose branches | |
| 318 | /// are `branches`. `to` is tidied of surrounding space. | |
| 319 | pub fn branch_rename(from: &str, to: &str, branches: &[String]) -> std::result::Result<String, Refusal> { | |
| 320 | let to = to.trim().to_owned(); | |
| 321 | if !branches.iter().any(|branch| branch == from) { | |
| 322 | return Err((FailureCode::NotFound, format!("There is no branch named {from}."))); | |
| 323 | } | |
| 324 | if !is_valid_branch_name(&to) { | |
| 325 | return Err(( | |
| 326 | FailureCode::Invalid, | |
| 327 | format!("{to:?} cannot be a branch name. Use letters, digits, '/', '-', '_' and '.', and no spaces."), | |
| 328 | )); | |
| 329 | } | |
| 330 | if to == from { | |
| 331 | return Err((FailureCode::Invalid, format!("It is already called {to}."))); | |
| 332 | } | |
| 333 | if branches.contains(&to) { | |
| 334 | return Err((FailureCode::Conflict, format!("There is already a branch named {to}."))); | |
| 335 | } | |
| 336 | Ok(to) | |
| 337 | } | |
| 338 | ||
| The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days. | 339 | /// Whether a branch named `name` can be made in a repository whose |
| 340 | /// branches are `branches`: a valid name, free, and neither inside nor | |
| 341 | /// holding another branch's name as a folder (git keeps `feature` and | |
| 342 | /// `feature/login` apart only as a file and a directory, which cannot | |
| 343 | /// both be). `name` is tidied of surrounding space. | |
| 344 | pub fn branch_creation(name: &str, branches: &[Branch]) -> std::result::Result<String, Refusal> { | |
| 345 | let name = name.trim().to_owned(); | |
| 346 | if !is_valid_branch_name(&name) { | |
| 347 | return Err(( | |
| 348 | FailureCode::Invalid, | |
| 349 | format!("{name:?} cannot be a branch name. Use letters, digits, '/', '-', '_' and '.', and no spaces."), | |
| 350 | )); | |
| 351 | } | |
| 352 | if branches.iter().any(|branch| branch.name == name) { | |
| 353 | return Err((FailureCode::Conflict, format!("There is already a branch named {name}."))); | |
| 354 | } | |
| 355 | if let Some(clash) = branches.iter().find(|branch| { | |
| 356 | name.starts_with(&format!("{}/", branch.name)) || branch.name.starts_with(&format!("{name}/")) | |
| 357 | }) { | |
| 358 | return Err(( | |
| 359 | FailureCode::Conflict, | |
| 360 | format!("{name} cannot sit beside {}: one name would be a folder of the other.", clash.name), | |
| 361 | )); | |
| 362 | } | |
| 363 | Ok(name) | |
| 364 | } | |
| 365 | ||
| 366 | /// The commit `branch` points to, when it may be deleted from a repository | |
| 367 | /// whose default branch is `default_branch` and whose branches are | |
| 368 | /// `branches`: never the default, and, when the caller says which commit | |
| 369 | /// it saw (`head`), only while it still points there. | |
| 370 | pub fn branch_removal( | |
| 371 | branch: &str, | |
| 372 | default_branch: &str, | |
| 373 | head: Option<&str>, | |
| 374 | branches: &[Branch], | |
| 375 | ) -> std::result::Result<String, Refusal> { | |
| 376 | if branch == default_branch { | |
| 377 | return Err(( | |
| 378 | FailureCode::Forbidden, | |
| 379 | format!("{branch} is the default branch, which is never deleted. Make another branch the default first."), | |
| 380 | )); | |
| 381 | } | |
| 382 | let Some(found) = branches.iter().find(|found| found.name == branch) else { | |
| 383 | return Err((FailureCode::NotFound, format!("There is no branch named {branch}."))); | |
| 384 | }; | |
| 385 | if head.is_some_and(|head| !head.is_empty() && head != found.hash) { | |
| 386 | return Err(( | |
| 387 | FailureCode::Conflict, | |
| 388 | format!("{branch} has new commits since you looked, so it was left alone. Reload to see them."), | |
| 389 | )); | |
| 390 | } | |
| 391 | Ok(found.hash.clone()) | |
| 392 | } | |
| 393 | ||
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 394 | /// The row of a deleted repository. |
| 395 | #[derive(Deserialize)] | |
| 396 | struct DeletedRow { | |
| 397 | id: String, | |
| 398 | namespace: String, | |
| 399 | name: String, | |
| 400 | description: Option<String>, | |
| 401 | is_private: u8, | |
| 402 | deleted_at: String, | |
| 403 | #[serde(default)] | |
| 404 | deleted_by: Option<String>, | |
| 405 | purge_after: String, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 406 | #[serde(default)] |
| 407 | deleted_with: Option<String>, | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 408 | } |
| 409 | ||
| 410 | impl From<DeletedRow> for DeletedRepo { | |
| 411 | fn from(row: DeletedRow) -> Self { | |
| 412 | DeletedRepo { | |
| 413 | id: row.id, | |
| 414 | namespace: row.namespace, | |
| 415 | name: row.name, | |
| 416 | description: row.description, | |
| 417 | is_private: row.is_private != 0, | |
| 418 | deleted_at: row.deleted_at, | |
| 419 | deleted_by: row.deleted_by.unwrap_or_default(), | |
| 420 | purge_after: row.purge_after, | |
| 421 | } | |
| 422 | } | |
| 423 | } | |
| 424 | ||
| 425 | const DELETED_COLUMNS: &str = | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 426 | "id, namespace, name, description, is_private, deleted_at, deleted_by, purge_after, deleted_with"; |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 427 | |
| 428 | impl Registry { | |
| 429 | /// Deletes a repository and its pull requests' working copies, softly. | |
| 430 | pub async fn soft_delete(&self, id: &str, by: &str, at: &str, purge_after: &str) -> Result<()> { | |
| 431 | self.db | |
| 432 | .prepare( | |
| 433 | "UPDATE repos SET deleted_at = ?1, deleted_by = ?2, purge_after = ?3 | |
| 434 | WHERE (id = ?4 OR fork_of = ?4) AND deleted_at IS NULL", | |
| 435 | ) | |
| 436 | .bind(&[at.into(), by.into(), purge_after.into(), id.into()])? | |
| 437 | .run() | |
| 438 | .await?; | |
| 439 | Ok(()) | |
| 440 | } | |
| 441 | ||
| 442 | /// Brings a deleted repository and its working copies back. | |
| 443 | pub async fn undelete(&self, id: &str) -> Result<()> { | |
| 444 | self.db | |
| 445 | .prepare( | |
| 446 | "UPDATE repos SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL | |
| 447 | WHERE id = ?1 OR fork_of = ?1", | |
| 448 | ) | |
| 449 | .bind(&[id.into()])? | |
| 450 | .run() | |
| 451 | .await?; | |
| 452 | Ok(()) | |
| 453 | } | |
| 454 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 455 | /// Deletes every live repository of `namespace`, and the working |
| 456 | /// copies of its repositories, softly, each marked as gone with the | |
| 457 | /// workspace `workspace_id`. Those already deleted are left as they | |
| 458 | /// are. Returns every repository so marked, this time or before, so a | |
| 459 | /// delivery again tells services again. | |
| 460 | pub async fn delete_with_workspace( | |
| 461 | &self, | |
| 462 | namespace: &str, | |
| 463 | workspace_id: &str, | |
| 464 | by: &str, | |
| 465 | at: &str, | |
| 466 | purge_after: &str, | |
| 467 | ) -> Result<Vec<DeletedRepo>> { | |
| 468 | self.db | |
| 469 | .prepare( | |
| 470 | "UPDATE repos SET deleted_at = ?1, deleted_by = ?2, purge_after = ?3, deleted_with = ?4 | |
| 471 | WHERE deleted_at IS NULL | |
| 472 | AND (namespace = ?5 OR fork_of IN (SELECT id FROM repos WHERE namespace = ?5))", | |
| 473 | ) | |
| 474 | .bind(&[at.into(), by.into(), purge_after.into(), workspace_id.into(), namespace.into()])? | |
| 475 | .run() | |
| 476 | .await?; | |
| 477 | self.deleted_with(workspace_id).await | |
| 478 | } | |
| 479 | ||
| 480 | /// The repositories deleted with the workspace `workspace_id`. | |
| 481 | pub async fn deleted_with(&self, workspace_id: &str) -> Result<Vec<DeletedRepo>> { | |
| 482 | Ok(self | |
| 483 | .db | |
| 484 | .prepare(format!( | |
| 485 | "SELECT {DELETED_COLUMNS} FROM repos WHERE deleted_with = ? AND fork_of IS NULL" | |
| 486 | )) | |
| 487 | .bind(&[workspace_id.into()])? | |
| 488 | .all() | |
| 489 | .await? | |
| 490 | .results::<DeletedRow>()? | |
| 491 | .into_iter() | |
| 492 | .filter(|row| comes_back_with(row.deleted_with.as_deref(), workspace_id)) | |
| 493 | .map(DeletedRepo::from) | |
| 494 | .collect()) | |
| 495 | } | |
| 496 | ||
| 497 | /// Brings back the repositories, and their working copies, deleted | |
| 498 | /// with the workspace `workspace_id`, and only those. | |
| 499 | pub async fn undelete_with_workspace(&self, workspace_id: &str) -> Result<()> { | |
| 500 | self.db | |
| 501 | .prepare( | |
| 502 | "UPDATE repos SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_with = NULL | |
| 503 | WHERE deleted_with = ?", | |
| 504 | ) | |
| 505 | .bind(&[workspace_id.into()])? | |
| 506 | .run() | |
| 507 | .await?; | |
| 508 | Ok(()) | |
| 509 | } | |
| 510 | ||
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 511 | /// A workspace's deleted repositories, newest first. |
| 512 | pub async fn deleted_in(&self, namespace: &str) -> Result<Vec<DeletedRepo>> { | |
| 513 | Ok(self | |
| 514 | .db | |
| 515 | .prepare(format!( | |
| 516 | "SELECT {DELETED_COLUMNS} FROM repos | |
| 517 | WHERE namespace = ? AND deleted_at IS NOT NULL AND fork_of IS NULL | |
| 518 | ORDER BY deleted_at DESC LIMIT 200" | |
| 519 | )) | |
| 520 | .bind(&[namespace.to_lowercase().into()])? | |
| 521 | .all() | |
| 522 | .await? | |
| 523 | .results::<DeletedRow>()? | |
| 524 | .into_iter() | |
| 525 | .map(DeletedRepo::from) | |
| 526 | .collect()) | |
| 527 | } | |
| 528 | ||
| 529 | /// The deleted repository at `path`, if that is what holds it. | |
| 530 | pub async fn deleted_at(&self, path: &RepoPath) -> Result<Option<DeletedRepo>> { | |
| 531 | Ok(self | |
| 532 | .db | |
| 533 | .prepare(format!( | |
| 534 | "SELECT {DELETED_COLUMNS} FROM repos | |
| 535 | WHERE namespace = ? AND name = ? AND deleted_at IS NOT NULL AND fork_of IS NULL" | |
| 536 | )) | |
| 537 | .bind(&[ | |
| 538 | path.namespace.to_lowercase().into(), | |
| 539 | path.name.to_lowercase().into(), | |
| 540 | ])? | |
| 541 | .first::<DeletedRow>(None) | |
| 542 | .await? | |
| 543 | .map(DeletedRepo::from)) | |
| 544 | } | |
| 545 | ||
| 546 | /// Deleted repositories whose time to be restored has passed. | |
| 547 | pub async fn due(&self, now: &str, limit: u32) -> Result<Vec<DeletedRepo>> { | |
| 548 | Ok(self | |
| 549 | .db | |
| 550 | .prepare(format!( | |
| 551 | "SELECT {DELETED_COLUMNS} FROM repos | |
| 552 | WHERE deleted_at IS NOT NULL AND purge_after <= ? AND fork_of IS NULL | |
| 553 | ORDER BY purge_after LIMIT ?" | |
| 554 | )) | |
| 555 | .bind(&[now.into(), limit.into()])? | |
| 556 | .all() | |
| 557 | .await? | |
| 558 | .results::<DeletedRow>()? | |
| 559 | .into_iter() | |
| 560 | .map(DeletedRepo::from) | |
| 561 | .collect()) | |
| 562 | } | |
| 563 | ||
| 564 | /// Every deleted repository left in a workspace, for when the | |
| 565 | /// workspace itself is deleted. | |
| 566 | pub async fn deleted_ids_in(&self, namespace: &str) -> Result<Vec<DeletedRepo>> { | |
| 567 | self.deleted_in(namespace).await | |
| 568 | } | |
| 569 | ||
| 570 | /// The git store keys of a repository and of its working copies. | |
| 571 | pub async fn store_keys(&self, id: &str) -> Result<Vec<String>> { | |
| 572 | #[derive(Deserialize)] | |
| 573 | struct Row { | |
| 574 | namespace: String, | |
| 575 | name: String, | |
| 576 | #[serde(default)] | |
| 577 | store: Option<String>, | |
| 578 | } | |
| 579 | Ok(self | |
| 580 | .db | |
| 581 | .prepare("SELECT namespace, name, store FROM repos WHERE id = ?1 OR fork_of = ?1") | |
| 582 | .bind(&[id.into()])? | |
| 583 | .all() | |
| 584 | .await? | |
| 585 | .results::<Row>()? | |
| 586 | .into_iter() | |
| 587 | .map(|row| row.store.unwrap_or_else(|| format!("{}--{}", row.namespace, row.name))) | |
| 588 | .collect()) | |
| 589 | } | |
| 590 | ||
| 591 | /// Forgets a purged repository: its rows, its working copies' rows and | |
| 592 | /// every redirect to it. | |
| 593 | pub async fn erase(&self, id: &str) -> Result<()> { | |
| 594 | self.db | |
| 595 | .batch(vec![ | |
| 596 | self.db.prepare("DELETE FROM repos WHERE fork_of = ?1").bind(&[id.into()])?, | |
| 597 | self.db.prepare("DELETE FROM repos WHERE id = ?1").bind(&[id.into()])?, | |
| 598 | self.db | |
| 599 | .prepare("DELETE FROM repo_redirects WHERE repo_id = ?1") | |
| 600 | .bind(&[id.into()])?, | |
| 601 | self.db | |
| 602 | .prepare("DELETE FROM branch_redirects WHERE repo_id = ?1") | |
| 603 | .bind(&[id.into()])?, | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 604 | // Its About, stars and releases (about.rs). |
| 605 | self.db.prepare("DELETE FROM repo_stats WHERE repo_id = ?1").bind(&[id.into()])?, | |
| 606 | self.db.prepare("DELETE FROM repo_stars WHERE repo_id = ?1").bind(&[id.into()])?, | |
| 607 | self.db.prepare("DELETE FROM releases WHERE repo_id = ?1").bind(&[id.into()])?, | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 608 | ]) |
| 609 | .await?; | |
| 610 | Ok(()) | |
| 611 | } | |
| 612 | ||
| 613 | /// Renames a repository, keeping its old path as a redirect. Any | |
| 614 | /// redirect held by the new path gives way. | |
| 615 | pub async fn rename(&self, repo: &Repo, name: &str) -> Result<()> { | |
| 616 | let now = rfc3339(now_ms()); | |
| 617 | self.db | |
| 618 | .batch(vec![ | |
| 619 | self.db | |
| 620 | .prepare("UPDATE repos SET name = ? WHERE id = ? AND name = ?") | |
| 621 | .bind(&[name.into(), repo.id.as_str().into(), repo.name.as_str().into()])?, | |
| 622 | self.db | |
| 623 | .prepare("DELETE FROM repo_redirects WHERE namespace = ? AND name = ?") | |
| 624 | .bind(&[repo.namespace.as_str().into(), name.into()])?, | |
| 625 | self.db | |
| 626 | .prepare( | |
| 627 | "INSERT OR REPLACE INTO repo_redirects (namespace, name, repo_id, created_at) | |
| 628 | VALUES (?, ?, ?, ?)", | |
| 629 | ) | |
| 630 | .bind(&[ | |
| 631 | repo.namespace.as_str().into(), | |
| 632 | repo.name.as_str().into(), | |
| 633 | repo.id.as_str().into(), | |
| 634 | now.as_str().into(), | |
| 635 | ])?, | |
| 636 | ]) | |
| 637 | .await?; | |
| 638 | Ok(()) | |
| 639 | } | |
| 640 | ||
| 641 | pub async fn set_archived(&self, id: &str, at: Option<&str>) -> Result<()> { | |
| 642 | self.db | |
| 643 | .prepare("UPDATE repos SET archived_at = ? WHERE id = ?") | |
| 644 | .bind(&[at.map_or(JsValue::NULL, JsValue::from), id.into()])? | |
| 645 | .run() | |
| 646 | .await?; | |
| 647 | Ok(()) | |
| 648 | } | |
| 649 | ||
| Merge branch 'mirroring' into artifacts-mode | 650 | /// A repository's mirror (see `g1t_contracts::mirrors`), or none. |
| 651 | pub async fn set_mirror(&self, id: &str, mirror: Option<&g1t_contracts::mirrors::RepoMirror>) -> Result<()> { | |
| 652 | let mirror = mirror.and_then(|mirror| serde_json::to_string(mirror).ok()); | |
| 653 | self.db | |
| 654 | .prepare("UPDATE repos SET mirror = ? WHERE id = ?") | |
| 655 | .bind(&[mirror.map_or(JsValue::NULL, JsValue::from), id.into()])? | |
| 656 | .run() | |
| 657 | .await?; | |
| 658 | Ok(()) | |
| 659 | } | |
| 660 | ||
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 661 | /// Makes a repository and its working copies public or private. |
| 662 | pub async fn set_private(&self, id: &str, private: bool) -> Result<()> { | |
| 663 | self.db | |
| 664 | .prepare("UPDATE repos SET is_private = ?1 WHERE id = ?2 OR fork_of = ?2") | |
| 665 | .bind(&[u32::from(private).into(), id.into()])? | |
| 666 | .run() | |
| 667 | .await?; | |
| 668 | Ok(()) | |
| 669 | } | |
| 670 | ||
| 671 | pub async fn set_default_branch(&self, id: &str, branch: &str) -> Result<()> { | |
| 672 | self.db | |
| 673 | .prepare("UPDATE repos SET default_branch = ? WHERE id = ?") | |
| 674 | .bind(&[branch.into(), id.into()])? | |
| 675 | .run() | |
| 676 | .await?; | |
| 677 | Ok(()) | |
| 678 | } | |
| 679 | ||
| 680 | /// Working copies of a repository, newest first, at most `limit`. | |
| 681 | pub async fn forks_of(&self, id: &str, limit: u32) -> Result<Vec<Repo>> { | |
| 682 | let rows = self | |
| 683 | .db | |
| 684 | .prepare( | |
| 685 | "SELECT * FROM repos WHERE fork_of = ? AND deleted_at IS NULL AND retired_at IS NULL | |
| 686 | ORDER BY created_at DESC LIMIT ?", | |
| 687 | ) | |
| 688 | .bind(&[id.into(), limit.into()])? | |
| 689 | .all() | |
| 690 | .await? | |
| 691 | .results::<crate::registry::RepoRow>()?; | |
| 692 | Ok(rows.into_iter().map(Repo::from).collect()) | |
| 693 | } | |
| 694 | ||
| 695 | /// Records that `from` is now called `to`. Redirects that pointed at | |
| 696 | /// `from` point at `to`, and one held by `to` itself ends. | |
| 697 | pub async fn add_branch_redirect(&self, repo_id: &str, from: &str, to: &str) -> Result<()> { | |
| 698 | let now = rfc3339(now_ms()); | |
| 699 | self.db | |
| 700 | .batch(vec![ | |
| 701 | self.db | |
| 702 | .prepare("DELETE FROM branch_redirects WHERE repo_id = ? AND branch = ?") | |
| 703 | .bind(&[repo_id.into(), to.into()])?, | |
| 704 | self.db | |
| 705 | .prepare("UPDATE branch_redirects SET now = ? WHERE repo_id = ? AND now = ?") | |
| 706 | .bind(&[to.into(), repo_id.into(), from.into()])?, | |
| 707 | self.db | |
| 708 | .prepare( | |
| 709 | "INSERT OR REPLACE INTO branch_redirects (repo_id, branch, now, created_at) | |
| 710 | VALUES (?, ?, ?, ?)", | |
| 711 | ) | |
| 712 | .bind(&[repo_id.into(), from.into(), to.into(), now.as_str().into()])?, | |
| 713 | ]) | |
| 714 | .await?; | |
| 715 | Ok(()) | |
| 716 | } | |
| 717 | ||
| 718 | pub async fn branch_redirect(&self, repo_id: &str, branch: &str) -> Result<Option<String>> { | |
| 719 | #[derive(Deserialize)] | |
| 720 | struct Row { | |
| 721 | now: String, | |
| 722 | } | |
| 723 | Ok(self | |
| 724 | .db | |
| 725 | .prepare("SELECT now FROM branch_redirects WHERE repo_id = ? AND branch = ?") | |
| 726 | .bind(&[repo_id.into(), branch.into()])? | |
| 727 | .first::<Row>(None) | |
| 728 | .await? | |
| 729 | .map(|row| row.now)) | |
| 730 | } | |
| 731 | ||
| 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. | 732 | /// Whether a repository is archived or deleted, and the remote it |
| 733 | /// mirrors; unknown is deleted. | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 734 | pub async fn status(&self, id: &str) -> Result<RepoStatus> { |
| 735 | #[derive(Deserialize)] | |
| 736 | struct Row { | |
| 737 | #[serde(default)] | |
| 738 | archived_at: Option<String>, | |
| 739 | #[serde(default)] | |
| 740 | deleted_at: Option<String>, | |
| 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. | 741 | #[serde(default)] |
| 742 | mirror: Option<String>, | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 743 | } |
| 744 | Ok(self | |
| 745 | .db | |
| 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. | 746 | .prepare("SELECT archived_at, deleted_at, mirror FROM repos WHERE id = ?") |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 747 | .bind(&[id.into()])? |
| 748 | .first::<Row>(None) | |
| 749 | .await? | |
| 750 | .map_or( | |
| 751 | RepoStatus { | |
| 752 | archived: false, | |
| 753 | deleted: true, | |
| 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. | 754 | mirror: None, |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 755 | }, |
| 756 | |row| RepoStatus { | |
| 757 | archived: row.archived_at.is_some(), | |
| 758 | deleted: row.deleted_at.is_some(), | |
| 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. | 759 | mirror: row.mirror.as_deref().and_then(|mirror| serde_json::from_str(mirror).ok()), |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 760 | }, |
| 761 | )) | |
| 762 | } | |
| 763 | } | |
| 764 | ||
| 765 | /// An audit entry for something done to a repository. | |
| 766 | fn entry(actor: AuditActor, action: &str, surface: Option<Surface>, path: &RepoPath, rule: &str, message: String) -> NewAuditEntry { | |
| 767 | NewAuditEntry { | |
| 768 | actor, | |
| 769 | action: action.to_owned(), | |
| 770 | surface: surface.unwrap_or(Surface::Web), | |
| 771 | target: AuditTarget { | |
| 772 | workspace: path.namespace.clone(), | |
| 773 | repo: Some(format!("{}/{}", path.namespace, path.name)), | |
| 774 | ..AuditTarget::default() | |
| 775 | }, | |
| 776 | outcome: AuditOutcome::Allowed, | |
| 777 | rule: rule.to_owned(), | |
| 778 | result: Some("ok".to_owned()), | |
| 779 | message: Some(message), | |
| 780 | request_id: new_id("req", now_ms()), | |
| 781 | } | |
| 782 | } | |
| 783 | ||
| 784 | /// g1t itself, as the actor of what its schedule does. | |
| 785 | fn g1t_actor() -> AuditActor { | |
| 786 | AuditActor::system() | |
| 787 | } | |
| 788 | ||
| 789 | fn fail<T>((code, message): Refusal) -> Outcome<T> { | |
| 790 | Outcome::fail(code, message) | |
| 791 | } | |
| 792 | ||
| 793 | fn path_of(repo: &Repo) -> RepoPath { | |
| 794 | RepoPath { | |
| 795 | namespace: repo.namespace.clone(), | |
| 796 | name: repo.name.clone(), | |
| 797 | } | |
| 798 | } | |
| 799 | ||
| 800 | impl<S: GitStore> Repos<S> { | |
| 801 | pub(crate) async fn record(&self, entries: Vec<NewAuditEntry>) { | |
| 802 | let recorded: Result<u32> = | |
| 803 | g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries }).await; | |
| 804 | if let Err(error) = recorded { | |
| 805 | worker::console_error!("audit entries not recorded: {error}"); | |
| 806 | } | |
| 807 | } | |
| 808 | ||
| 809 | /// The repository at `path` an admin is acting on: found, not a | |
| 810 | /// working copy, and the actor allowed `capability` on it (Admin, and | |
| 811 | /// for deleting, an owner of its workspace). | |
| 812 | async fn owned( | |
| 813 | &self, | |
| 814 | actor: &User, | |
| 815 | path: &RepoPath, | |
| 816 | what: &str, | |
| 817 | capability: Capability, | |
| 818 | ) -> Result<std::result::Result<Repo, Refusal>> { | |
| 819 | let viewer = Some(actor.clone()); | |
| 820 | let Some(repo) = self.readable(path, &viewer).await? else { | |
| 821 | return Ok(Err((FailureCode::NotFound, "Repository not found.".into()))); | |
| 822 | }; | |
| 823 | if repo.fork_of.is_some() { | |
| 824 | return Ok(Err((FailureCode::NotFound, "Repository not found.".into()))); | |
| 825 | } | |
| 826 | if let Err(refusal) = admin_only(Asker::on(actor, &repo), &repo.namespace, what, capability) { | |
| 827 | return Ok(Err(refusal)); | |
| 828 | } | |
| 829 | Ok(Ok(repo)) | |
| 830 | } | |
| 831 | ||
| 832 | /// `delete`: see `g1t_contracts::repos::DeleteArgs`. | |
| 833 | pub(crate) async fn delete(&self, a: DeleteArgs) -> Result<Outcome<DeletedRepo>> { | |
| 834 | let repo = match self.owned(&a.actor, &a.path, "delete", Capability::Delete).await? { | |
| 835 | Ok(repo) => repo, | |
| 836 | Err(refusal) => return Ok(fail(refusal)), | |
| 837 | }; | |
| 838 | let path = path_of(&repo); | |
| 839 | if !confirmed(&path, &a.confirm) { | |
| 840 | return Ok(fail(confirm_refusal(&path))); | |
| 841 | } | |
| 842 | let now = now_ms(); | |
| 843 | let at = rfc3339(now); | |
| 844 | let purge = purge_after(now); | |
| 845 | self.registry | |
| 846 | .soft_delete(&repo.id, &a.actor.username, &at, &purge) | |
| 847 | .await?; | |
| 848 | self.publish(NewEvent { | |
| 849 | kind: "repo.deleted", | |
| 850 | source: SOURCE, | |
| 851 | repo_id: Some(repo.id.clone()), | |
| 852 | actor: Some(a.actor.id.clone()), | |
| 853 | data: RepoDeleted { | |
| 854 | repo_id: repo.id.clone(), | |
| 855 | namespace: repo.namespace.clone(), | |
| 856 | name: repo.name.clone(), | |
| 857 | is_private: repo.is_private, | |
| 858 | purge_after: purge.clone(), | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 859 | with_workspace: false, |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 860 | }, |
| 861 | }) | |
| 862 | .await?; | |
| 863 | self.record(vec![entry( | |
| 864 | AuditActor::of(&a.actor), | |
| 865 | "repo.deleted", | |
| 866 | a.surface, | |
| 867 | &path, | |
| 868 | "owner", | |
| 869 | format!("Deleted; restorable until {purge}"), | |
| 870 | )]) | |
| 871 | .await; | |
| 872 | Ok(Outcome::Ok(DeletedRepo { | |
| 873 | id: repo.id, | |
| 874 | namespace: repo.namespace, | |
| 875 | name: repo.name, | |
| 876 | description: repo.description, | |
| 877 | is_private: repo.is_private, | |
| 878 | deleted_at: at, | |
| 879 | deleted_by: a.actor.username, | |
| 880 | purge_after: purge, | |
| 881 | })) | |
| 882 | } | |
| 883 | ||
| 884 | /// `deleted`: see `g1t_contracts::repos::DeletedArgs`. | |
| 885 | pub(crate) async fn deleted(&self, a: DeletedArgs) -> Result<Vec<DeletedRepo>> { | |
| 886 | let namespace = a.namespace.to_lowercase(); | |
| 887 | let owner = a | |
| 888 | .viewer | |
| 889 | .as_ref() | |
| 890 | .is_some_and(|user| user.role_in(&namespace) == Some(Role::Owner)); | |
| 891 | if !owner { | |
| 892 | return Ok(Vec::new()); | |
| 893 | } | |
| 894 | self.registry.deleted_in(&namespace).await | |
| 895 | } | |
| 896 | ||
| 897 | /// The deleted repository an owner is acting on. | |
| 898 | async fn owned_deleted( | |
| 899 | &self, | |
| 900 | actor: &User, | |
| 901 | path: &RepoPath, | |
| 902 | what: &str, | |
| 903 | ) -> Result<std::result::Result<DeletedRepo, Refusal>> { | |
| 904 | if let Err(refusal) = owner_only(Asker::of(actor, &path.namespace), &path.namespace.to_lowercase(), what) { | |
| 905 | return Ok(Err(refusal)); | |
| 906 | } | |
| 907 | Ok(match self.registry.deleted_at(path).await? { | |
| 908 | Some(deleted) => Ok(deleted), | |
| 909 | None => Err(( | |
| 910 | FailureCode::NotFound, | |
| 911 | format!( | |
| 912 | "{}/{} is not among the workspace's recently deleted repositories.", | |
| 913 | path.namespace, path.name | |
| 914 | ), | |
| 915 | )), | |
| 916 | }) | |
| 917 | } | |
| 918 | ||
| 919 | /// `restore`: see `g1t_contracts::repos::DeletedRepoArgs`. | |
| 920 | pub(crate) async fn restore(&self, a: DeletedRepoArgs) -> Result<Outcome<Repo>> { | |
| 921 | let deleted = match self.owned_deleted(&a.actor, &a.path, "restore").await? { | |
| 922 | Ok(deleted) => deleted, | |
| 923 | Err(refusal) => return Ok(fail(refusal)), | |
| 924 | }; | |
| 925 | let deleted_state = state( | |
| 926 | None, | |
| 927 | Some((&deleted.deleted_at, &deleted.purge_after)), | |
| 928 | &rfc3339(now_ms()), | |
| 929 | ); | |
| 930 | if deleted_state == State::Due { | |
| 931 | return Ok(Outcome::fail( | |
| 932 | FailureCode::Conflict, | |
| 933 | format!("{}/{} is being purged and can no longer be restored.", deleted.namespace, deleted.name), | |
| 934 | )); | |
| 935 | } | |
| 936 | self.registry.undelete(&deleted.id).await?; | |
| 937 | let Some(repo) = self.registry.by_id(&deleted.id).await? else { | |
| 938 | return Ok(not_found()); | |
| 939 | }; | |
| 940 | self.publish(NewEvent { | |
| 941 | kind: "repo.restored", | |
| 942 | source: SOURCE, | |
| 943 | repo_id: Some(repo.id.clone()), | |
| 944 | actor: Some(a.actor.id.clone()), | |
| 945 | data: RepoRestored { | |
| 946 | repo_id: repo.id.clone(), | |
| 947 | namespace: repo.namespace.clone(), | |
| 948 | name: repo.name.clone(), | |
| 949 | is_private: repo.is_private, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 950 | with_workspace: false, |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 951 | }, |
| 952 | }) | |
| 953 | .await?; | |
| 954 | self.record(vec![entry( | |
| 955 | AuditActor::of(&a.actor), | |
| 956 | "repo.restored", | |
| 957 | a.surface, | |
| 958 | &path_of(&repo), | |
| 959 | "owner", | |
| 960 | format!("Restored; deleted by {} at {}", deleted.deleted_by, deleted.deleted_at), | |
| 961 | )]) | |
| 962 | .await; | |
| 963 | Ok(Outcome::Ok(repo)) | |
| 964 | } | |
| 965 | ||
| 966 | /// `purge`: see `g1t_contracts::repos::DeletedRepoArgs`. | |
| 967 | pub(crate) async fn purge(&self, a: DeletedRepoArgs) -> Result<Outcome<bool>> { | |
| 968 | let deleted = match self.owned_deleted(&a.actor, &a.path, "permanently delete").await? { | |
| 969 | Ok(deleted) => deleted, | |
| 970 | Err(refusal) => return Ok(fail(refusal)), | |
| 971 | }; | |
| 972 | let path = RepoPath { | |
| 973 | namespace: deleted.namespace.clone(), | |
| 974 | name: deleted.name.clone(), | |
| 975 | }; | |
| 976 | if !confirmed(&path, a.confirm.as_deref().unwrap_or_default()) { | |
| 977 | return Ok(fail(confirm_refusal(&path))); | |
| 978 | } | |
| 979 | self.purge_now(&deleted, Some(&a.actor.id)).await?; | |
| 980 | self.record(vec![entry( | |
| 981 | AuditActor::of(&a.actor), | |
| 982 | "repo.purged", | |
| 983 | a.surface, | |
| 984 | &path, | |
| 985 | "owner", | |
| 986 | "Permanently deleted, with its git data".to_owned(), | |
| 987 | )]) | |
| 988 | .await; | |
| 989 | Ok(Outcome::Ok(true)) | |
| 990 | } | |
| 991 | ||
| 992 | /// Removes a deleted repository for good: git data first, so a failure | |
| 993 | /// leaves it to the next sweep, then its rows; then says so. | |
| 994 | async fn purge_now(&self, deleted: &DeletedRepo, actor: Option<&str>) -> Result<()> { | |
| 995 | for key in self.registry.store_keys(&deleted.id).await? { | |
| 996 | self.store.delete(&key).await?; | |
| 997 | } | |
| 998 | self.registry.erase(&deleted.id).await?; | |
| 999 | // Who had access to it goes with it. | |
| 1000 | if let Some(identity) = &self.identity { | |
| 1001 | let forgotten: Result<bool> = g1t_kit::call( | |
| 1002 | identity, | |
| 1003 | "forget_repo_access", | |
| 1004 | &g1t_contracts::access::ForgetRepoAccessArgs { | |
| 1005 | repo_id: deleted.id.clone(), | |
| 1006 | }, | |
| 1007 | ) | |
| 1008 | .await; | |
| 1009 | if let Err(error) = forgotten { | |
| 1010 | worker::console_error!("access to {} not forgotten: {error}", deleted.id); | |
| 1011 | } | |
| 1012 | } | |
| 1013 | self.publish(NewEvent { | |
| 1014 | kind: "repo.purged", | |
| 1015 | source: SOURCE, | |
| 1016 | repo_id: Some(deleted.id.clone()), | |
| 1017 | actor: actor.map(str::to_owned), | |
| 1018 | data: RepoPurged { | |
| 1019 | repo_id: deleted.id.clone(), | |
| 1020 | namespace: deleted.namespace.clone(), | |
| 1021 | name: deleted.name.clone(), | |
| 1022 | }, | |
| 1023 | }) | |
| 1024 | .await | |
| 1025 | } | |
| 1026 | ||
| 1027 | /// `purge_due`: see `g1t_contracts::repos::PurgeDueArgs`. | |
| 1028 | pub(crate) async fn purge_due(&self, a: PurgeDueArgs) -> Result<u32> { | |
| 1029 | let limit = a.limit.unwrap_or(PURGES_PER_SWEEP).clamp(1, 100); | |
| 1030 | let due = self.registry.due(&rfc3339(now_ms()), limit).await?; | |
| 1031 | let mut purged = 0; | |
| 1032 | for deleted in due { | |
| 1033 | match self.purge_now(&deleted, None).await { | |
| 1034 | Ok(()) => { | |
| 1035 | purged += 1; | |
| 1036 | let path = RepoPath { | |
| 1037 | namespace: deleted.namespace.clone(), | |
| 1038 | name: deleted.name.clone(), | |
| 1039 | }; | |
| 1040 | self.record(vec![entry( | |
| 1041 | g1t_actor(), | |
| 1042 | "repo.purged", | |
| 1043 | None, | |
| 1044 | &path, | |
| 1045 | "schedule", | |
| 1046 | format!("Purged {RESTORE_DAYS} days after {} deleted it", deleted.deleted_by), | |
| 1047 | )]) | |
| 1048 | .await; | |
| 1049 | } | |
| 1050 | Err(error) => worker::console_error!("{} not purged: {error}", deleted.id), | |
| 1051 | } | |
| 1052 | } | |
| 1053 | Ok(purged) | |
| 1054 | } | |
| 1055 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1056 | /// `workspace.deleting`: every live repository of the workspace is |
| 1057 | /// deleted with it, marked so its restore brings back exactly these, | |
| 1058 | /// and `repo.deleted` (with `with_workspace`) tells services to stop | |
| 1059 | /// what runs for each and hide it. They are purged with the workspace, | |
| 1060 | /// or by the sweep at the same `purge_after`. Never for a protected | |
| 1061 | /// workspace, whoever published it. | |
| 1062 | pub(crate) async fn delete_with_workspace(&self, deleting: &WorkspaceDeleting, protected: &[String]) -> Result<()> { | |
| 1063 | let namespace = deleting.slug.to_lowercase(); | |
| 1064 | if let Err(why) = may_go_with_workspace(&namespace, protected) { | |
| 1065 | worker::console_error!("workspace.deleting for {namespace} ignored: {why}"); | |
| 1066 | return Ok(()); | |
| 1067 | } | |
| 1068 | let marked = self | |
| 1069 | .registry | |
| 1070 | .delete_with_workspace( | |
| 1071 | &namespace, | |
| 1072 | &deleting.workspace_id, | |
| 1073 | &deleting.by, | |
| 1074 | &rfc3339(now_ms()), | |
| 1075 | &deleting.purge_after, | |
| 1076 | ) | |
| 1077 | .await?; | |
| 1078 | for repo in marked { | |
| 1079 | self.publish(NewEvent { | |
| 1080 | kind: "repo.deleted", | |
| 1081 | source: SOURCE, | |
| 1082 | repo_id: Some(repo.id.clone()), | |
| 1083 | actor: None, | |
| 1084 | data: RepoDeleted { | |
| 1085 | repo_id: repo.id.clone(), | |
| 1086 | namespace: repo.namespace.clone(), | |
| 1087 | name: repo.name.clone(), | |
| 1088 | is_private: repo.is_private, | |
| 1089 | purge_after: repo.purge_after.clone(), | |
| 1090 | with_workspace: true, | |
| 1091 | }, | |
| 1092 | }) | |
| 1093 | .await?; | |
| 1094 | } | |
| 1095 | Ok(()) | |
| 1096 | } | |
| 1097 | ||
| 1098 | /// `workspace.restored`: the repositories deleted with the workspace | |
| 1099 | /// come back, and `repo.restored` (with `with_workspace`) says so for | |
| 1100 | /// each. Ones deleted on their own before stay deleted. | |
| 1101 | pub(crate) async fn restore_with_workspace(&self, restored: &WorkspaceRestored) -> Result<()> { | |
| 1102 | let marked = self.registry.deleted_with(&restored.workspace_id).await?; | |
| 1103 | self.registry.undelete_with_workspace(&restored.workspace_id).await?; | |
| 1104 | for deleted in marked { | |
| 1105 | let Some(repo) = self.registry.by_id(&deleted.id).await? else { | |
| 1106 | continue; | |
| 1107 | }; | |
| 1108 | self.publish(NewEvent { | |
| 1109 | kind: "repo.restored", | |
| 1110 | source: SOURCE, | |
| 1111 | repo_id: Some(repo.id.clone()), | |
| 1112 | actor: None, | |
| 1113 | data: RepoRestored { | |
| 1114 | repo_id: repo.id.clone(), | |
| 1115 | namespace: repo.namespace.clone(), | |
| 1116 | name: repo.name.clone(), | |
| 1117 | is_private: repo.is_private, | |
| 1118 | with_workspace: true, | |
| 1119 | }, | |
| 1120 | }) | |
| 1121 | .await?; | |
| 1122 | } | |
| 1123 | Ok(()) | |
| 1124 | } | |
| 1125 | ||
| 1126 | /// `workspace.deleted`: the workspace is purged, and every repository | |
| 1127 | /// it had goes with it: those deleted with it, those deleted before, | |
| 1128 | /// and any still live (a `workspace.deleting` that never arrived). | |
| 1129 | pub(crate) async fn purge_workspace(&self, deleted: &WorkspaceDeleted, protected: &[String]) -> Result<()> { | |
| 1130 | let namespace = deleted.slug.to_lowercase(); | |
| 1131 | if let Err(why) = may_go_with_workspace(&namespace, protected) { | |
| 1132 | worker::console_error!("workspace.deleted for {namespace} ignored: {why}"); | |
| 1133 | return Ok(()); | |
| 1134 | } | |
| 1135 | let now = rfc3339(now_ms()); | |
| 1136 | self.registry | |
| 1137 | .delete_with_workspace(&namespace, &deleted.workspace_id, "g1t", &now, &now) | |
| 1138 | .await?; | |
| 1139 | // A page at a time; one that fails is left to the hourly sweep. | |
| 1140 | loop { | |
| 1141 | let page = self.registry.deleted_ids_in(&namespace).await?; | |
| 1142 | let mut purged = 0; | |
| 1143 | for repo in &page { | |
| 1144 | match self.purge_now(repo, None).await { | |
| 1145 | Ok(()) => purged += 1, | |
| 1146 | Err(error) => worker::console_error!("{} not purged with its workspace: {error}", repo.id), | |
| 1147 | } | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1148 | } |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1149 | if purged == 0 { |
| 1150 | break; | |
| 1151 | } | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1152 | } |
| 1153 | Ok(()) | |
| 1154 | } | |
| 1155 | ||
| 1156 | /// `rename`: see `g1t_contracts::repos::RenameArgs`. | |
| 1157 | pub(crate) async fn rename(&self, a: RenameArgs) -> Result<Outcome<Repo>> { | |
| 1158 | let repo = match self.owned(&a.actor, &a.path, "rename", Capability::Administer).await? { | |
| 1159 | Ok(repo) => repo, | |
| 1160 | Err(refusal) => return Ok(fail(refusal)), | |
| 1161 | }; | |
| 1162 | let wanted = RepoPath { | |
| 1163 | namespace: repo.namespace.clone(), | |
| 1164 | name: a.name.trim().to_lowercase(), | |
| 1165 | }; | |
| 1166 | let held = match self.registry.by_path_any(&wanted).await? { | |
| 1167 | None => Held::Free, | |
| 1168 | Some((_, None)) => Held::ByRepo, | |
| 1169 | Some((_, Some(_))) => Held::ByDeleted, | |
| 1170 | }; | |
| 1171 | let name = match new_name(&repo.namespace, &repo.name, &a.name, held) { | |
| 1172 | Ok(name) => name, | |
| 1173 | Err(refusal) => return Ok(fail(refusal)), | |
| 1174 | }; | |
| 1175 | // The git store key stays what it was; the new path must not | |
| 1176 | // change where it is read from. | |
| 1177 | let key = store_key(&repo); | |
| 1178 | self.registry.rename(&repo, &name).await?; | |
| 1179 | let renamed = Repo { | |
| 1180 | name: name.clone(), | |
| 1181 | ..repo.clone() | |
| 1182 | }; | |
| 1183 | remember_store(&renamed, &key); | |
| 1184 | let from = path_of(&repo); | |
| 1185 | let to = path_of(&renamed); | |
| 1186 | if let Some(identity) = &self.identity { | |
| 1187 | let moved: Result<bool> = g1t_kit::call( | |
| 1188 | identity, | |
| 1189 | "transfer_repo_scopes", | |
| 1190 | &TransferRepoScopesArgs { | |
| 1191 | from: from.clone(), | |
| 1192 | to: to.clone(), | |
| 1193 | }, | |
| 1194 | ) | |
| 1195 | .await; | |
| 1196 | if let Err(error) = moved { | |
| 1197 | worker::console_error!("agent scopes for {} not moved: {error}", repo.id); | |
| 1198 | } | |
| 1199 | } | |
| 1200 | self.publish(NewEvent { | |
| 1201 | kind: "repo.renamed", | |
| 1202 | source: SOURCE, | |
| 1203 | repo_id: Some(repo.id.clone()), | |
| 1204 | actor: Some(a.actor.id.clone()), | |
| 1205 | data: RepoRenamed { | |
| 1206 | repo_id: repo.id.clone(), | |
| 1207 | namespace: repo.namespace.clone(), | |
| 1208 | from: repo.name.clone(), | |
| 1209 | to: name.clone(), | |
| 1210 | }, | |
| 1211 | }) | |
| 1212 | .await?; | |
| 1213 | self.record(vec![entry( | |
| 1214 | AuditActor::of(&a.actor), | |
| 1215 | "repo.renamed", | |
| 1216 | a.surface, | |
| 1217 | &to, | |
| 1218 | "owner", | |
| 1219 | format!("Renamed from {}/{}", from.namespace, from.name), | |
| 1220 | )]) | |
| 1221 | .await; | |
| 1222 | Ok(Outcome::Ok(renamed)) | |
| 1223 | } | |
| 1224 | ||
| 1225 | /// `archive`: see `g1t_contracts::repos::ArchiveArgs`. | |
| 1226 | pub(crate) async fn archive(&self, a: ArchiveArgs) -> Result<Outcome<Repo>> { | |
| 1227 | let what = if a.archived { "archive" } else { "unarchive" }; | |
| 1228 | let repo = match self.owned(&a.actor, &a.path, what, Capability::Administer).await? { | |
| 1229 | Ok(repo) => repo, | |
| 1230 | Err(refusal) => return Ok(fail(refusal)), | |
| 1231 | }; | |
| 1232 | if repo.archived() == a.archived { | |
| 1233 | return Ok(Outcome::Ok(repo)); | |
| 1234 | } | |
| 1235 | let at = a.archived.then(|| rfc3339(now_ms())); | |
| 1236 | self.registry.set_archived(&repo.id, at.as_deref()).await?; | |
| 1237 | let changed = Repo { | |
| 1238 | archived_at: at, | |
| 1239 | ..repo | |
| 1240 | }; | |
| 1241 | let kind = if a.archived { "repo.archived" } else { "repo.unarchived" }; | |
| 1242 | self.publish(NewEvent { | |
| 1243 | kind, | |
| 1244 | source: SOURCE, | |
| 1245 | repo_id: Some(changed.id.clone()), | |
| 1246 | actor: Some(a.actor.id.clone()), | |
| 1247 | data: RepoArchived { | |
| 1248 | repo_id: changed.id.clone(), | |
| 1249 | namespace: changed.namespace.clone(), | |
| 1250 | name: changed.name.clone(), | |
| 1251 | archived: a.archived, | |
| 1252 | }, | |
| 1253 | }) | |
| 1254 | .await?; | |
| 1255 | self.record(vec![entry( | |
| 1256 | AuditActor::of(&a.actor), | |
| 1257 | kind, | |
| 1258 | a.surface, | |
| 1259 | &path_of(&changed), | |
| 1260 | "owner", | |
| 1261 | if a.archived { | |
| 1262 | "Archived: read-only".to_owned() | |
| 1263 | } else { | |
| 1264 | "Unarchived".to_owned() | |
| 1265 | }, | |
| 1266 | )]) | |
| 1267 | .await; | |
| 1268 | Ok(Outcome::Ok(changed)) | |
| 1269 | } | |
| 1270 | ||
| 1271 | /// `set_visibility`: see `g1t_contracts::repos::SetVisibilityArgs`. | |
| 1272 | pub(crate) async fn set_visibility(&self, a: SetVisibilityArgs) -> Result<Outcome<Repo>> { | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1273 | let repo = match self.owned(&a.actor, &a.path, "change the visibility of", Capability::ChangeVisibility).await? { |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1274 | Ok(repo) => repo, |
| 1275 | Err(refusal) => return Ok(fail(refusal)), | |
| 1276 | }; | |
| 1277 | if !confirmed(&path_of(&repo), &a.confirm) { | |
| 1278 | return Ok(fail(confirm_refusal(&path_of(&repo)))); | |
| 1279 | } | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1280 | if let Some(why) = visibility_refusal(&a.actor, &repo, a.is_private) { |
| 1281 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); | |
| 1282 | } | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1283 | self.change_visibility(repo, a.is_private, &a.actor, a.surface).await |
| 1284 | } | |
| 1285 | ||
| 1286 | /// Makes `repo` public or private, if the workspace's storage allows, | |
| 1287 | /// and says so: `repo.updated` and `repo.visibility_changed`. The | |
| 1288 | /// caller has checked the actor may. | |
| 1289 | pub(crate) async fn change_visibility( | |
| 1290 | &self, | |
| 1291 | repo: Repo, | |
| 1292 | private: bool, | |
| 1293 | actor: &User, | |
| 1294 | surface: Option<Surface>, | |
| 1295 | ) -> Result<Outcome<Repo>> { | |
| 1296 | if repo.is_private == private { | |
| 1297 | return Ok(Outcome::Ok(repo)); | |
| 1298 | } | |
| 1299 | let facts = if private { | |
| 1300 | VisibilityFacts { | |
| 1301 | to_private: true, | |
| 1302 | free: git_ops::is_free(self.billing.as_ref(), &repo.namespace).await, | |
| 1303 | private_bytes: self.registry.private_bytes(&repo.namespace).await.unwrap_or(0), | |
| 1304 | bytes: self.registry.stored_bytes(&repo.id).await?, | |
| 1305 | free_private_bytes: self.free_private_bytes, | |
| 1306 | } | |
| 1307 | } else { | |
| 1308 | VisibilityFacts::default() | |
| 1309 | }; | |
| 1310 | if let Err(refusal) = visibility_check(&repo.namespace, &facts) { | |
| 1311 | return Ok(fail(refusal)); | |
| 1312 | } | |
| 1313 | self.registry.set_private(&repo.id, private).await?; | |
| 1314 | let changed = Repo { | |
| 1315 | is_private: private, | |
| 1316 | ..repo | |
| 1317 | }; | |
| 1318 | self.publish(NewEvent { | |
| 1319 | kind: "repo.updated", | |
| 1320 | source: SOURCE, | |
| 1321 | repo_id: Some(changed.id.clone()), | |
| 1322 | actor: Some(actor.id.clone()), | |
| 1323 | data: RepoUpdated { | |
| 1324 | repo_id: changed.id.clone(), | |
| 1325 | namespace: changed.namespace.clone(), | |
| 1326 | name: changed.name.clone(), | |
| 1327 | is_private: private, | |
| 1328 | visibility_changed: true, | |
| 1329 | }, | |
| 1330 | }) | |
| 1331 | .await?; | |
| 1332 | self.publish(NewEvent { | |
| 1333 | kind: "repo.visibility_changed", | |
| 1334 | source: SOURCE, | |
| 1335 | repo_id: Some(changed.id.clone()), | |
| 1336 | actor: Some(actor.id.clone()), | |
| 1337 | data: RepoVisibilityChanged { | |
| 1338 | repo_id: changed.id.clone(), | |
| 1339 | is_private: private, | |
| 1340 | }, | |
| 1341 | }) | |
| 1342 | .await?; | |
| 1343 | self.record(vec![entry( | |
| 1344 | AuditActor::of(actor), | |
| 1345 | "repo.visibility_changed", | |
| 1346 | surface, | |
| 1347 | &path_of(&changed), | |
| 1348 | "owner", | |
| 1349 | if private { "Made private".to_owned() } else { "Made public".to_owned() }, | |
| 1350 | )]) | |
| 1351 | .await; | |
| 1352 | Ok(Outcome::Ok(changed)) | |
| 1353 | } | |
| 1354 | ||
| 1355 | /// The repository at `path` someone is changing the branches of: | |
| 1356 | /// found, not a working copy, the actor allowed `capability` on it | |
| 1357 | /// (Push to rename a branch, Administer to change the default), | |
| 1358 | /// verified, and not archived. | |
| 1359 | async fn writable_by( | |
| 1360 | &self, | |
| 1361 | actor: &User, | |
| 1362 | path: &RepoPath, | |
| 1363 | capability: Capability, | |
| 1364 | ) -> Result<std::result::Result<Repo, Refusal>> { | |
| 1365 | let viewer = Some(actor.clone()); | |
| 1366 | let Some(repo) = self.readable(path, &viewer).await? else { | |
| 1367 | return Ok(Err((FailureCode::NotFound, "Repository not found.".into()))); | |
| 1368 | }; | |
| 1369 | if repo.fork_of.is_some() || !crate::registry::can(&repo, &viewer, capability) { | |
| 1370 | return Ok(Err(( | |
| 1371 | FailureCode::Forbidden, | |
| 1372 | access::needs(capability, &format!("{}/{}", repo.namespace, repo.name)), | |
| 1373 | ))); | |
| 1374 | } | |
| 1375 | if !actor.verified { | |
| 1376 | return Ok(Err((FailureCode::Forbidden, UNVERIFIED.into()))); | |
| 1377 | } | |
| 1378 | if let Some(refusal) = archived_refusal(&repo) { | |
| 1379 | return Ok(Err(refusal)); | |
| 1380 | } | |
| Merge branch 'worktree-agent-a2013627e5ea4ab13' | 1381 | // Moving between namespaces: wait for it (moves.rs). |
| 1382 | self.unpaused(repo).await | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1383 | } |
| 1384 | ||
| 1385 | /// `set_default_branch`: see `g1t_contracts::repos::SetDefaultBranchArgs`. | |
| 1386 | pub(crate) async fn set_default_branch(&self, a: SetDefaultBranchArgs) -> Result<Outcome<Repo>> { | |
| 1387 | let repo = match self.writable_by(&a.actor, &a.path, Capability::Administer).await? { | |
| 1388 | Ok(repo) => repo, | |
| 1389 | Err(refusal) => return Ok(fail(refusal)), | |
| 1390 | }; | |
| 1391 | let branch = a.branch.trim().to_owned(); | |
| 1392 | if branch == repo.default_branch { | |
| 1393 | return Ok(Outcome::Ok(repo)); | |
| 1394 | } | |
| 1395 | let git = self.store.open(&store_key(&repo)).await?; | |
| 1396 | if !git.branches().await?.iter().any(|b| b.name == branch) { | |
| 1397 | return Ok(Outcome::fail( | |
| 1398 | FailureCode::Invalid, | |
| 1399 | format!("There is no branch named {branch}. Push it first."), | |
| 1400 | )); | |
| 1401 | } | |
| 1402 | self.registry.set_default_branch(&repo.id, &branch).await?; | |
| 1403 | // HEAD in what git is told follows it. | |
| 1404 | self.refs_moved(&repo.id).await; | |
| 1405 | let from = repo.default_branch.clone(); | |
| 1406 | let changed = Repo { | |
| 1407 | default_branch: branch.clone(), | |
| 1408 | ..repo | |
| 1409 | }; | |
| 1410 | self.forks_follow(&changed, &from, &branch, false).await; | |
| 1411 | self.publish(NewEvent { | |
| 1412 | kind: "repo.default_branch_changed", | |
| 1413 | source: SOURCE, | |
| 1414 | repo_id: Some(changed.id.clone()), | |
| 1415 | actor: Some(a.actor.id.clone()), | |
| 1416 | data: RepoDefaultBranchChanged { | |
| 1417 | repo_id: changed.id.clone(), | |
| 1418 | from: from.clone(), | |
| 1419 | to: branch.clone(), | |
| 1420 | renamed: false, | |
| 1421 | }, | |
| 1422 | }) | |
| 1423 | .await?; | |
| 1424 | self.record(vec![entry( | |
| 1425 | AuditActor::of(&a.actor), | |
| 1426 | "repo.default_branch_changed", | |
| 1427 | a.surface, | |
| 1428 | &path_of(&changed), | |
| 1429 | "member", | |
| 1430 | format!("Default branch changed from {from} to {branch}"), | |
| 1431 | )]) | |
| 1432 | .await; | |
| 1433 | Ok(Outcome::Ok(changed)) | |
| 1434 | } | |
| 1435 | ||
| 1436 | /// `rename_branch`: see `g1t_contracts::repos::RenameBranchArgs`. | |
| 1437 | pub(crate) async fn rename_branch(&self, a: RenameBranchArgs) -> Result<Outcome<Repo>> { | |
| 1438 | let repo = match self.writable_by(&a.actor, &a.path, Capability::Push).await? { | |
| 1439 | Ok(repo) => repo, | |
| 1440 | Err(refusal) => return Ok(fail(refusal)), | |
| 1441 | }; | |
| 1442 | let from = a.from.trim().to_owned(); | |
| 1443 | let is_default = from == repo.default_branch; | |
| 1444 | if is_default | |
| 1445 | && let Err(refusal) = admin_only( | |
| 1446 | Asker::on(&a.actor, &repo), | |
| 1447 | &repo.namespace, | |
| 1448 | "rename the default branch of", | |
| 1449 | Capability::Administer, | |
| 1450 | ) | |
| 1451 | { | |
| 1452 | return Ok(fail(refusal)); | |
| 1453 | } | |
| 1454 | let git = self.store.open(&store_key(&repo)).await?; | |
| 1455 | let branches = git.branches().await?; | |
| 1456 | let names: Vec<String> = branches.iter().map(|b| b.name.clone()).collect(); | |
| 1457 | let to = match branch_rename(&from, &a.to, &names) { | |
| 1458 | Ok(to) => to, | |
| 1459 | Err(refusal) => return Ok(fail(refusal)), | |
| 1460 | }; | |
| 1461 | let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else { | |
| 1462 | return Ok(not_found()); | |
| 1463 | }; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1464 | // A rename deletes one name and creates another: the rules of both |
| 1465 | // hold (rules.rs). | |
| 1466 | let renamed = vec![ | |
| 1467 | g1t_rules::push::RefChange { | |
| 1468 | git_ref: format!("refs/heads/{from}"), | |
| 1469 | old: Some(head.clone()), | |
| 1470 | new: None, | |
| 1471 | complete: true, | |
| 1472 | ..Default::default() | |
| 1473 | }, | |
| 1474 | g1t_rules::push::RefChange { | |
| 1475 | git_ref: format!("refs/heads/{to}"), | |
| 1476 | old: None, | |
| 1477 | new: Some(head.clone()), | |
| 1478 | complete: true, | |
| 1479 | ..Default::default() | |
| 1480 | }, | |
| 1481 | ]; | |
| 1482 | if let crate::rules::Ruled::Refused { message, .. } = | |
| 1483 | self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::RenameRef, renamed).await? | |
| 1484 | { | |
| 1485 | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 1486 | } | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1487 | let access = git.access(Scope::Write).await?; |
| 1488 | let made = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await?; | |
| 1489 | self.refs_moved(&repo.id).await; | |
| 1490 | if let Err(reason) = made { | |
| 1491 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{to} could not be made: {reason}"))); | |
| 1492 | } | |
| 1493 | // The default moves before the old name goes, so it never names a | |
| 1494 | // branch that is not there. | |
| 1495 | if is_default { | |
| 1496 | self.registry.set_default_branch(&repo.id, &to).await?; | |
| 1497 | } | |
| 1498 | let removed = land::delete_ref(&access, &from, &head).await; | |
| 1499 | self.refs_moved(&repo.id).await; | |
| 1500 | if let Err(reason) = removed? { | |
| 1501 | worker::console_error!("{from} not removed after renaming it to {to}: {reason}"); | |
| 1502 | } | |
| 1503 | self.registry.add_branch_redirect(&repo.id, &from, &to).await?; | |
| 1504 | let changed = if is_default { | |
| 1505 | Repo { | |
| 1506 | default_branch: to.clone(), | |
| 1507 | ..repo | |
| 1508 | } | |
| 1509 | } else { | |
| 1510 | repo | |
| 1511 | }; | |
| 1512 | if is_default { | |
| 1513 | self.forks_follow(&changed, &from, &to, true).await; | |
| 1514 | } | |
| 1515 | self.publish(NewEvent { | |
| 1516 | kind: "branch.renamed", | |
| 1517 | source: SOURCE, | |
| 1518 | repo_id: Some(changed.id.clone()), | |
| 1519 | actor: Some(a.actor.id.clone()), | |
| 1520 | data: BranchRenamed { | |
| 1521 | repo_id: changed.id.clone(), | |
| 1522 | from: from.clone(), | |
| 1523 | to: to.clone(), | |
| 1524 | default_branch: is_default, | |
| 1525 | }, | |
| 1526 | }) | |
| 1527 | .await?; | |
| 1528 | if is_default { | |
| 1529 | self.publish(NewEvent { | |
| 1530 | kind: "repo.default_branch_changed", | |
| 1531 | source: SOURCE, | |
| 1532 | repo_id: Some(changed.id.clone()), | |
| 1533 | actor: Some(a.actor.id.clone()), | |
| 1534 | data: RepoDefaultBranchChanged { | |
| 1535 | repo_id: changed.id.clone(), | |
| 1536 | from: from.clone(), | |
| 1537 | to: to.clone(), | |
| 1538 | renamed: true, | |
| 1539 | }, | |
| 1540 | }) | |
| 1541 | .await?; | |
| 1542 | } | |
| 1543 | self.record(vec![entry( | |
| 1544 | AuditActor::of(&a.actor), | |
| 1545 | "branch.renamed", | |
| 1546 | a.surface, | |
| 1547 | &path_of(&changed), | |
| 1548 | if is_default { "owner" } else { "member" }, | |
| 1549 | format!("Branch {from} renamed to {to}"), | |
| 1550 | )]) | |
| 1551 | .await; | |
| 1552 | Ok(Outcome::Ok(changed)) | |
| 1553 | } | |
| 1554 | ||
| The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days. | 1555 | /// `create_branch`: see `g1t_contracts::repos::CreateBranchArgs`. |
| 1556 | pub(crate) async fn create_branch(&self, a: CreateBranchArgs) -> Result<Outcome<Branch>> { | |
| 1557 | let repo = match self.writable_by(&a.actor, &a.path, Capability::Push).await? { | |
| 1558 | Ok(repo) => repo, | |
| 1559 | Err(refusal) => return Ok(fail(refusal)), | |
| 1560 | }; | |
| 1561 | let git = self.store.open(&store_key(&repo)).await?; | |
| 1562 | let branches = git.branches().await?; | |
| 1563 | let name = match branch_creation(&a.name, &branches) { | |
| 1564 | Ok(name) => name, | |
| 1565 | Err(refusal) => return Ok(fail(refusal)), | |
| 1566 | }; | |
| 1567 | let from = a.from.trim(); | |
| 1568 | let from = if from.is_empty() { repo.default_branch.as_str() } else { from }; | |
| 1569 | // A branch by its name, else whatever git makes of it: a tag or a commit. | |
| 1570 | let head = match branches.iter().find(|branch| branch.name == from) { | |
| 1571 | Some(branch) => Some(branch.hash.clone()), | |
| 1572 | None => git.log(from, 1).await?.into_iter().next().map(|commit| commit.hash), | |
| 1573 | }; | |
| 1574 | let Some(head) = head else { | |
| 1575 | return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no branch, tag or commit {from}."))); | |
| 1576 | }; | |
| 1577 | let made = vec![g1t_rules::push::RefChange { | |
| 1578 | git_ref: format!("refs/heads/{name}"), | |
| 1579 | old: None, | |
| 1580 | new: Some(head.clone()), | |
| 1581 | complete: true, | |
| 1582 | ..Default::default() | |
| 1583 | }]; | |
| 1584 | if let crate::rules::Ruled::Refused { message, .. } = | |
| 1585 | self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::CreateRef, made).await? | |
| 1586 | { | |
| 1587 | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 1588 | } | |
| 1589 | let access = git.access(Scope::Write).await?; | |
| 1590 | let pushed = land::push_pack(&access, &name, None, &head, EMPTY_PACK.to_vec()).await?; | |
| 1591 | self.refs_moved(&repo.id).await; | |
| 1592 | if let Err(reason) = pushed { | |
| 1593 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{name} could not be made: {reason}"))); | |
| 1594 | } | |
| 1595 | self.publish_push(&repo, &format!("refs/heads/{name}"), None, &head, Some(&a.actor), Some(0)).await?; | |
| 1596 | self.record(vec![entry( | |
| 1597 | AuditActor::of(&a.actor), | |
| 1598 | "branch.created", | |
| 1599 | a.surface, | |
| 1600 | &path_of(&repo), | |
| 1601 | "member", | |
| 1602 | format!("Branch {name} created at {}", &head[..head.len().min(7)]), | |
| 1603 | )]) | |
| 1604 | .await; | |
| 1605 | Ok(Outcome::Ok(Branch { name, hash: head })) | |
| 1606 | } | |
| 1607 | ||
| 1608 | /// `remove_branch`: see `g1t_contracts::repos::RemoveBranchArgs`. | |
| 1609 | pub(crate) async fn remove_branch(&self, a: RemoveBranchArgs) -> Result<Outcome<Branch>> { | |
| 1610 | let repo = match self.writable_by(&a.actor, &a.path, Capability::Push).await? { | |
| 1611 | Ok(repo) => repo, | |
| 1612 | Err(refusal) => return Ok(fail(refusal)), | |
| 1613 | }; | |
| 1614 | let branch = a.branch.trim().to_owned(); | |
| 1615 | let git = self.store.open(&store_key(&repo)).await?; | |
| 1616 | let branches = git.branches().await?; | |
| 1617 | let head = match branch_removal(&branch, &repo.default_branch, a.head.as_deref(), &branches) { | |
| 1618 | Ok(head) => head, | |
| 1619 | Err(refusal) => return Ok(fail(refusal)), | |
| 1620 | }; | |
| 1621 | let removed = vec![g1t_rules::push::RefChange { | |
| 1622 | git_ref: format!("refs/heads/{branch}"), | |
| 1623 | old: Some(head.clone()), | |
| 1624 | new: None, | |
| 1625 | complete: true, | |
| 1626 | ..Default::default() | |
| 1627 | }]; | |
| 1628 | if let crate::rules::Ruled::Refused { message, .. } = | |
| 1629 | self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::DeleteRef, removed).await? | |
| 1630 | { | |
| 1631 | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 1632 | } | |
| 1633 | let access = git.access(Scope::Write).await?; | |
| 1634 | let deleted = land::delete_ref(&access, &branch, &head).await?; | |
| 1635 | self.refs_moved(&repo.id).await; | |
| 1636 | if let Err(reason) = deleted { | |
| 1637 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} could not be deleted: {reason}"))); | |
| 1638 | } | |
| 1639 | let git_ref = format!("refs/heads/{branch}"); | |
| 1640 | self.publish_deleted(&repo, &[(git_ref, head.clone())], Some(&a.actor.id), None).await?; | |
| 1641 | self.record(vec![entry( | |
| 1642 | AuditActor::of(&a.actor), | |
| 1643 | "branch.deleted", | |
| 1644 | a.surface, | |
| 1645 | &path_of(&repo), | |
| 1646 | "member", | |
| 1647 | format!("Branch {branch} deleted at {}", &head[..head.len().min(7)]), | |
| 1648 | )]) | |
| 1649 | .await; | |
| 1650 | Ok(Outcome::Ok(Branch { name: branch, hash: head })) | |
| 1651 | } | |
| 1652 | ||
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1653 | /// Pull requests' working copies name their branch after the default |
| 1654 | /// branch of the repository they came from. When it changes, the | |
| 1655 | /// newest of them get a branch of the new name at the same commit (and, | |
| 1656 | /// for a rename, lose the old one), so agents and merges find it. | |
| 1657 | /// Best effort: a copy that cannot follow is logged and left. | |
| 1658 | async fn forks_follow(&self, repo: &Repo, from: &str, to: &str, renamed: bool) { | |
| 1659 | let forks = match self.registry.forks_of(&repo.id, FORKS_FOLLOWING).await { | |
| 1660 | Ok(forks) => forks, | |
| 1661 | Err(error) => { | |
| 1662 | worker::console_error!("working copies of {} not listed: {error}", repo.id); | |
| 1663 | return; | |
| 1664 | } | |
| 1665 | }; | |
| 1666 | for fork in forks { | |
| 1667 | let followed: Result<()> = async { | |
| 1668 | let git = self.store.open(&store_key(&fork)).await?; | |
| 1669 | let branches = git.branches().await?; | |
| 1670 | let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else { | |
| 1671 | return Ok(()); | |
| 1672 | }; | |
| 1673 | let access = git.access(Scope::Write).await?; | |
| 1674 | if !branches.iter().any(|b| b.name == to) { | |
| 1675 | let made = land::push_pack(&access, to, None, &head, EMPTY_PACK.to_vec()).await; | |
| 1676 | self.refs_moved(&fork.id).await; | |
| 1677 | if let Err(reason) = made? { | |
| 1678 | worker::console_error!("working copy {} did not get {to}: {reason}", fork.id); | |
| 1679 | return Ok(()); | |
| 1680 | } | |
| 1681 | } | |
| 1682 | self.registry.set_default_branch(&fork.id, to).await?; | |
| 1683 | if renamed { | |
| 1684 | let removed = land::delete_ref(&access, from, &head).await; | |
| 1685 | self.refs_moved(&fork.id).await; | |
| 1686 | if let Err(reason) = removed? { | |
| 1687 | worker::console_error!("working copy {} kept {from}: {reason}", fork.id); | |
| 1688 | } | |
| 1689 | } | |
| 1690 | Ok(()) | |
| 1691 | } | |
| 1692 | .await; | |
| 1693 | if let Err(error) = followed { | |
| 1694 | worker::console_error!("working copy {} did not follow {from} → {to}: {error}", fork.id); | |
| 1695 | } | |
| 1696 | } | |
| 1697 | } | |
| 1698 | ||
| 1699 | /// `resolve_branch`: see `g1t_contracts::repos::ResolveBranchArgs`. | |
| 1700 | pub(crate) async fn resolve_branch(&self, a: ResolveBranchArgs) -> Result<Option<String>> { | |
| 1701 | let Some(now) = self.registry.branch_redirect(&a.repo_id, &a.branch).await? else { | |
| 1702 | return Ok(None); | |
| 1703 | }; | |
| 1704 | // A branch made again under the old name ends the redirect. | |
| 1705 | let Some(repo) = self.registry.by_id(&a.repo_id).await? else { | |
| 1706 | return Ok(None); | |
| 1707 | }; | |
| 1708 | let git = self.store.open(&store_key(&repo)).await?; | |
| 1709 | let branches = git.branches().await?; | |
| 1710 | if branches.iter().any(|b| b.name == a.branch) || !branches.iter().any(|b| b.name == now) { | |
| 1711 | return Ok(None); | |
| 1712 | } | |
| 1713 | Ok(Some(now)) | |
| 1714 | } | |
| 1715 | ||
| 1716 | /// `status_by_id`: see `g1t_contracts::repos::StatusByIdArgs`. | |
| 1717 | pub(crate) async fn status_by_id(&self, a: StatusByIdArgs) -> Result<RepoStatus> { | |
| 1718 | self.registry.status(&a.id).await | |
| 1719 | } | |
| 1720 | } | |
| 1721 | ||
| 1722 | #[cfg(test)] | |
| 1723 | mod tests { | |
| 1724 | use super::*; | |
| 1725 | ||
| 1726 | fn owner() -> Asker { | |
| 1727 | Asker { | |
| 1728 | person: true, | |
| 1729 | verified: true, | |
| 1730 | role: Some(Role::Owner), | |
| 1731 | repo_role: Some(RepoRole::Admin), | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1732 | privileges: g1t_contracts::MemberPrivileges::default(), |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1733 | } |
| 1734 | } | |
| 1735 | ||
| 1736 | fn path() -> RepoPath { | |
| 1737 | RepoPath { | |
| 1738 | namespace: "acme".into(), | |
| 1739 | name: "rocket".into(), | |
| 1740 | } | |
| 1741 | } | |
| 1742 | ||
| 1743 | #[test] | |
| 1744 | fn only_a_verified_person_who_owns_the_workspace_may() { | |
| 1745 | assert!(owner_only(owner(), "acme", "delete").is_ok()); | |
| 1746 | let member = Asker { role: Some(Role::Member), ..owner() }; | |
| 1747 | let (code, message) = owner_only(member, "acme", "delete").unwrap_err(); | |
| 1748 | assert_eq!(code, FailureCode::Forbidden); | |
| 1749 | assert_eq!(message, "Only an owner of acme can delete its repositories."); | |
| 1750 | assert_eq!(owner_only(Asker { person: false, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::Forbidden); | |
| 1751 | assert_eq!(owner_only(Asker { verified: false, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::Forbidden); | |
| 1752 | // Outside the workspace, a private repository is not there at all. | |
| 1753 | assert_eq!(owner_only(Asker { role: None, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::NotFound); | |
| 1754 | } | |
| 1755 | ||
| 1756 | /// Renaming, archiving and changing visibility take Admin on the | |
| 1757 | /// repository, which a direct grant can give; deleting and | |
| 1758 | /// transferring still take an owner of the workspace. | |
| 1759 | #[test] | |
| 1760 | fn admin_on_the_repository_may_administer_but_not_delete() { | |
| 1761 | let admin = Asker { role: None, repo_role: Some(RepoRole::Admin), ..owner() }; | |
| 1762 | assert!(admin_only(admin, "acme", "rename", Capability::Administer).is_ok()); | |
| 1763 | let (code, message) = admin_only(admin, "acme", "delete", Capability::Delete).unwrap_err(); | |
| 1764 | assert_eq!(code, FailureCode::Forbidden); | |
| 1765 | assert_eq!(message, "Only an owner of acme can delete its repositories."); | |
| 1766 | let member_admin = Asker { role: Some(Role::Member), ..admin }; | |
| 1767 | assert_eq!(admin_only(member_admin, "acme", "delete", Capability::Delete).unwrap_err().0, FailureCode::Forbidden); | |
| 1768 | // Write (the default base permission) cannot rename. | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1769 | // By default a member with Admin changes visibility but does not |
| 1770 | // delete; the workspace's member privileges change both. | |
| 1771 | assert!(admin_only(member_admin, "acme", "change the visibility of", Capability::ChangeVisibility).is_ok()); | |
| 1772 | let open = Asker { | |
| 1773 | privileges: g1t_contracts::MemberPrivileges { members_can_delete_repositories: true, ..g1t_contracts::MemberPrivileges::default() }, | |
| 1774 | ..member_admin | |
| 1775 | }; | |
| 1776 | assert!(admin_only(open, "acme", "delete", Capability::Delete).is_ok()); | |
| 1777 | let closed = Asker { | |
| 1778 | privileges: g1t_contracts::MemberPrivileges { members_can_change_repo_visibility: false, ..g1t_contracts::MemberPrivileges::default() }, | |
| 1779 | ..member_admin | |
| 1780 | }; | |
| 1781 | assert_eq!( | |
| 1782 | admin_only(closed, "acme", "change the visibility of", Capability::ChangeVisibility).unwrap_err().0, | |
| 1783 | FailureCode::Forbidden | |
| 1784 | ); | |
| 1785 | // An outside collaborator with Admin never can. | |
| 1786 | assert_eq!(admin_only(Asker { privileges: open.privileges, ..admin }, "acme", "delete", Capability::Delete).unwrap_err().0, FailureCode::Forbidden); | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1787 | let writer = Asker { role: Some(Role::Member), repo_role: Some(RepoRole::Write), ..owner() }; |
| 1788 | let (code, message) = admin_only(writer, "acme", "rename", Capability::Administer).unwrap_err(); | |
| 1789 | assert_eq!(code, FailureCode::Forbidden); | |
| 1790 | assert_eq!(message, "You need the Admin role on a repository of acme to rename it."); | |
| 1791 | // Someone who can read a public repository is refused, not told it is missing. | |
| 1792 | let reader = Asker { role: None, repo_role: Some(RepoRole::Read), ..owner() }; | |
| 1793 | assert_eq!(admin_only(reader, "acme", "archive", Capability::Administer).unwrap_err().0, FailureCode::Forbidden); | |
| 1794 | let stranger = Asker { role: None, repo_role: None, ..owner() }; | |
| 1795 | assert_eq!(admin_only(stranger, "acme", "archive", Capability::Administer).unwrap_err().0, FailureCode::NotFound); | |
| 1796 | assert_eq!(admin_only(Asker { person: false, ..owner() }, "acme", "rename", Capability::Administer).unwrap_err().0, FailureCode::Forbidden); | |
| 1797 | } | |
| 1798 | ||
| 1799 | #[test] | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 1800 | fn a_workspace_restore_brings_back_only_what_went_with_it() { |
| 1801 | assert!(comes_back_with(Some("wsp_1"), "wsp_1")); | |
| 1802 | // Deleted on its own before the workspace was. | |
| 1803 | assert!(!comes_back_with(None, "wsp_1")); | |
| 1804 | // Went with another workspace (it was transferred since). | |
| 1805 | assert!(!comes_back_with(Some("wsp_2"), "wsp_1")); | |
| 1806 | } | |
| 1807 | ||
| 1808 | #[test] | |
| 1809 | fn a_protected_workspace_keeps_its_repositories_whatever_is_published() { | |
| 1810 | let protected = g1t_contracts::identity::protected_names(None); | |
| 1811 | assert_eq!( | |
| 1812 | may_go_with_workspace("flagon-io", &protected).unwrap_err(), | |
| 1813 | "flagon-io is protected and can never be deleted." | |
| 1814 | ); | |
| 1815 | assert!(may_go_with_workspace("FLAGON-IO", &protected).is_err()); | |
| 1816 | assert!(may_go_with_workspace("acme", &protected).is_ok()); | |
| 1817 | let configured = g1t_contracts::identity::protected_names(Some("acme")); | |
| 1818 | assert!(may_go_with_workspace("acme", &configured).is_err()); | |
| 1819 | } | |
| 1820 | ||
| 1821 | #[test] | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1822 | fn the_full_name_confirms_in_any_case() { |
| 1823 | assert!(confirmed(&path(), "acme/rocket")); | |
| 1824 | assert!(confirmed(&path(), " ACME/Rocket ")); | |
| 1825 | assert!(!confirmed(&path(), "rocket")); | |
| 1826 | assert!(!confirmed(&path(), "")); | |
| 1827 | } | |
| 1828 | ||
| 1829 | #[test] | |
| 1830 | fn a_deleted_repository_is_restorable_for_thirty_days_then_due() { | |
| 1831 | let deleted_at = 1_790_000_000_000u64; | |
| 1832 | let purge = purge_after(deleted_at); | |
| 1833 | assert_eq!(purge, rfc3339(deleted_at + 30 * 86_400_000)); | |
| 1834 | let day = 86_400_000u64; | |
| 1835 | let at = |ms: u64| rfc3339(ms); | |
| 1836 | assert_eq!(state(None, None, &at(deleted_at)), State::Active); | |
| 1837 | assert_eq!(state(Some("2026-10-01T00:00:00.000Z"), None, &at(deleted_at)), State::Archived); | |
| 1838 | let deleted = Some((at(deleted_at), purge.clone())); | |
| 1839 | let deleted = deleted.as_ref().map(|(a, b)| (a.as_str(), b.as_str())); | |
| 1840 | assert_eq!(state(None, deleted, &at(deleted_at + day)), State::Deleted); | |
| 1841 | assert_eq!(state(None, deleted, &at(deleted_at + 30 * day - 1)), State::Deleted); | |
| 1842 | assert_eq!(state(None, deleted, &at(deleted_at + 30 * day)), State::Due); | |
| 1843 | // Archived and then deleted: deleted is what counts. | |
| 1844 | assert_eq!(state(Some("x"), deleted, &at(deleted_at + day)), State::Deleted); | |
| 1845 | assert!(restorable(&purge, &at(deleted_at + 29 * day))); | |
| 1846 | assert!(!restorable(&purge, &at(deleted_at + 31 * day))); | |
| 1847 | } | |
| 1848 | ||
| 1849 | #[test] | |
| 1850 | fn a_rename_needs_a_valid_free_name() { | |
| 1851 | assert_eq!(new_name("acme", "rocket", " Booster ", Held::Free).unwrap(), "booster"); | |
| 1852 | assert_eq!(new_name("acme", "rocket", "rocket", Held::Free).unwrap_err().0, FailureCode::Invalid); | |
| 1853 | assert_eq!(new_name("acme", "rocket", "no spaces", Held::Free).unwrap_err().0, FailureCode::Invalid); | |
| 1854 | assert_eq!(new_name("acme", "rocket", "x.git", Held::Free).unwrap_err().0, FailureCode::Invalid); | |
| 1855 | let (code, message) = new_name("acme", "rocket", "booster", Held::ByRepo).unwrap_err(); | |
| 1856 | assert_eq!(code, FailureCode::Conflict); | |
| 1857 | assert_eq!(message, "acme already has a repository named booster."); | |
| 1858 | let (code, message) = new_name("acme", "rocket", "booster", Held::ByDeleted).unwrap_err(); | |
| 1859 | assert_eq!(code, FailureCode::Conflict); | |
| 1860 | assert!(message.contains("deleted recently")); | |
| 1861 | } | |
| 1862 | ||
| 1863 | fn repo(archived: bool) -> Repo { | |
| 1864 | Repo { | |
| 1865 | id: "rep_1".into(), | |
| 1866 | namespace: "acme".into(), | |
| 1867 | name: "rocket".into(), | |
| 1868 | description: None, | |
| 1869 | is_private: false, | |
| 1870 | owner_id: "usr_1".into(), | |
| 1871 | default_branch: "main".into(), | |
| 1872 | fork_of: None, | |
| 1873 | protected: false, | |
| 1874 | created_at: String::new(), | |
| 1875 | topics: Vec::new(), | |
| 1876 | website: None, | |
| 1877 | archived_at: archived.then(|| "2026-10-05T00:00:00.000Z".to_owned()), | |
| Merge branch 'mirroring' into artifacts-mode | 1878 | mirror: None, |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1879 | } |
| 1880 | } | |
| 1881 | ||
| 1882 | #[test] | |
| Merge branch 'mirroring' into artifacts-mode | 1883 | fn a_mirror_refuses_writes_until_g1t_takes_over() { |
| 1884 | use g1t_contracts::mirrors::{MirrorState, RepoMirror}; | |
| 1885 | let mirror = |state| Repo { | |
| 1886 | mirror: Some(RepoMirror { state, remote: "github.com/acme/rocket".into(), ..RepoMirror::default() }), | |
| 1887 | ..repo(false) | |
| 1888 | }; | |
| 1889 | let (code, message) = read_only_refusal(&mirror(MirrorState::Standby)).unwrap(); | |
| 1890 | assert_eq!(code, FailureCode::Forbidden); | |
| 1891 | assert!(message.contains("acme/rocket is a mirror of github.com/acme/rocket")); | |
| 1892 | assert!(message.contains("take over")); | |
| 1893 | assert!(read_only_refusal(&mirror(MirrorState::Ci)).is_some(), "CI failover runs workflows, not pushes"); | |
| 1894 | assert!(read_only_refusal(&mirror(MirrorState::Takeover)).is_none()); | |
| 1895 | assert!(read_only_refusal(&mirror(MirrorState::HandingBack)).unwrap().1.contains("handing back")); | |
| 1896 | assert!(archived_refusal(&mirror(MirrorState::Standby)).is_none(), "a mirror's settings stay changeable"); | |
| 1897 | assert!(read_only_refusal(&repo(true)).unwrap().1.contains("archived")); | |
| 1898 | assert!(read_only_refusal(&repo(false)).is_none()); | |
| 1899 | } | |
| 1900 | ||
| 1901 | #[test] | |
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1902 | fn an_archived_repository_refuses_writes_with_the_reason() { |
| 1903 | assert!(archived_refusal(&repo(false)).is_none()); | |
| 1904 | let (code, message) = archived_refusal(&repo(true)).unwrap(); | |
| 1905 | assert_eq!(code, FailureCode::Forbidden); | |
| 1906 | assert_eq!(message, "acme/rocket is archived, so it is read-only. An owner can unarchive it in its settings."); | |
| 1907 | } | |
| 1908 | ||
| 1909 | #[test] | |
| 1910 | fn going_private_on_a_free_workspace_needs_room() { | |
| 1911 | let full = VisibilityFacts { | |
| 1912 | to_private: true, | |
| 1913 | free: true, | |
| 1914 | private_bytes: 900_000_000, | |
| 1915 | bytes: 200_000_000, | |
| 1916 | free_private_bytes: 1_000_000_000, | |
| 1917 | }; | |
| 1918 | assert_eq!(visibility_check("acme", &full).unwrap_err().0, FailureCode::PaymentRequired); | |
| 1919 | assert!(visibility_check("acme", &VisibilityFacts { free: false, ..full }).is_ok()); | |
| 1920 | let full = VisibilityFacts { | |
| 1921 | to_private: true, | |
| 1922 | free: true, | |
| 1923 | private_bytes: 900_000_000, | |
| 1924 | bytes: 200_000_000, | |
| 1925 | free_private_bytes: 1_000_000_000, | |
| 1926 | }; | |
| 1927 | // Going public is never refused. | |
| 1928 | assert!(visibility_check("acme", &VisibilityFacts { to_private: false, ..full }).is_ok()); | |
| 1929 | let light = VisibilityFacts { | |
| 1930 | to_private: true, | |
| 1931 | free: true, | |
| 1932 | private_bytes: 1_000, | |
| 1933 | bytes: 1_000, | |
| 1934 | free_private_bytes: 1_000_000_000, | |
| 1935 | }; | |
| 1936 | assert!(visibility_check("acme", &light).is_ok()); | |
| 1937 | } | |
| 1938 | ||
| 1939 | #[test] | |
| 1940 | fn a_branch_is_renamed_to_a_free_valid_name() { | |
| 1941 | let branches = vec!["main".to_owned(), "dev".to_owned()]; | |
| 1942 | assert_eq!(branch_rename("main", " trunk ", &branches).unwrap(), "trunk"); | |
| 1943 | assert_eq!(branch_rename("nope", "trunk", &branches).unwrap_err().0, FailureCode::NotFound); | |
| 1944 | assert_eq!(branch_rename("main", "dev", &branches).unwrap_err().0, FailureCode::Conflict); | |
| 1945 | assert_eq!(branch_rename("main", "main", &branches).unwrap_err().0, FailureCode::Invalid); | |
| 1946 | assert_eq!(branch_rename("main", "a b", &branches).unwrap_err().0, FailureCode::Invalid); | |
| 1947 | assert_eq!(branch_rename("main", "g1t-queue", &branches).unwrap_err().0, FailureCode::Invalid); | |
| 1948 | } | |
| 1949 | ||
| The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days. | 1950 | fn branch(name: &str, hash: &str) -> Branch { |
| 1951 | Branch { name: name.to_owned(), hash: hash.to_owned() } | |
| 1952 | } | |
| 1953 | ||
| 1954 | #[test] | |
| 1955 | fn a_branch_is_made_with_a_free_valid_name_that_is_no_other_branch_folder() { | |
| 1956 | let branches = vec![branch("main", "aaa"), branch("feature", "bbb"), branch("fix/login", "ccc")]; | |
| 1957 | assert_eq!(branch_creation(" docs/intro ", &branches).unwrap(), "docs/intro"); | |
| 1958 | assert_eq!(branch_creation("main", &branches).unwrap_err().0, FailureCode::Conflict); | |
| 1959 | // `feature` is a branch, so it cannot also be a folder of one. | |
| 1960 | assert_eq!(branch_creation("feature/new", &branches).unwrap_err().0, FailureCode::Conflict); | |
| 1961 | // `fix` holds `fix/login`, so it cannot be a branch itself. | |
| 1962 | assert_eq!(branch_creation("fix", &branches).unwrap_err().0, FailureCode::Conflict); | |
| 1963 | assert!(branch_creation("fixes", &branches).is_ok()); | |
| 1964 | assert_eq!(branch_creation("a b", &branches).unwrap_err().0, FailureCode::Invalid); | |
| 1965 | assert_eq!(branch_creation("g1t-queue/1", &branches).unwrap_err().0, FailureCode::Invalid); | |
| 1966 | assert_eq!(branch_creation("", &branches).unwrap_err().0, FailureCode::Invalid); | |
| 1967 | } | |
| 1968 | ||
| 1969 | #[test] | |
| 1970 | fn a_branch_is_deleted_only_when_it_is_not_the_default_and_has_not_moved() { | |
| 1971 | let branches = vec![branch("main", "aaa"), branch("feature", "bbb")]; | |
| 1972 | assert_eq!(branch_removal("feature", "main", None, &branches).unwrap(), "bbb"); | |
| 1973 | assert_eq!(branch_removal("feature", "main", Some("bbb"), &branches).unwrap(), "bbb"); | |
| 1974 | assert_eq!(branch_removal("feature", "main", Some(""), &branches).unwrap(), "bbb"); | |
| 1975 | assert_eq!(branch_removal("feature", "main", Some("ccc"), &branches).unwrap_err().0, FailureCode::Conflict); | |
| 1976 | assert_eq!(branch_removal("main", "main", None, &branches).unwrap_err().0, FailureCode::Forbidden); | |
| 1977 | assert_eq!(branch_removal("gone", "main", None, &branches).unwrap_err().0, FailureCode::NotFound); | |
| 1978 | } | |
| 1979 | ||
| Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0 | 1980 | #[test] |
| 1981 | fn the_empty_pack_is_well_formed() { | |
| 1982 | assert_eq!(EMPTY_PACK.len(), 32); | |
| 1983 | assert!(EMPTY_PACK.starts_with(b"PACK\0\0\0\x02\0\0\0\0")); | |
| 1984 | } | |
| 1985 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.