Skip to content

Commit 323852a

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

3 files changed

Lines changed: 224 additions & 4 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: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ use crate::sync::team_api::TeamApi;
2525
use anyhow::{Context, Error, bail, format_err};
2626
use api::github;
2727
use clap::Parser;
28+
use indexmap::IndexSet;
2829
use log::{error, info, warn};
2930
use std::collections::{BTreeMap, HashMap};
30-
use std::path::PathBuf;
31+
use std::path::{Path, PathBuf};
3132
use std::str::FromStr;
3233

3334
#[derive(clap::ValueEnum, Clone, Debug)]
@@ -106,6 +107,9 @@ enum RootOpts {
106107
DecryptEmail,
107108
/// Generate a x25519 key for use with the email encryption module
108109
GenerateKey,
110+
/// Archive a repo or team, moving it to the archive directory
111+
#[clap(subcommand)]
112+
Archive(ArchiveOpts),
109113
/// CI scripts
110114
#[clap(subcommand)]
111115
Ci(CiOpts),
@@ -133,6 +137,20 @@ enum CiOpts {
133137
CheckUntrackedRepos,
134138
}
135139

140+
#[derive(clap::Parser, Clone, Debug)]
141+
enum ArchiveOpts {
142+
/// Archive a repository
143+
Repo {
144+
/// Repository in "org/name" format (e.g. "rust-lang/homu")
145+
name: String,
146+
},
147+
/// Archive a team
148+
Team {
149+
/// Team name (e.g. "project-generic-associated-types")
150+
name: String,
151+
},
152+
}
153+
136154
#[derive(clap::Parser, Clone, Debug)]
137155
struct SyncOpts {
138156
/// Comma-separated list of available services
@@ -563,6 +581,14 @@ async fn run() -> Result<(), Error> {
563581
let (secret, public) = rust_team_data::email_encryption::generate_x25519_keypair();
564582
println!("Generated keypair: secret: {} - public: {}", secret, public);
565583
}
584+
RootOpts::Archive(opts) => match opts {
585+
ArchiveOpts::Repo { ref name } => {
586+
archive_repo(&cli.data_dir, name)?;
587+
}
588+
ArchiveOpts::Team { ref name } => {
589+
archive_team(&cli.data_dir, name)?;
590+
}
591+
},
566592
RootOpts::Ci(opts) => match opts {
567593
CiOpts::GenerateCodeowners => generate_codeowners_file(data)?,
568594
CiOpts::CheckCodeowners => check_codeowners(data)?,
@@ -647,3 +673,162 @@ async fn perform_sync(opts: SyncOpts, data: Data) -> anyhow::Result<()> {
647673
)
648674
.await
649675
}
676+
677+
fn get_access_teams(doc: &mut toml_edit::DocumentMut) -> Option<&mut toml_edit::Table> {
678+
doc.get_mut("access")?.get_mut("teams")?.as_table_mut()
679+
}
680+
681+
fn archive_repo(data_dir: &Path, name: &str) -> Result<(), Error> {
682+
let (org, repo_name) = name
683+
.split_once('/')
684+
.ok_or_else(|| format_err!("repository must be in 'org/name' format, got '{}'", name))?;
685+
686+
let src = data_dir
687+
.join("repos")
688+
.join(org)
689+
.join(format!("{repo_name}.toml"));
690+
let dest_dir = data_dir.join("repos").join("archive").join(org);
691+
let dest = dest_dir.join(format!("{repo_name}.toml"));
692+
693+
if !src.is_file() {
694+
bail!("repo file not found: {}", src.display());
695+
}
696+
if dest.is_file() {
697+
bail!("repo is already archived: {}", dest.display());
698+
}
699+
700+
let content = std::fs::read_to_string(&src)
701+
.with_context(|| format!("failed to read {}", src.display()))?;
702+
let mut doc: toml_edit::DocumentMut = content
703+
.parse()
704+
.with_context(|| format!("failed to parse {}", src.display()))?;
705+
706+
if let Some(table) = get_access_teams(&mut doc) {
707+
table.clear();
708+
}
709+
710+
std::fs::create_dir_all(&dest_dir)
711+
.with_context(|| format!("failed to create directory {}", dest_dir.display()))?;
712+
std::fs::write(&dest, doc.to_string())
713+
.with_context(|| format!("failed to write {}", dest.display()))?;
714+
std::fs::remove_file(&src).with_context(|| format!("failed to remove {}", src.display()))?;
715+
716+
info!("archived repo {} -> {}", src.display(), dest.display());
717+
Ok(())
718+
}
719+
720+
fn archive_team(data_dir: &Path, name: &str) -> Result<(), Error> {
721+
let src = data_dir.join("teams").join(format!("{name}.toml"));
722+
let dest_dir = data_dir.join("teams").join("archive");
723+
let dest = dest_dir.join(format!("{name}.toml"));
724+
725+
if !src.is_file() {
726+
bail!("team file not found: {}", src.display());
727+
}
728+
if dest.is_file() {
729+
bail!("team is already archived: {}", dest.display());
730+
}
731+
732+
let content = std::fs::read_to_string(&src)
733+
.with_context(|| format!("failed to read {}", src.display()))?;
734+
let mut doc: toml_edit::DocumentMut = content
735+
.parse()
736+
.with_context(|| format!("failed to parse {}", src.display()))?;
737+
738+
if let Some(people) = doc.get_mut("people")
739+
&& let Some(people_table) = people.as_table_mut()
740+
{
741+
let mut all_alumni = IndexSet::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() {
758+
all_alumni.insert(username);
759+
}
760+
}
761+
}
762+
}
763+
764+
people_table.insert("leads", toml_edit::Array::new().into());
765+
people_table.insert("members", toml_edit::Array::new().into());
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", alumni_array.into());
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)