From ce7b140af94389baa7a1a87bb43d23c2173fb24f Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Wed, 15 Apr 2026 21:24:51 +0100 Subject: [PATCH] Added CLI command to automate team and repo archival --- Cargo.lock | 40 ++++++++++- Cargo.toml | 1 + src/main.rs | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 225 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52e1002f1..c04b26118 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1869,6 +1869,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml", + "toml_edit", "walkdir", ] @@ -2511,12 +2512,18 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.0", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "1.1.0+spec-1.1.0" @@ -2526,15 +2533,33 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.0", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.0+spec-1.1.0" @@ -3183,6 +3208,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 15c232e76..07a5a35d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ tokio = { version = "1", default-features = false, features = ["net", "rt-multi- tempfile = "3.19.1" thiserror = "2.0.18" toml = "1.0" +toml_edit = "0.22" [dev-dependencies] ansi_term = "0.12.1" diff --git a/src/main.rs b/src/main.rs index 068034ec7..46eb64429 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,8 +26,8 @@ use anyhow::{Context, Error, bail, format_err}; use api::github; use clap::Parser; use log::{error, info, warn}; -use std::collections::{BTreeMap, HashMap}; -use std::path::PathBuf; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; use std::str::FromStr; #[derive(clap::ValueEnum, Clone, Debug)] @@ -106,6 +106,9 @@ enum RootOpts { DecryptEmail, /// Generate a x25519 key for use with the email encryption module GenerateKey, + /// Archive a repo or team, moving it to the archive directory + #[clap(subcommand)] + Archive(ArchiveOpts), /// CI scripts #[clap(subcommand)] Ci(CiOpts), @@ -133,6 +136,20 @@ enum CiOpts { CheckUntrackedRepos, } +#[derive(clap::Parser, Clone, Debug)] +enum ArchiveOpts { + /// Archive a repository + Repo { + /// Repository in "org/name" format (e.g. "rust-lang/homu") + name: String, + }, + /// Archive a team + Team { + /// Team name (e.g. "project-generic-associated-types") + name: String, + }, +} + #[derive(clap::Parser, Clone, Debug)] struct SyncOpts { /// Comma-separated list of available services @@ -563,6 +580,14 @@ async fn run() -> Result<(), Error> { let (secret, public) = rust_team_data::email_encryption::generate_x25519_keypair(); println!("Generated keypair: secret: {} - public: {}", secret, public); } + RootOpts::Archive(opts) => match opts { + ArchiveOpts::Repo { ref name } => { + archive_repo(&cli.data_dir, name)?; + } + ArchiveOpts::Team { ref name } => { + archive_team(&cli.data_dir, name)?; + } + }, RootOpts::Ci(opts) => match opts { CiOpts::GenerateCodeowners => generate_codeowners_file(data)?, CiOpts::CheckCodeowners => check_codeowners(data)?, @@ -647,3 +672,163 @@ async fn perform_sync(opts: SyncOpts, data: Data) -> anyhow::Result<()> { ) .await } + +fn get_access_teams(doc: &mut toml_edit::DocumentMut) -> Option<&mut toml_edit::Table> { + doc.get_mut("access")?.get_mut("teams")?.as_table_mut() +} + +fn archive_repo(data_dir: &Path, name: &str) -> Result<(), Error> { + let (org, repo_name) = name + .split_once('/') + .ok_or_else(|| format_err!("repository must be in 'org/name' format, got '{}'", name))?; + + let src = data_dir + .join("repos") + .join(org) + .join(format!("{repo_name}.toml")); + let dest_dir = data_dir.join("repos").join("archive").join(org); + let dest = dest_dir.join(format!("{repo_name}.toml")); + + if !src.is_file() { + bail!("repo file not found: {}", src.display()); + } + if dest.is_file() { + bail!("repo is already archived: {}", dest.display()); + } + + let content = std::fs::read_to_string(&src) + .with_context(|| format!("failed to read {}", src.display()))?; + let mut doc: toml_edit::DocumentMut = content + .parse() + .with_context(|| format!("failed to parse {}", src.display()))?; + + if let Some(table) = get_access_teams(&mut doc) { + table.clear(); + } + + std::fs::create_dir_all(&dest_dir) + .with_context(|| format!("failed to create directory {}", dest_dir.display()))?; + std::fs::write(&dest, doc.to_string()) + .with_context(|| format!("failed to write {}", dest.display()))?; + std::fs::remove_file(&src).with_context(|| format!("failed to remove {}", src.display()))?; + + info!("archived repo {} -> {}", src.display(), dest.display()); + Ok(()) +} + +fn archive_team(data_dir: &Path, name: &str) -> Result<(), Error> { + let src = data_dir.join("teams").join(format!("{name}.toml")); + let dest_dir = data_dir.join("teams").join("archive"); + let dest = dest_dir.join(format!("{name}.toml")); + + if !src.is_file() { + bail!("team file not found: {}", src.display()); + } + if dest.is_file() { + bail!("team is already archived: {}", dest.display()); + } + + let content = std::fs::read_to_string(&src) + .with_context(|| format!("failed to read {}", src.display()))?; + let mut doc: toml_edit::DocumentMut = content + .parse() + .with_context(|| format!("failed to parse {}", src.display()))?; + + if let Some(people) = doc.get_mut("people") + && let Some(people_table) = people.as_table_mut() + { + let mut all_alumni: Vec = Vec::new(); + let mut seen = HashSet::new(); + + // Collect everyone from leads, members, and existing alumni + for key in &["leads", "members", "alumni"] { + if let Some(arr) = people_table.get(key).and_then(|v| v.as_array()) { + for item in arr.iter() { + let username = if let Some(s) = item.as_str() { + s.to_string() + } else if let Some(tbl) = item.as_inline_table() { + match tbl.get("github").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => continue, + } + } else { + continue; + }; + if !username.is_empty() && seen.insert(username.clone()) { + all_alumni.push(username); + } + } + } + } + + people_table.insert("leads", toml_edit::value(toml_edit::Array::new())); + people_table.insert("members", toml_edit::value(toml_edit::Array::new())); + + let mut alumni_array = toml_edit::Array::new(); + for person in &all_alumni { + let mut val = toml_edit::Value::from(person.as_str()); + val.decor_mut().set_prefix("\n "); + alumni_array.push_formatted(val); + } + alumni_array.set_trailing("\n"); + alumni_array.set_trailing_comma(true); + people_table.insert("alumni", toml_edit::value(alumni_array)); + } + + std::fs::create_dir_all(&dest_dir) + .with_context(|| format!("failed to create directory {}", dest_dir.display()))?; + std::fs::write(&dest, doc.to_string()) + .with_context(|| format!("failed to write {}", dest.display()))?; + std::fs::remove_file(&src).with_context(|| format!("failed to remove {}", src.display()))?; + + info!("archived team {} -> {}", src.display(), dest.display()); + + remove_team_from_repos(data_dir, name)?; + + Ok(()) +} + +fn remove_team_from_repos(data_dir: &Path, team_name: &str) -> Result<(), Error> { + let repos_dir = data_dir.join("repos"); + if !repos_dir.is_dir() { + return Ok(()); + } + + for org_entry in std::fs::read_dir(&repos_dir) + .with_context(|| format!("failed to read {}", repos_dir.display()))? + { + let org_path = org_entry?.path(); + if !org_path.is_dir() || org_path.file_name() == Some(std::ffi::OsStr::new("archive")) { + continue; + } + + for repo_entry in std::fs::read_dir(&org_path) + .with_context(|| format!("failed to read {}", org_path.display()))? + { + let repo_path = repo_entry?.path(); + if !repo_path.is_file() || repo_path.extension() != Some(std::ffi::OsStr::new("toml")) { + continue; + } + + let content = std::fs::read_to_string(&repo_path) + .with_context(|| format!("failed to read {}", repo_path.display()))?; + let mut doc: toml_edit::DocumentMut = content + .parse() + .with_context(|| format!("failed to parse {}", repo_path.display()))?; + + let removed = if let Some(table) = get_access_teams(&mut doc) { + table.remove(team_name).is_some() + } else { + false + }; + + if removed { + std::fs::write(&repo_path, doc.to_string()) + .with_context(|| format!("failed to write {}", repo_path.display()))?; + info!("removed team '{}' from {}", team_name, repo_path.display()); + } + } + } + + Ok(()) +}