Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
194 changes: 194 additions & 0 deletions src/archive.rs
Original file line number Diff line number Diff line change
@@ -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<F>(
src: &Path,
dest_dir: &Path,
dest: &Path,
entity: &str,
transform: F,
) -> anyhow::Result<()>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like importing directly anyhow::Error. I like Error to always be the one from std.
But this is a matter of taste.

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)?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created this function to extract a common behavior


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<toml_edit::DocumentMut> {
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/<org>/`
/// 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<String> {
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<String>) -> 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");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here you were failing silently (return Ok).
Instead assert is better:

  • while reading the code you understand that it is not part of the logic so your brain can skip it if you just want to understand the code
  • if repos is not a directory at this point it's better to fail because something is going wrong


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)?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I extracted this function so that remove_team_from_repos is shorter

}
}

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(())
}
27 changes: 27 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod data;
#[macro_use]
mod permissions;
mod api;
mod archive;
mod ci;
mod schema;
mod static_api;
Expand All @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)?,
Expand Down