Skip to content

Commit c012998

Browse files
committed
Add archive CLI command for teams and repos
1 parent 732d2c1 commit c012998

4 files changed

Lines changed: 239 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 17 additions & 0 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.25"
3132

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

src/archive.rs

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
use anyhow::{Context, bail, format_err};
2+
use indexmap::IndexSet;
3+
use log::info;
4+
use std::path::Path;
5+
6+
fn get_access_teams(doc: &mut toml_edit::DocumentMut) -> Option<&mut toml_edit::Table> {
7+
doc.get_mut("access")?.get_mut("teams")?.as_table_mut()
8+
}
9+
10+
fn archive_toml_file<F>(
11+
src: &Path,
12+
dest_dir: &Path,
13+
dest: &Path,
14+
entity: &str,
15+
transform: F,
16+
) -> anyhow::Result<()>
17+
where
18+
F: FnOnce(&mut toml_edit::DocumentMut),
19+
{
20+
if !src.is_file() {
21+
bail!("{entity} file not found: {}", src.display());
22+
}
23+
if dest.is_file() {
24+
bail!("{entity} is already archived: {}", dest.display());
25+
}
26+
27+
let mut doc = read_toml_mut(src)?;
28+
29+
transform(&mut doc);
30+
31+
std::fs::create_dir_all(dest_dir)
32+
.with_context(|| format!("failed to create directory {dest_dir:?}"))?;
33+
std::fs::write(dest, doc.to_string()).with_context(|| format!("failed to write {dest:?}"))?;
34+
std::fs::remove_file(src).with_context(|| format!("failed to remove {src:?}"))?;
35+
36+
info!("archived {entity} {src:?} -> {dest:?}");
37+
Ok(())
38+
}
39+
40+
fn read_toml_mut(src: &Path) -> anyhow::Result<toml_edit::DocumentMut> {
41+
let content =
42+
std::fs::read_to_string(src).with_context(|| format!("failed to read {src:?}"))?;
43+
let doc: toml_edit::DocumentMut = content
44+
.parse()
45+
.with_context(|| format!("failed to parse {src:?}"))?;
46+
Ok(doc)
47+
}
48+
49+
/// Archive a repository by moving its TOML file to `repos/archive/<org>/`
50+
/// and clearing every entry from the `[access.teams]` table.
51+
pub fn archive_repo(data_dir: &Path, name: &str) -> anyhow::Result<()> {
52+
let (org, repo_name) = name
53+
.split_once('/')
54+
.ok_or_else(|| format_err!("repository must be in 'org/name' format, got '{name}'"))?;
55+
56+
let repos_dir = data_dir.join("repos");
57+
let src = repos_dir.join(org).join(format!("{repo_name}.toml"));
58+
let dest_dir = repos_dir.join("archive").join(org);
59+
let dest = dest_dir.join(format!("{repo_name}.toml"));
60+
61+
archive_toml_file(&src, &dest_dir, &dest, "repo", |doc| {
62+
if let Some(table) = get_access_teams(doc) {
63+
table.clear();
64+
}
65+
})
66+
}
67+
68+
/// Gather every username from a team's `leads`, `members`, and `alumni`
69+
/// arrays into a single deduplicated, order-preserving set.
70+
///
71+
/// Handles both bare strings (`"alice"`) and inline tables (`{ github = "alice" }`),
72+
/// skipping any entries that don't match either shape or that have an empty
73+
/// `github` field.
74+
fn collect_all_team_members(people_table: &toml_edit::Table) -> IndexSet<String> {
75+
let mut all = IndexSet::new();
76+
for key in ["leads", "members", "alumni"] {
77+
let Some(arr) = people_table.get(key).and_then(|v| v.as_array()) else {
78+
continue;
79+
};
80+
for item in arr.iter() {
81+
let username = if let Some(s) = item.as_str() {
82+
s.to_string()
83+
} else if let Some(tbl) = item.as_inline_table() {
84+
match tbl.get("github").and_then(|v| v.as_str()) {
85+
Some(s) => s.to_string(),
86+
None => continue,
87+
}
88+
} else {
89+
continue;
90+
};
91+
if !username.is_empty() {
92+
all.insert(username);
93+
}
94+
}
95+
}
96+
all
97+
}
98+
99+
/// Build a TOML array of usernames laid out one per line with ` ` indentation
100+
/// and a trailing comma — matching the style used elsewhere in the team repo.
101+
fn build_alumni_array(usernames: &IndexSet<String>) -> toml_edit::Array {
102+
let mut arr = toml_edit::Array::new();
103+
for person in usernames {
104+
let mut val = toml_edit::Value::from(person.as_str());
105+
val.decor_mut().set_prefix("\n ");
106+
arr.push_formatted(val);
107+
}
108+
arr.set_trailing("\n");
109+
arr.set_trailing_comma(true);
110+
arr
111+
}
112+
113+
/// Move everyone listed in a team's `leads`, `members`, and existing `alumni`
114+
/// into a single `alumni` array, leaving `leads` and `members` empty.
115+
///
116+
/// No-op if the document has no `[people]` table.
117+
fn move_team_members_to_alumni(doc: &mut toml_edit::DocumentMut) {
118+
let Some(people_table) = doc.get_mut("people").and_then(|v| v.as_table_mut()) else {
119+
return;
120+
};
121+
122+
let all_alumni = collect_all_team_members(people_table);
123+
124+
people_table.insert("leads", toml_edit::Array::new().into());
125+
people_table.insert("members", toml_edit::Array::new().into());
126+
people_table.insert("alumni", build_alumni_array(&all_alumni).into());
127+
}
128+
129+
/// Archive a team by moving its TOML file to `teams/archive/`, collapsing
130+
/// every `leads`/`members`/`alumni` entry into a single `alumni` array,
131+
/// and removing the team from every repo's `[access.teams]` table.
132+
pub fn archive_team(data_dir: &Path, name: &str) -> anyhow::Result<()> {
133+
let teams_dir = data_dir.join("teams");
134+
let src = teams_dir.join(format!("{name}.toml"));
135+
let dest_dir = teams_dir.join("archive");
136+
let dest = dest_dir.join(format!("{name}.toml"));
137+
138+
archive_toml_file(&src, &dest_dir, &dest, "team", move_team_members_to_alumni)?;
139+
remove_team_from_repos(data_dir, name)?;
140+
Ok(())
141+
}
142+
143+
fn remove_team_from_repos(data_dir: &Path, team_name: &str) -> anyhow::Result<()> {
144+
let repos_dir = data_dir.join("repos");
145+
assert!(repos_dir.is_dir(), "`repos` directory does not exist");
146+
147+
for org_entry in
148+
std::fs::read_dir(&repos_dir).with_context(|| format!("failed to read {repos_dir:?}"))?
149+
{
150+
let org_path = org_entry?.path();
151+
assert!(
152+
org_path.is_dir(),
153+
"unexpected non-directory entry: `repos/{org_path:?}`"
154+
);
155+
if org_path.file_name() == Some(std::ffi::OsStr::new("archive")) {
156+
continue;
157+
}
158+
159+
for repo_entry in
160+
std::fs::read_dir(&org_path).with_context(|| format!("failed to read {org_path:?}"))?
161+
{
162+
let repo_path = repo_entry?.path();
163+
remove_team_from_repository(team_name, &repo_path)?;
164+
}
165+
}
166+
167+
Ok(())
168+
}
169+
170+
fn remove_team_from_repository(team_name: &str, repo_path: &Path) -> anyhow::Result<()> {
171+
assert!(
172+
repo_path.is_file(),
173+
"unexpected non-file entry: `repos/{repo_path:?}`"
174+
);
175+
assert!(
176+
repo_path.extension() == Some(std::ffi::OsStr::new("toml")),
177+
"unexpected non-TOML file: `repos/{repo_path:?}`"
178+
);
179+
180+
let mut doc = read_toml_mut(repo_path)?;
181+
182+
let removed = if let Some(table) = get_access_teams(&mut doc) {
183+
table.remove(team_name).is_some()
184+
} else {
185+
false
186+
};
187+
188+
if removed {
189+
std::fs::write(repo_path, doc.to_string())
190+
.with_context(|| format!("failed to write {repo_path:?}"))?;
191+
info!("removed team '{team_name}' from {repo_path:?}");
192+
}
193+
Ok(())
194+
}

src/main.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod data;
44
#[macro_use]
55
mod permissions;
66
mod api;
7+
mod archive;
78
mod ci;
89
mod schema;
910
mod static_api;
@@ -24,6 +25,7 @@ use api::zulip::ZulipApi;
2425
use data::Data;
2526
use schema::{Email, Team, TeamKind};
2627

28+
use crate::archive::{archive_repo, archive_team};
2729
use crate::ci::{check_codeowners, generate_codeowners_file};
2830
use crate::schema::RepoPermission;
2931
use crate::sync::run_sync_team;
@@ -112,6 +114,9 @@ enum RootOpts {
112114
DecryptEmail,
113115
/// Generate a x25519 key for use with the email encryption module
114116
GenerateKey,
117+
/// Archive a repo or team, moving it to the archive directory
118+
#[clap(subcommand)]
119+
Archive(ArchiveOpts),
115120
/// CI scripts
116121
#[clap(subcommand)]
117122
Ci(CiOpts),
@@ -139,6 +144,20 @@ enum CiOpts {
139144
CheckUntrackedRepos,
140145
}
141146

147+
#[derive(clap::Parser, Clone, Debug)]
148+
enum ArchiveOpts {
149+
/// Archive a repository
150+
Repo {
151+
/// Repository in "org/name" format (e.g. "rust-lang/homu")
152+
name: String,
153+
},
154+
/// Archive a team
155+
Team {
156+
/// Team name (e.g. "project-generic-associated-types")
157+
name: String,
158+
},
159+
}
160+
142161
#[derive(clap::Parser, Clone, Debug)]
143162
struct SyncOpts {
144163
/// Comma-separated list of available services
@@ -569,6 +588,14 @@ async fn run() -> Result<(), Error> {
569588
let (secret, public) = rust_team_data::email_encryption::generate_x25519_keypair();
570589
println!("Generated keypair: secret: {} - public: {}", secret, public);
571590
}
591+
RootOpts::Archive(opts) => match opts {
592+
ArchiveOpts::Repo { ref name } => {
593+
archive_repo(&cli.data_dir, name)?;
594+
}
595+
ArchiveOpts::Team { ref name } => {
596+
archive_team(&cli.data_dir, name)?;
597+
}
598+
},
572599
RootOpts::Ci(opts) => match opts {
573600
CiOpts::GenerateCodeowners => generate_codeowners_file(data)?,
574601
CiOpts::CheckCodeowners => check_codeowners(data)?,

0 commit comments

Comments
 (0)