Skip to content

Commit ce7b140

Browse files
committed
Added CLI command to automate team and repo archival
1 parent a009122 commit ce7b140

3 files changed

Lines changed: 225 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 37 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ tokio = { version = "1", default-features = false, features = ["net", "rt-multi-
2828
tempfile = "3.19.1"
2929
thiserror = "2.0.18"
3030
toml = "1.0"
31+
toml_edit = "0.22"
3132

3233
[dev-dependencies]
3334
ansi_term = "0.12.1"

src/main.rs

Lines changed: 187 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ use anyhow::{Context, Error, bail, format_err};
2626
use api::github;
2727
use clap::Parser;
2828
use 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};
3131
use 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)]
137154
struct 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

Comments
 (0)