diff --git a/Cargo.lock b/Cargo.lock index 3cac4d377..acf61c712 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1854,6 +1854,7 @@ dependencies = [ "thiserror", "tokio", "toml", + "toml_edit", "walkdir", ] @@ -2507,6 +2508,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -3103,6 +3117,9 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" diff --git a/Cargo.toml b/Cargo.toml index 92582c0ea..d747c579e 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.25" [dev-dependencies] ansi_term = "0.12.1" diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 000000000..a78a7f63d --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,194 @@ +use anyhow::{Context, bail, format_err}; +use indexmap::IndexSet; +use log::info; +use std::path::Path; + +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_toml_file( + src: &Path, + dest_dir: &Path, + dest: &Path, + entity: &str, + transform: F, +) -> anyhow::Result<()> +where + F: FnOnce(&mut toml_edit::DocumentMut), +{ + if !src.is_file() { + bail!("{entity} file not found: {}", src.display()); + } + if dest.is_file() { + bail!("{entity} is already archived: {}", dest.display()); + } + + let mut doc = read_toml_mut(src)?; + + transform(&mut doc); + + std::fs::create_dir_all(dest_dir) + .with_context(|| format!("failed to create directory {dest_dir:?}"))?; + std::fs::write(dest, doc.to_string()).with_context(|| format!("failed to write {dest:?}"))?; + std::fs::remove_file(src).with_context(|| format!("failed to remove {src:?}"))?; + + info!("archived {entity} {src:?} -> {dest:?}"); + Ok(()) +} + +fn read_toml_mut(src: &Path) -> anyhow::Result { + let content = + std::fs::read_to_string(src).with_context(|| format!("failed to read {src:?}"))?; + let doc: toml_edit::DocumentMut = content + .parse() + .with_context(|| format!("failed to parse {src:?}"))?; + Ok(doc) +} + +/// Archive a repository by moving its TOML file to `repos/archive//` +/// and clearing every entry from the `[access.teams]` table. +pub fn archive_repo(data_dir: &Path, name: &str) -> anyhow::Result<()> { + let (org, repo_name) = name + .split_once('/') + .ok_or_else(|| format_err!("repository must be in 'org/name' format, got '{name}'"))?; + + let repos_dir = data_dir.join("repos"); + let src = repos_dir.join(org).join(format!("{repo_name}.toml")); + let dest_dir = repos_dir.join("archive").join(org); + let dest = dest_dir.join(format!("{repo_name}.toml")); + + archive_toml_file(&src, &dest_dir, &dest, "repo", |doc| { + if let Some(table) = get_access_teams(doc) { + table.clear(); + } + }) +} + +/// Gather every username from a team's `leads`, `members`, and `alumni` +/// arrays into a single deduplicated, order-preserving set. +/// +/// Handles both bare strings (`"alice"`) and inline tables (`{ github = "alice" }`), +/// skipping any entries that don't match either shape or that have an empty +/// `github` field. +fn collect_all_team_members(people_table: &toml_edit::Table) -> IndexSet { + let mut all = IndexSet::new(); + for key in ["leads", "members", "alumni"] { + let Some(arr) = people_table.get(key).and_then(|v| v.as_array()) else { + continue; + }; + 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() { + all.insert(username); + } + } + } + all +} + +/// Build a TOML array of usernames laid out one per line with ` ` indentation +/// and a trailing comma — matching the style used elsewhere in the team repo. +fn build_alumni_array(usernames: &IndexSet) -> toml_edit::Array { + let mut arr = toml_edit::Array::new(); + for person in usernames { + let mut val = toml_edit::Value::from(person.as_str()); + val.decor_mut().set_prefix("\n "); + arr.push_formatted(val); + } + arr.set_trailing("\n"); + arr.set_trailing_comma(true); + arr +} + +/// Move everyone listed in a team's `leads`, `members`, and existing `alumni` +/// into a single `alumni` array, leaving `leads` and `members` empty. +/// +/// No-op if the document has no `[people]` table. +fn move_team_members_to_alumni(doc: &mut toml_edit::DocumentMut) { + let Some(people_table) = doc.get_mut("people").and_then(|v| v.as_table_mut()) else { + return; + }; + + let all_alumni = collect_all_team_members(people_table); + + people_table.insert("leads", toml_edit::Array::new().into()); + people_table.insert("members", toml_edit::Array::new().into()); + people_table.insert("alumni", build_alumni_array(&all_alumni).into()); +} + +/// Archive a team by moving its TOML file to `teams/archive/`, collapsing +/// every `leads`/`members`/`alumni` entry into a single `alumni` array, +/// and removing the team from every repo's `[access.teams]` table. +pub fn archive_team(data_dir: &Path, name: &str) -> anyhow::Result<()> { + let teams_dir = data_dir.join("teams"); + let src = teams_dir.join(format!("{name}.toml")); + let dest_dir = teams_dir.join("archive"); + let dest = dest_dir.join(format!("{name}.toml")); + + archive_toml_file(&src, &dest_dir, &dest, "team", move_team_members_to_alumni)?; + remove_team_from_repos(data_dir, name)?; + Ok(()) +} + +fn remove_team_from_repos(data_dir: &Path, team_name: &str) -> anyhow::Result<()> { + let repos_dir = data_dir.join("repos"); + assert!(repos_dir.is_dir(), "`repos` directory does not exist"); + + for org_entry in + std::fs::read_dir(&repos_dir).with_context(|| format!("failed to read {repos_dir:?}"))? + { + let org_path = org_entry?.path(); + assert!( + org_path.is_dir(), + "unexpected non-directory entry: `repos/{org_path:?}`" + ); + if 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:?}"))? + { + let repo_path = repo_entry?.path(); + remove_team_from_repository(team_name, &repo_path)?; + } + } + + Ok(()) +} + +fn remove_team_from_repository(team_name: &str, repo_path: &Path) -> anyhow::Result<()> { + assert!( + repo_path.is_file(), + "unexpected non-file entry: `repos/{repo_path:?}`" + ); + assert!( + repo_path.extension() == Some(std::ffi::OsStr::new("toml")), + "unexpected non-TOML file: `repos/{repo_path:?}`" + ); + + let mut doc = read_toml_mut(repo_path)?; + + 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:?}"))?; + info!("removed team '{team_name}' from {repo_path:?}"); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 5dc9d51cb..14e10e494 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod data; #[macro_use] mod permissions; mod api; +mod archive; mod ci; mod schema; mod static_api; @@ -24,6 +25,7 @@ use api::zulip::ZulipApi; use data::Data; use schema::{Email, Team, TeamKind}; +use crate::archive::{archive_repo, archive_team}; use crate::ci::{check_codeowners, generate_codeowners_file}; use crate::schema::RepoPermission; use crate::sync::run_sync_team; @@ -112,6 +114,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), @@ -139,6 +144,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 @@ -569,6 +588,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)?,