@@ -26,8 +26,8 @@ use anyhow::{Context, Error, bail, format_err};
2626use api:: github;
2727use clap:: Parser ;
2828use log:: { error, info, warn} ;
29- use std:: collections:: { BTreeMap , HashMap } ;
30- use std:: path:: PathBuf ;
29+ use std:: collections:: { BTreeMap , HashMap , HashSet } ;
30+ use std:: path:: { Path , PathBuf } ;
3131use std:: str:: FromStr ;
3232
3333#[ derive( clap:: ValueEnum , Clone , Debug ) ]
@@ -106,6 +106,9 @@ enum RootOpts {
106106 DecryptEmail ,
107107 /// Generate a x25519 key for use with the email encryption module
108108 GenerateKey ,
109+ /// Archive a repo or team, moving it to the archive directory
110+ #[ clap( subcommand) ]
111+ Archive ( ArchiveOpts ) ,
109112 /// CI scripts
110113 #[ clap( subcommand) ]
111114 Ci ( CiOpts ) ,
@@ -133,6 +136,20 @@ enum CiOpts {
133136 CheckUntrackedRepos ,
134137}
135138
139+ #[ derive( clap:: Parser , Clone , Debug ) ]
140+ enum ArchiveOpts {
141+ /// Archive a repository
142+ Repo {
143+ /// Repository in "org/name" format (e.g. "rust-lang/homu")
144+ name : String ,
145+ } ,
146+ /// Archive a team
147+ Team {
148+ /// Team name (e.g. "project-generic-associated-types")
149+ name : String ,
150+ } ,
151+ }
152+
136153#[ derive( clap:: Parser , Clone , Debug ) ]
137154struct SyncOpts {
138155 /// Comma-separated list of available services
@@ -563,6 +580,14 @@ async fn run() -> Result<(), Error> {
563580 let ( secret, public) = rust_team_data:: email_encryption:: generate_x25519_keypair ( ) ;
564581 println ! ( "Generated keypair: secret: {} - public: {}" , secret, public) ;
565582 }
583+ RootOpts :: Archive ( opts) => match opts {
584+ ArchiveOpts :: Repo { ref name } => {
585+ archive_repo ( & cli. data_dir , name) ?;
586+ }
587+ ArchiveOpts :: Team { ref name } => {
588+ archive_team ( & cli. data_dir , name) ?;
589+ }
590+ } ,
566591 RootOpts :: Ci ( opts) => match opts {
567592 CiOpts :: GenerateCodeowners => generate_codeowners_file ( data) ?,
568593 CiOpts :: CheckCodeowners => check_codeowners ( data) ?,
@@ -647,3 +672,163 @@ async fn perform_sync(opts: SyncOpts, data: Data) -> anyhow::Result<()> {
647672 )
648673 . await
649674}
675+
676+ fn get_access_teams ( doc : & mut toml_edit:: DocumentMut ) -> Option < & mut toml_edit:: Table > {
677+ doc. get_mut ( "access" ) ?. get_mut ( "teams" ) ?. as_table_mut ( )
678+ }
679+
680+ fn archive_repo ( data_dir : & Path , name : & str ) -> Result < ( ) , Error > {
681+ let ( org, repo_name) = name
682+ . split_once ( '/' )
683+ . ok_or_else ( || format_err ! ( "repository must be in 'org/name' format, got '{}'" , name) ) ?;
684+
685+ let src = data_dir
686+ . join ( "repos" )
687+ . join ( org)
688+ . join ( format ! ( "{repo_name}.toml" ) ) ;
689+ let dest_dir = data_dir. join ( "repos" ) . join ( "archive" ) . join ( org) ;
690+ let dest = dest_dir. join ( format ! ( "{repo_name}.toml" ) ) ;
691+
692+ if !src. is_file ( ) {
693+ bail ! ( "repo file not found: {}" , src. display( ) ) ;
694+ }
695+ if dest. is_file ( ) {
696+ bail ! ( "repo is already archived: {}" , dest. display( ) ) ;
697+ }
698+
699+ let content = std:: fs:: read_to_string ( & src)
700+ . with_context ( || format ! ( "failed to read {}" , src. display( ) ) ) ?;
701+ let mut doc: toml_edit:: DocumentMut = content
702+ . parse ( )
703+ . with_context ( || format ! ( "failed to parse {}" , src. display( ) ) ) ?;
704+
705+ if let Some ( table) = get_access_teams ( & mut doc) {
706+ table. clear ( ) ;
707+ }
708+
709+ std:: fs:: create_dir_all ( & dest_dir)
710+ . with_context ( || format ! ( "failed to create directory {}" , dest_dir. display( ) ) ) ?;
711+ std:: fs:: write ( & dest, doc. to_string ( ) )
712+ . with_context ( || format ! ( "failed to write {}" , dest. display( ) ) ) ?;
713+ std:: fs:: remove_file ( & src) . with_context ( || format ! ( "failed to remove {}" , src. display( ) ) ) ?;
714+
715+ info ! ( "archived repo {} -> {}" , src. display( ) , dest. display( ) ) ;
716+ Ok ( ( ) )
717+ }
718+
719+ fn archive_team ( data_dir : & Path , name : & str ) -> Result < ( ) , Error > {
720+ let src = data_dir. join ( "teams" ) . join ( format ! ( "{name}.toml" ) ) ;
721+ let dest_dir = data_dir. join ( "teams" ) . join ( "archive" ) ;
722+ let dest = dest_dir. join ( format ! ( "{name}.toml" ) ) ;
723+
724+ if !src. is_file ( ) {
725+ bail ! ( "team file not found: {}" , src. display( ) ) ;
726+ }
727+ if dest. is_file ( ) {
728+ bail ! ( "team is already archived: {}" , dest. display( ) ) ;
729+ }
730+
731+ let content = std:: fs:: read_to_string ( & src)
732+ . with_context ( || format ! ( "failed to read {}" , src. display( ) ) ) ?;
733+ let mut doc: toml_edit:: DocumentMut = content
734+ . parse ( )
735+ . with_context ( || format ! ( "failed to parse {}" , src. display( ) ) ) ?;
736+
737+ if let Some ( people) = doc. get_mut ( "people" )
738+ && let Some ( people_table) = people. as_table_mut ( )
739+ {
740+ let mut all_alumni: Vec < String > = Vec :: new ( ) ;
741+ let mut seen = HashSet :: new ( ) ;
742+
743+ // Collect everyone from leads, members, and existing alumni
744+ for key in & [ "leads" , "members" , "alumni" ] {
745+ if let Some ( arr) = people_table. get ( key) . and_then ( |v| v. as_array ( ) ) {
746+ for item in arr. iter ( ) {
747+ let username = if let Some ( s) = item. as_str ( ) {
748+ s. to_string ( )
749+ } else if let Some ( tbl) = item. as_inline_table ( ) {
750+ match tbl. get ( "github" ) . and_then ( |v| v. as_str ( ) ) {
751+ Some ( s) => s. to_string ( ) ,
752+ None => continue ,
753+ }
754+ } else {
755+ continue ;
756+ } ;
757+ if !username. is_empty ( ) && seen. insert ( username. clone ( ) ) {
758+ all_alumni. push ( username) ;
759+ }
760+ }
761+ }
762+ }
763+
764+ people_table. insert ( "leads" , toml_edit:: value ( toml_edit:: Array :: new ( ) ) ) ;
765+ people_table. insert ( "members" , toml_edit:: value ( toml_edit:: Array :: new ( ) ) ) ;
766+
767+ let mut alumni_array = toml_edit:: Array :: new ( ) ;
768+ for person in & all_alumni {
769+ let mut val = toml_edit:: Value :: from ( person. as_str ( ) ) ;
770+ val. decor_mut ( ) . set_prefix ( "\n " ) ;
771+ alumni_array. push_formatted ( val) ;
772+ }
773+ alumni_array. set_trailing ( "\n " ) ;
774+ alumni_array. set_trailing_comma ( true ) ;
775+ people_table. insert ( "alumni" , toml_edit:: value ( alumni_array) ) ;
776+ }
777+
778+ std:: fs:: create_dir_all ( & dest_dir)
779+ . with_context ( || format ! ( "failed to create directory {}" , dest_dir. display( ) ) ) ?;
780+ std:: fs:: write ( & dest, doc. to_string ( ) )
781+ . with_context ( || format ! ( "failed to write {}" , dest. display( ) ) ) ?;
782+ std:: fs:: remove_file ( & src) . with_context ( || format ! ( "failed to remove {}" , src. display( ) ) ) ?;
783+
784+ info ! ( "archived team {} -> {}" , src. display( ) , dest. display( ) ) ;
785+
786+ remove_team_from_repos ( data_dir, name) ?;
787+
788+ Ok ( ( ) )
789+ }
790+
791+ fn remove_team_from_repos ( data_dir : & Path , team_name : & str ) -> Result < ( ) , Error > {
792+ let repos_dir = data_dir. join ( "repos" ) ;
793+ if !repos_dir. is_dir ( ) {
794+ return Ok ( ( ) ) ;
795+ }
796+
797+ for org_entry in std:: fs:: read_dir ( & repos_dir)
798+ . with_context ( || format ! ( "failed to read {}" , repos_dir. display( ) ) ) ?
799+ {
800+ let org_path = org_entry?. path ( ) ;
801+ if !org_path. is_dir ( ) || org_path. file_name ( ) == Some ( std:: ffi:: OsStr :: new ( "archive" ) ) {
802+ continue ;
803+ }
804+
805+ for repo_entry in std:: fs:: read_dir ( & org_path)
806+ . with_context ( || format ! ( "failed to read {}" , org_path. display( ) ) ) ?
807+ {
808+ let repo_path = repo_entry?. path ( ) ;
809+ if !repo_path. is_file ( ) || repo_path. extension ( ) != Some ( std:: ffi:: OsStr :: new ( "toml" ) ) {
810+ continue ;
811+ }
812+
813+ let content = std:: fs:: read_to_string ( & repo_path)
814+ . with_context ( || format ! ( "failed to read {}" , repo_path. display( ) ) ) ?;
815+ let mut doc: toml_edit:: DocumentMut = content
816+ . parse ( )
817+ . with_context ( || format ! ( "failed to parse {}" , repo_path. display( ) ) ) ?;
818+
819+ let removed = if let Some ( table) = get_access_teams ( & mut doc) {
820+ table. remove ( team_name) . is_some ( )
821+ } else {
822+ false
823+ } ;
824+
825+ if removed {
826+ std:: fs:: write ( & repo_path, doc. to_string ( ) )
827+ . with_context ( || format ! ( "failed to write {}" , repo_path. display( ) ) ) ?;
828+ info ! ( "removed team '{}' from {}" , team_name, repo_path. display( ) ) ;
829+ }
830+ }
831+ }
832+
833+ Ok ( ( ) )
834+ }
0 commit comments