Skip to content
475 linesCodeBlameRaw

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! Transferring a repository to another workspace.
2//!
3//! A repository keeps its id, its name and its git store key; only its
4//! namespace changes, so its git data does not move at all. The path it
5//! left is kept in `repo_redirects`, pointing at its id, so old links, git
6//! remotes and API calls resolve to wherever it is now, however many times
7//! it has moved since. A redirect stops when a repository is made at its
8//! path.
9//!
10//! Everything other services keep under the repository's path or its
11//! workspace's slug follows on `repo.transferred` (see
12//! `g1t_kit::transfer`); the tokens of agents at work on it are moved here,
13//! with identity, before the event goes out, so a run under way keeps
14//! working.
15
16use g1t_contracts::audit::{
17 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
18};
19use g1t_contracts::events::{NewEvent, RepoTransferred};
20use g1t_contracts::identity::TransferRepoScopesArgs;
21use g1t_contracts::repos::{Repo, RepoPath, TransferArgs};
22use g1t_contracts::time::rfc3339;
23use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, new_id};
24use g1t_kit::now_ms;
25use serde::Deserialize;
26use worker::Result;
27
28use crate::registry::Registry;
29use crate::store::GitStore;
30use crate::{Repos, SOURCE, UNVERIFIED, git_ops, not_found};
31
32/// Everything about a transfer that decides whether it may happen, as read
33/// from the request and the database.
34#[derive(Debug, Default)]
35pub struct Facts {
36 /// The actor is a person, not a workspace's or an agent's token.
37 pub person: bool,
38 pub verified: bool,
39 pub role_in_source: Option<Role>,
40 pub role_in_destination: Option<Role>,
Merge main (membership, two-factor, GitHub repo roles) into tokens41 /// A member (not an owner) of the source with the Admin role on the
42 /// repository, whose member privileges let admins delete and transfer.
43 pub admin_may_transfer: bool,
44 /// The destination's member privileges let its members create a
45 /// repository of this one's visibility.
46 pub may_create_in_destination: bool,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look47 pub source: String,
48 pub destination: String,
49 pub name: String,
50 /// Another repository has the name in the destination.
51 pub name_taken: bool,
52 pub is_private: bool,
53 /// The repository's measured bytes.
54 pub bytes: i64,
55 /// The destination is on no plan, so its private storage is capped.
56 pub destination_free: bool,
57 /// What the destination's private repositories hold now.
58 pub destination_private_bytes: i64,
59 /// What a free workspace's private repositories may hold.
60 pub free_private_bytes: i64,
61}
62
63/// Whether the transfer `facts` describe may happen: `Ok`, or why not, in
64/// words for the person asking.
65pub fn check(facts: &Facts) -> std::result::Result<(), (FailureCode, String)> {
66 let refuse = |code, message: String| Err((code, message));
67 if !facts.person {
68 return refuse(
69 FailureCode::Forbidden,
70 "Only a person can transfer a repository. Sign in, or use a personal access token.".into(),
71 );
72 }
Merge main (membership, two-factor, GitHub repo roles) into tokens73 let source_ok = facts.role_in_source == Some(Role::Owner)
74 || (facts.role_in_source == Some(Role::Member) && facts.admin_may_transfer);
75 if !source_ok {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look76 return refuse(
77 FailureCode::Forbidden,
Merge main (membership, two-factor, GitHub repo roles) into tokens78 format!(
79 "Only an owner of {} can transfer its repositories, unless its member privileges let repository admins.",
80 facts.source
81 ),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look82 );
83 }
84 if !facts.verified {
85 return refuse(FailureCode::Forbidden, UNVERIFIED.into());
86 }
87 if facts.destination.is_empty() {
88 return refuse(FailureCode::Invalid, "Say which workspace to transfer it to.".into());
89 }
90 if facts.destination == facts.source {
91 return refuse(
92 FailureCode::Invalid,
93 format!("{}/{} is already in {}.", facts.source, facts.name, facts.destination),
94 );
95 }
Merge main (membership, two-factor, GitHub repo roles) into tokens96 let destination_ok = facts.role_in_destination == Some(Role::Owner)
97 || (facts.role_in_destination == Some(Role::Member) && facts.may_create_in_destination);
98 if !destination_ok {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look99 return refuse(
100 FailureCode::Forbidden,
101 format!(
Merge main (membership, two-factor, GitHub repo roles) into tokens102 "You can transfer a repository only to a workspace where you can create one, and you cannot in {}.",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look103 facts.destination
104 ),
105 );
106 }
107 if facts.name_taken {
108 return refuse(
109 FailureCode::Conflict,
110 format!(
111 "{} already has a repository named {}. Rename or remove that one first.",
112 facts.destination, facts.name
113 ),
114 );
115 }
116 if facts.is_private
117 && facts.destination_free
118 && git_ops::storage_full(facts.destination_private_bytes + facts.bytes, facts.free_private_bytes)
119 {
120 return refuse(
121 FailureCode::PaymentRequired,
122 format!(
123 "{}'s private repositories would hold {:.2} GB, more than the {:.0} GB a free workspace has. Start the g1t plan in {}, or make the repository public first.",
124 facts.destination,
125 (facts.destination_private_bytes + facts.bytes) as f64 / 1e9,
126 facts.free_private_bytes as f64 / 1e9,
127 facts.destination
128 ),
129 );
130 }
131 Ok(())
132}
133
134/// The redirect of an old path, as read with where its repository is now.
135#[derive(Debug, Deserialize)]
136struct Moved {
137 namespace: String,
138 name: String,
139}
140
141impl Registry {
142 /// Moves a repository to `to`, keeping its old path as a redirect. Any
143 /// redirect held by the path it moves to gives way: a repository is
144 /// there now.
145 pub async fn transfer(&self, repo: &Repo, to: &str) -> Result<()> {
146 let now = rfc3339(now_ms());
147 self.db
148 .batch(vec![
149 self.db
150 .prepare("UPDATE repos SET namespace = ? WHERE id = ? AND namespace = ?")
151 .bind(&[to.into(), repo.id.as_str().into(), repo.namespace.as_str().into()])?,
152 self.db
153 .prepare("DELETE FROM repo_redirects WHERE namespace = ? AND name = ?")
154 .bind(&[to.into(), repo.name.as_str().into()])?,
155 self.db
156 .prepare(
157 "INSERT OR REPLACE INTO repo_redirects (namespace, name, repo_id, created_at)
158 VALUES (?, ?, ?, ?)",
159 )
160 .bind(&[
161 repo.namespace.as_str().into(),
162 repo.name.as_str().into(),
163 repo.id.as_str().into(),
164 now.as_str().into(),
165 ])?,
166 ])
167 .await?;
168 Ok(())
169 }
170
171 /// Where the repository that left `path` is now, unless a repository
172 /// is at `path` itself.
173 pub async fn resolve_moved(&self, path: &RepoPath) -> Result<Option<RepoPath>> {
174 Ok(self
175 .db
176 .prepare(
177 "SELECT repos.namespace, repos.name FROM repo_redirects
178 JOIN repos ON repos.id = repo_redirects.repo_id AND repos.deleted_at IS NULL
179 WHERE repo_redirects.namespace = ?1 AND repo_redirects.name = ?2
180 AND NOT EXISTS (SELECT 1 FROM repos AS here WHERE here.namespace = ?1 AND here.name = ?2)",
181 )
182 .bind(&[
183 path.namespace.to_lowercase().into(),
184 path.name.to_lowercase().into(),
185 ])?
186 .first::<Moved>(None)
187 .await?
188 .map(|moved| RepoPath {
189 namespace: moved.namespace,
190 name: moved.name,
191 }))
192 }
193
194 /// A repository made at `path` ends any redirect there.
195 pub async fn drop_redirect(&self, path: &RepoPath) -> Result<()> {
196 self.db
197 .prepare("DELETE FROM repo_redirects WHERE namespace = ? AND name = ?")
198 .bind(&[path.namespace.as_str().into(), path.name.as_str().into()])?
199 .run()
200 .await?;
201 Ok(())
202 }
203
204 /// How many repositories (not working copies) a workspace holds.
205 pub async fn count_in(&self, namespace: &str) -> Result<u32> {
206 #[derive(Deserialize)]
207 struct Count {
208 n: u32,
209 }
210 Ok(self
211 .db
212 .prepare("SELECT count(*) AS n FROM repos WHERE namespace = ? AND fork_of IS NULL AND deleted_at IS NULL")
213 .bind(&[namespace.to_lowercase().into()])?
214 .first::<Count>(None)
215 .await?
216 .map_or(0, |count| count.n))
217 }
218
219 /// The repository's measured bytes.
220 pub async fn stored_bytes(&self, id: &str) -> Result<i64> {
221 #[derive(Deserialize)]
222 struct Bytes {
223 stored_bytes: Option<f64>,
224 }
225 Ok(self
226 .db
227 .prepare("SELECT stored_bytes FROM repos WHERE id = ? AND deleted_at IS NULL")
228 .bind(&[id.into()])?
229 .first::<Bytes>(None)
230 .await?
231 .and_then(|row| row.stored_bytes)
232 .unwrap_or(0.0) as i64)
233 }
234}
235
236impl<S: GitStore> Repos<S> {
237 /// `transfer`: see `g1t_contracts::repos::TransferArgs`.
238 pub(crate) async fn transfer(&self, a: TransferArgs) -> Result<Outcome<Repo>> {
239 let viewer = Some(a.actor.clone());
240 let Some(repo) = self.readable(&a.path, &viewer).await? else {
241 return Ok(not_found());
242 };
243 if repo.fork_of.is_some() {
244 return Ok(not_found());
245 }
246 let destination = a.to.trim().to_lowercase();
247 let source = repo.namespace.clone();
248 // A repository deleted there but not yet purged holds the name too.
249 let taken = !destination.is_empty()
250 && self
251 .registry
252 .by_path_any(&RepoPath {
253 namespace: destination.clone(),
254 name: repo.name.clone(),
255 })
256 .await?
257 .is_some();
258 let private_checks = repo.is_private && !destination.is_empty() && destination != source;
259 let (bytes, held, free) = if private_checks {
260 let free = git_ops::is_free(self.billing.as_ref(), &destination).await;
261 (
262 self.registry.stored_bytes(&repo.id).await?,
263 self.registry.private_bytes(&destination).await.unwrap_or(0),
264 free,
265 )
266 } else {
267 (0, 0, false)
268 };
Merge main (membership, two-factor, GitHub repo roles) into tokens269 let viewer = Some(a.actor.clone());
270 let admin_may_transfer = crate::registry::role(&repo, &viewer) == Some(g1t_contracts::access::RepoRole::Admin)
271 && a.actor.privileges_in(&source).members_can_delete_repositories;
272 let may_create_in_destination = a
273 .actor
274 .role_in(&destination)
275 .is_some_and(|role| a.actor.privileges_in(&destination).may_create(role, repo.is_private));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look276 let facts = Facts {
277 person: a.actor.kind == PrincipalKind::User,
278 verified: a.actor.verified,
279 role_in_source: a.actor.role_in(&source),
280 role_in_destination: a.actor.role_in(&destination),
Merge main (membership, two-factor, GitHub repo roles) into tokens281 admin_may_transfer,
282 may_create_in_destination,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look283 source: source.clone(),
284 destination: destination.clone(),
285 name: repo.name.clone(),
286 name_taken: taken,
287 is_private: repo.is_private,
288 bytes,
289 destination_free: free,
290 destination_private_bytes: held,
291 free_private_bytes: self.free_private_bytes,
292 };
293 if let Err((code, message)) = check(&facts) {
294 return Ok(Outcome::fail(code, message));
295 }
296
297 self.registry.transfer(&repo, &destination).await?;
298 let moved = Repo {
299 namespace: destination.clone(),
300 ..repo.clone()
301 };
302 let from = RepoPath {
303 namespace: source.clone(),
304 name: repo.name.clone(),
305 };
306 let to = RepoPath {
307 namespace: destination.clone(),
308 name: repo.name.clone(),
309 };
310 // Agents at work on it keep their scope, which names it by path.
311 if let Some(identity) = &self.identity {
312 let moved_scopes: Result<bool> = g1t_kit::call(
313 identity,
314 "transfer_repo_scopes",
315 &TransferRepoScopesArgs {
316 from: from.clone(),
317 to: to.clone(),
318 },
319 )
320 .await;
321 if let Err(error) = moved_scopes {
322 worker::console_error!("agent scopes for {} not moved: {error}", repo.id);
323 }
324 }
325 self.publish(NewEvent {
326 kind: "repo.transferred",
327 source: SOURCE,
328 repo_id: Some(repo.id.clone()),
329 actor: Some(a.actor.id.clone()),
330 data: RepoTransferred {
331 repo_id: repo.id.clone(),
332 name: repo.name.clone(),
333 from: source.clone(),
334 to: destination.clone(),
335 },
336 })
337 .await?;
338 self.record_transfer(&a, &from, &to).await;
339 Ok(Outcome::Ok(moved))
340 }
341
342 /// One entry in each workspace's audit log: the one it left and the
343 /// one it joined.
344 async fn record_transfer(&self, a: &TransferArgs, from: &RepoPath, to: &RepoPath) {
345 let request_id = new_id("req", now_ms());
346 let entry = |workspace: &str, repo: &RepoPath, message: String| NewAuditEntry {
347 actor: AuditActor::of(&a.actor),
348 action: "repo.transferred".to_owned(),
349 surface: a.surface.unwrap_or(Surface::Web),
350 target: AuditTarget {
351 workspace: workspace.to_owned(),
352 repo: Some(format!("{}/{}", repo.namespace, repo.name)),
353 ..AuditTarget::default()
354 },
355 outcome: AuditOutcome::Allowed,
356 rule: "owner".to_owned(),
357 result: Some("ok".to_owned()),
358 message: Some(message),
359 request_id: request_id.clone(),
360 };
361 let entries = vec![
362 entry(
363 &from.namespace,
364 from,
365 format!("Transferred to {}/{}", to.namespace, to.name),
366 ),
367 entry(
368 &to.namespace,
369 to,
370 format!("Transferred from {}/{}", from.namespace, from.name),
371 ),
372 ];
373 let recorded: Result<u32> =
374 g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries }).await;
375 if let Err(error) = recorded {
376 worker::console_error!("transfer audit entries not recorded: {error}");
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 fn facts() -> Facts {
386 Facts {
387 person: true,
388 verified: true,
389 role_in_source: Some(Role::Owner),
390 role_in_destination: Some(Role::Owner),
391 source: "syntaqx".into(),
392 destination: "flagon-io".into(),
393 name: "g1t".into(),
394 free_private_bytes: 1_000_000_000,
395 ..Facts::default()
396 }
397 }
398
399 fn refused(facts: &Facts) -> FailureCode {
400 check(facts).unwrap_err().0
401 }
402
403 #[test]
404 fn an_owner_of_both_may_transfer() {
405 assert!(check(&facts()).is_ok());
406 }
407
408 #[test]
Merge main (membership, two-factor, GitHub repo roles) into tokens409 fn member_privileges_let_an_admin_transfer_where_they_may_create() {
410 let member = Facts { role_in_source: Some(Role::Member), role_in_destination: Some(Role::Member), ..facts() };
411 assert_eq!(refused(&member), FailureCode::Forbidden);
412 let allowed = Facts { admin_may_transfer: true, may_create_in_destination: true, ..member };
413 assert!(check(&allowed).is_ok());
414 assert_eq!(
415 refused(&Facts { may_create_in_destination: false, admin_may_transfer: true, role_in_source: Some(Role::Member), role_in_destination: Some(Role::Member), ..facts() }),
416 FailureCode::Forbidden
417 );
418 }
419
420 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look421 fn needs_a_verified_person_who_owns_both() {
422 assert_eq!(refused(&Facts { person: false, ..facts() }), FailureCode::Forbidden);
423 assert_eq!(refused(&Facts { verified: false, ..facts() }), FailureCode::Forbidden);
424 assert_eq!(
425 refused(&Facts { role_in_source: Some(Role::Member), ..facts() }),
426 FailureCode::Forbidden
427 );
428 assert_eq!(
429 refused(&Facts { role_in_destination: Some(Role::Member), ..facts() }),
430 FailureCode::Forbidden
431 );
432 assert_eq!(refused(&Facts { role_in_destination: None, ..facts() }), FailureCode::Forbidden);
433 }
434
435 #[test]
436 fn needs_somewhere_else_with_the_name_free() {
437 assert_eq!(refused(&Facts { destination: String::new(), ..facts() }), FailureCode::Invalid);
438 assert_eq!(
439 refused(&Facts { destination: "syntaqx".into(), role_in_destination: Some(Role::Owner), ..facts() }),
440 FailureCode::Invalid
441 );
442 let (code, message) = check(&Facts { name_taken: true, ..facts() }).unwrap_err();
443 assert_eq!(code, FailureCode::Conflict);
444 assert!(message.contains("flagon-io already has a repository named g1t"));
445 }
446
447 #[test]
448 fn a_free_destination_takes_private_repositories_within_its_storage() {
449 let heavy = Facts {
450 is_private: true,
451 destination_free: true,
452 destination_private_bytes: 900_000_000,
453 bytes: 200_000_000,
454 ..facts()
455 };
456 assert_eq!(refused(&heavy), FailureCode::PaymentRequired);
457 // Public, or a destination on the plan: no cap.
458 assert!(check(&Facts { is_private: false, ..heavy }).is_ok());
459 let heavy = Facts {
460 is_private: true,
461 destination_free: true,
462 destination_private_bytes: 900_000_000,
463 bytes: 200_000_000,
464 ..facts()
465 };
466 assert!(check(&Facts { destination_free: false, ..heavy }).is_ok());
467 let light = Facts {
468 is_private: true,
469 destination_free: true,
470 bytes: 10_000,
471 ..facts()
472 };
473 assert!(check(&light).is_ok());
474 }
475}

This file's history is long; its oldest lines are credited to the oldest commit read.