diff --git a/src/api/github.rs b/src/api/github.rs index 06d49209f..33f95c06e 100644 --- a/src/api/github.rs +++ b/src/api/github.rs @@ -1,10 +1,11 @@ use crate::sync::utils::ResponseExt; -use anyhow::{Error, bail}; +use anyhow::{Context, Error, bail}; use base64::Engine; use base64::prelude::BASE64_STANDARD; -use reqwest::Method; +use chrono::{DateTime, Duration, Utc}; use reqwest::header::{self, HeaderValue}; use reqwest::{Client, ClientBuilder, RequestBuilder}; +use reqwest::{Method, StatusCode}; use std::borrow::Cow; use std::collections::HashMap; @@ -124,12 +125,41 @@ impl GitHubApi { where T: serde::de::DeserializeOwned, { - self.prepare(false, Method::GET, url)? - .send() - .await? - .error_for_status()? - .json_annotated() - .await + loop { + let response = self.prepare(false, Method::GET, url)?.send().await?; + + let status = response.status(); + if status != StatusCode::OK { + let headers = response.headers(); + + // Rate limited + if status == StatusCode::FORBIDDEN + && headers + .get("x-ratelimit-remaining") + .and_then(|v| v.to_str().ok()) + == Some("0") + { + let reset_at = headers + .get("x-ratelimit-reset") + .and_then(|v| v.to_str().ok()) + .and_then(|t| t.parse::().ok()) + .and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp as i64, 0)) + .map(|d| d + chrono::Duration::seconds(1)) + .unwrap_or(Utc::now() + chrono::Duration::minutes(1)); + eprintln!("Rate limited. Waiting until {reset_at}"); + let duration = reset_at + .signed_duration_since(Utc::now()) + .max(Duration::zero()); + tokio::time::sleep(duration.to_std().unwrap()).await; + continue; + } + + let text = response.text().await?; + return Err(anyhow::anyhow!("Request failed with {status}: {text}")); + } else { + return response.json_annotated().await; + } + } } pub(crate) async fn usernames(&self, ids: &[u64]) -> Result, Error> { @@ -209,8 +239,183 @@ impl GitHubApi { } Ok(result) } + + pub(crate) async fn recent_user_comments_in_org( + &self, + username: &str, + org: &str, + limit: usize, + ) -> anyhow::Result> { + // GitHub's GraphQL API doesn't seem to support filtering comments by author directly. + // We can use two endpoints here - either use the search query and filter by commented and + // organization, or use the user query and access its issueComments connection. + // The user endpoint would be more efficient, in theory. However, if the user makes a lot of + // comments in different organizations, we might load a lot of data before we get to their + // comments in the given organization. So instead we use the search endpoint. + + // The endpoint loads issues (and PRs), not comments. + // The `commenter:` filter guarantees each returned issue has at least one comment + // from the user. So we fetch `limit` issues and a small number of recent comments + // per issue, then filter to only the user's comments. + let search_query = format!("commenter:{username} org:{org} sort:updated-desc"); + let issues_to_fetch = limit; + let comments_per_issue = 100; + + let data = self + .graphql::( + r#" +query($query: String!, $issueLimit: Int!, $commentLimit: Int!) { + search(query: $query, type: ISSUE, first: $issueLimit) { + nodes { + ... on Issue { + number + url + title + repository { + name + owner { login } + } + comments(first: $commentLimit, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + author { login } + body + url + createdAt + } + } + } + ... on PullRequest { + number + url + title + repository { + name + owner { login } + } + comments(first: $commentLimit, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + author { login } + body + url + createdAt + } + } + } + } + } +} + "#, + serde_json::json!({ + "query": search_query, + "issueLimit": issues_to_fetch, + "commentLimit": comments_per_issue, + }), + ) + .await + .context("failed to search for user comments")?; + + let mut all_comments: Vec = Vec::new(); + + if let Some(nodes) = data["search"]["nodes"].as_array() { + for node in nodes { + let repo_owner = node["repository"]["owner"]["login"] + .as_str() + .unwrap_or("") + .to_string(); + let repo_name = node["repository"]["name"] + .as_str() + .unwrap_or("") + .to_string(); + let issue_number = node["number"].as_u64().unwrap_or(0); + let issue_title = node["title"].as_str().unwrap_or("Unknown"); + let issue_url = node["url"].as_str().unwrap_or(""); + + if let Some(comments) = node["comments"]["nodes"].as_array() { + for comment in comments { + // Filter to only comments by the target user + let author = comment["author"]["login"].as_str().unwrap_or(""); + if !author.eq_ignore_ascii_case(username) { + continue; + } + + let body = comment["body"].as_str().unwrap_or(""); + let url = comment["url"].as_str().unwrap_or(""); + let created_at = comment["createdAt"] + .as_str() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + + all_comments.push(UserComment { + repo_owner: repo_owner.clone(), + repo_name: repo_name.clone(), + issue_number, + issue_title: issue_title.to_string(), + issue_url: issue_url.to_string(), + comment_url: url.to_string(), + body: body.to_string(), + created_at, + }); + } + } + } + } + + // Sort by creation date (most recent first) and take the limit + all_comments.sort_by_key(|b| std::cmp::Reverse(b.created_at)); + all_comments.truncate(limit); + + Ok(all_comments) + } + + pub(crate) async fn recent_user_commits_in_org( + &self, + username: &str, + org: &str, + limit: usize, + ) -> anyhow::Result> { + #[derive(serde::Deserialize, Debug)] + struct Response { + items: Vec, + } + + let response: Response = self.get(&format!("search/commits?q=author:{username}+org:{org}&sort=author-date&order=desc&per_page={limit}")).await?; + Ok(response + .items + .into_iter() + .filter_map(|c| { + Some(CommitInfo { + repo_owner: c.repository.owner.login, + repo_name: c.repository.name, + created_at: chrono::DateTime::parse_from_rfc3339(&c.commit.committer?.date?) + .ok()? + .with_timezone(&Utc), + }) + }) + .collect()) + } } fn user_node_id(id: u64) -> String { BASE64_STANDARD.encode(format!("04:User{id}")) } + +/// A comment made by a user on an issue or PR. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UserComment { + pub repo_owner: String, + pub repo_name: String, + pub issue_number: u64, + pub issue_title: String, + pub issue_url: String, + pub comment_url: String, + pub body: String, + pub created_at: Option>, +} + +/// A commit made by a user on a given repository. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CommitInfo { + pub repo_owner: String, + pub repo_name: String, + pub created_at: DateTime, +} diff --git a/src/api/zulip.rs b/src/api/zulip.rs index 5d89a896c..010ce9518 100644 --- a/src/api/zulip.rs +++ b/src/api/zulip.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; +use crate::sync::utils::ResponseExt; use anyhow::{Error, bail}; -use reqwest::Method; +use chrono::{DateTime, Utc}; use reqwest::{Client, ClientBuilder, Response}; +use reqwest::{Method, StatusCode}; use serde::Deserialize; const ZULIP_BASE_URL: &str = "https://rust-lang.zulipchat.com/api/v1"; @@ -75,6 +77,62 @@ impl ZulipApi { Ok(response) } + pub async fn get_last_n_messages_sent_by_user( + &self, + user: u64, + n: u64, + ) -> anyhow::Result> { + let query = serde_json::json!([{ + "operator": "sender", + "operand": user + }]) + .to_string(); + + #[derive(serde::Deserialize)] + struct Message { + subject: String, + timestamp: u64, + } + + #[derive(serde::Deserialize)] + struct Response { + messages: Vec, + } + + let response = self + .req( + Method::GET, + &format!("/messages?anchor=newest&num_before={n}&num_after=0&narrow={query}"), + None, + ) + .await?; + let status = response.status(); + if status == StatusCode::OK { + let response: Response = response.json_annotated().await?; + Ok(response + .messages + .into_iter() + .rev() + .map(|msg| MessageInfo { + subject: msg.subject, + timestamp: DateTime::from_timestamp(msg.timestamp as i64, 0) + .unwrap_or(Utc::now()), + }) + .collect()) + } else { + let text = response.text().await?; + // User might not exist + if status == StatusCode::BAD_REQUEST && text.contains("unknown user") { + eprintln!("Cannot get Zulip messages for user {user}, status {status}: {text}"); + Ok(vec![]) + } else { + Err(anyhow::anyhow!( + "Cannot get Zulip messages for user {user}, status {status}: {text}" + )) + } + } + } + /// Perform a request against the Zulip API async fn req( &self, @@ -130,3 +188,9 @@ impl ZulipUser { self.profile_data.get("3873").map(|v| v.value.as_str()) } } + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct MessageInfo { + pub subject: String, + pub timestamp: chrono::DateTime, +} diff --git a/src/find_inactive_members.rs b/src/find_inactive_members.rs new file mode 100644 index 000000000..ef6fc99b0 --- /dev/null +++ b/src/find_inactive_members.rs @@ -0,0 +1,328 @@ +//! This binary serves as a utility to find members of Rust teams that haven't been active +//! on Zulip or GitHub for a long time. +//! +//! It should help members of the Leadership Council with their duty of periodically removing +//! inactive users (https://github.com/rust-lang/leadership-council/blob/main/policies/membership/auto-alumni.md). +use crate::api::github::{CommitInfo, GitHubApi, UserComment}; +use crate::api::zulip::{MessageInfo, ZulipApi}; +use crate::sync::team_api::TeamApi; +use chrono::Utc; +use futures_util::StreamExt; +use rust_team_data::v1; +use rust_team_data::v1::TeamKind; +use std::cmp::Reverse; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +pub struct InactiveTeamFilter { + /// Only search within teams that contain this string in their name. + pub name: Option, + /// Include all teams, including working groups, project groups and marker teams. + pub include_all_teams: bool, +} + +pub async fn find_inactive_members( + filter: InactiveTeamFilter, + cutoff_days: u64, +) -> anyhow::Result<()> { + let team_api = TeamApi::Production; + let teams = team_api.get_teams().await?; + + let mut users = find_team_members(&team_api, &teams, filter).await?; + users.sort_by_key(|u| u.username.clone()); + println!("Found {} team members", users.len()); + + let zulip_api = ZulipApi::new(); + let zulip_api = &zulip_api; + + let gh_api = GitHubApi::new(); + let gh_api = &gh_api; + + let cache = UserCache::new(Path::new(".user-cache")); + let cache = &cache; + + let user_count = users.len(); + let mut stream = futures_util::stream::iter(users.into_iter().map(|user| async move { + if let Ok(info) = cache.load(&user.username) { + return (user, info); + } + + let last_github_comments = gh_api + .recent_user_comments_in_org(&user.username, "rust-lang", 3) + .await + .expect("Cannot fetch GitHub comment activity"); + + // We search for commits, because finding issues/PRs is more expensive in terms of rate + // limits + let last_github_commits = match gh_api + .recent_user_commits_in_org(&user.username, "rust-lang", 3) + .await + { + Ok(c) => c, + Err(error) => { + eprintln!("Cannot load commits for {}: {error:?}", user.username); + vec![] + } + }; + + let last_zulip_messages = if let Some(zulip_id) = user.zulip_id { + zulip_api + .get_last_n_messages_sent_by_user(zulip_id, 3) + .await + .expect("Cannot fetch Zulip messages") + } else { + vec![] + }; + + // To give more leeways for rate limits + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let info = UserInfo { + last_zulip_messages, + last_github_comments, + last_github_commits, + }; + cache + .store(&user.username, info.clone()) + .expect("Cannot write cache entry"); + + (user, info) + })) + .buffer_unordered(5); + + let mut users = Vec::new(); + while let Some((user, info)) = stream.next().await { + users.push((user, info)); + if users.len() % 10 == 0 { + eprintln!( + "{}/{user_count} ({:.0}%)", + users.len(), + (users.len() as f64 / user_count as f64) * 100.0 + ); + } + } + eprintln!("Download finished\n"); + print_results(&teams, users, cutoff_days).await?; + + Ok(()) +} + +async fn find_team_members( + team_api: &TeamApi, + teams: &[v1::Team], + filter: InactiveTeamFilter, +) -> anyhow::Result> { + let team_teams = teams + .iter() + .filter(|team| match team.kind { + TeamKind::Team => true, + TeamKind::WorkingGroup | TeamKind::ProjectGroup | TeamKind::MarkerTeam => { + filter.include_all_teams + } + TeamKind::Unknown => false, + }) + .filter(|team| { + if let Some(name) = &filter.name { + team.name.contains(name) + } else { + true + } + }) + .filter(|team| team.name != "alumni" && team.name != "all" && team.name != "leads") + .collect::>(); + + let mut team_members = HashSet::new(); + for team in team_teams { + team_members.extend(team.members.iter().map(|m| (m.github.clone(), m.github_id))); + } + + let zulip_map = team_api.get_zulip_map().await?; + let gh_id_to_zulip_id: HashMap = + zulip_map.users.into_iter().map(|(k, v)| (v, k)).collect(); + + let mut users = Vec::new(); + for (username, github_id) in team_members { + let zulip_id = gh_id_to_zulip_id.get(&github_id).copied(); + let user = User { + username, + github_id, + zulip_id, + }; + users.push(user); + } + Ok(users) +} + +async fn print_results( + teams: &[v1::Team], + mut users: Vec<(User, UserInfo)>, + cutoff_days: u64, +) -> anyhow::Result<()> { + const NEVER: u64 = 99999; + + // Keep only users who didn't have any Zulip public message or GitHub contribution in the past + // `cutoff_days`. + users.retain(|(_, info)| { + info.zulip_age_days().unwrap_or(NEVER) > cutoff_days + && info.github_comment_age_days().unwrap_or(NEVER) > cutoff_days + && info.github_commit_age_days().unwrap_or(NEVER) > cutoff_days + }); + eprintln!("Inactive users: {}", users.len()); + + users.sort_by_key(|(_, info)| { + // Sort by the largest minimum of these durations + Reverse( + info.zulip_age_days() + .unwrap_or(NEVER) + .min(info.github_comment_age_days().unwrap_or(NEVER)) + .min(info.github_commit_age_days().unwrap_or(NEVER)), + ) + }); + + let mut person_to_teams: HashMap> = HashMap::new(); + for team in teams { + if team.name == "all" || team.name == "leads" { + continue; + } + for member in &team.members { + person_to_teams + .entry(member.github.clone()) + .or_default() + .push(team.name.clone()); + } + } + + let now = Utc::now(); + for (user, info) in users { + let comments = if info.last_github_comments.is_empty() { + "never".to_string() + } else { + format!( + "{} days ago", + info.last_github_comments + .iter() + .filter_map(|c| c.created_at) + .map(|d| now.signed_duration_since(d).num_days().to_string()) + .collect::>() + .join(", ") + ) + }; + let commits = if info.last_github_commits.is_empty() { + "never".to_string() + } else { + format!( + "{} days ago", + info.last_github_commits + .iter() + .map(|c| c.created_at) + .map(|d| now.signed_duration_since(d).num_days().to_string()) + .collect::>() + .join(", ") + ) + }; + println!( + r#"**{}** + - Zulip: {}{} + - GitHub comments (rust-lang): {comments} + - GitHub commits (rust-lang): {commits} + - Teams: {} +"#, + user.username, + info.zulip_age_days() + .map(|s| format!("{s} days ago")) + .unwrap_or_else(|| "never".to_string()), + if user.zulip_id.is_none() { + " (no Zulip account)" + } else { + "" + }, + person_to_teams + .get(&user.username) + .cloned() + .unwrap_or_default() + .join(", ") + ); + } + Ok(()) +} + +struct UserCache { + directory: PathBuf, +} + +impl UserCache { + fn new(path: &Path) -> Self { + std::fs::create_dir_all(path).unwrap(); + Self { + directory: path.to_path_buf(), + } + } + + fn load(&self, username: &str) -> anyhow::Result { + let data = std::fs::read(self.path(username))?; + let entry: CacheEntry = serde_json::from_slice(&data)?; + + // If the cache is too old, do not use it + if Utc::now().signed_duration_since(entry.timestamp) > chrono::Duration::days(30) { + Err(anyhow::anyhow!("Cache entry for {username} is too old")) + } else { + Ok(entry.info) + } + } + + fn store(&self, username: &str, info: UserInfo) -> anyhow::Result<()> { + let data = serde_json::to_string(&CacheEntry { + timestamp: Utc::now(), + info, + })?; + std::fs::write(self.path(username), data)?; + Ok(()) + } + + fn path(&self, username: &str) -> PathBuf { + self.directory.join(format!("{username}.json")) + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct CacheEntry { + timestamp: chrono::DateTime, + info: UserInfo, +} + +#[derive(PartialEq, Eq, Hash, Debug)] +struct User { + username: String, + github_id: u64, + zulip_id: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +struct UserInfo { + last_zulip_messages: Vec, + last_github_comments: Vec, + last_github_commits: Vec, +} + +impl UserInfo { + fn zulip_age_days(&self) -> Option { + self.last_zulip_messages + .first() + .map(|msg| Utc::now().signed_duration_since(msg.timestamp).num_days() as u64) + } + + fn github_comment_age_days(&self) -> Option { + self.last_github_comments + .iter() + .filter_map(|comment| comment.created_at) + .next() + .map(|date| Utc::now().signed_duration_since(date).num_days() as u64) + } + + fn github_commit_age_days(&self) -> Option { + self.last_github_commits + .iter() + .map(|commit| commit.created_at) + .next() + .map(|date| Utc::now().signed_duration_since(date).num_days() as u64) + } +} diff --git a/src/main.rs b/src/main.rs index 14e10e494..b5e07a11e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,12 @@ mod data; #[macro_use] mod permissions; mod api; -mod archive; mod ci; +mod find_inactive_members; mod schema; mod static_api; mod sync; +mod toml_manipulation; mod validate; const AVAILABLE_SERVICES: &[&str] = &[ @@ -25,17 +26,20 @@ 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::find_inactive_members::{InactiveTeamFilter, find_inactive_members}; use crate::schema::RepoPermission; use crate::sync::run_sync_team; use crate::sync::team_api::TeamApi; +use crate::toml_manipulation::{archive_repo, archive_team, move_person_to_alumni}; use anyhow::{Context, Error, bail, format_err}; use api::github; +use chrono::Utc; use clap::Parser; use log::{error, info, warn}; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; +use std::process::Command; use std::str::FromStr; #[derive(clap::ValueEnum, Clone, Debug)] @@ -44,6 +48,15 @@ enum DumpIndividualAccessGroupBy { Repo, } +#[derive(clap::ValueEnum, Clone, Debug)] +enum CreateAlumniPr { + /// Create the alumni pull request on your behalf. + Myself, + /// Create the alumni pull request of behalf of someone else, who you are moving to alumni + /// status. + External, +} + #[derive(clap::Parser, Debug)] /// Manage the Rust team members struct Cli { @@ -108,6 +121,38 @@ enum RootOpts { }, /// Dump all repositories with their environments DumpEnvironments, + /// Find inactive Project members + FindInactiveMembers { + /// Only look for teams whose name contains this string. + #[arg(long)] + team_filter: Option, + /// Include also working groups, project groups and marker teams in the search. + #[arg(long)] + include_all_teams: bool, + /// Minimum number of days for which a user would have to not send any public + /// Zulip message or not send any GitHub comment in `rust-lang` for them to be considered + /// inactive. + #[arg(long, default_value = "30")] + cutoff_days: u64, + }, + /// Move a person to alumni status + MoveToAlumni { + /// GitHub username of the person. + username: String, + /// Subset of teams in which the person should be moved to alumni. + /// If not specified, then the person will be moved to alumni in all their teams. + #[arg(long, value_delimiter = ',')] + teams: Vec, + /// Create a git commit after moving the person to alumni and then + /// create a pull request using the `gh` CLI tool. + /// + /// You will be redirected to the GitHub web interface to confirm the PR. + /// + /// Specify either `myself` or `external`, based on whether you are moving yourself to + /// alumni, or if you are moving someone else to alumni. + #[arg(long)] + create_pr: Option, + }, /// Encrypt an email address EncryptEmail, /// Decrypt an email address @@ -566,6 +611,114 @@ async fn run() -> Result<(), Error> { } } } + RootOpts::FindInactiveMembers { + team_filter, + include_all_teams, + cutoff_days, + } => { + find_inactive_members( + InactiveTeamFilter { + name: team_filter, + include_all_teams, + }, + cutoff_days, + ) + .await? + } + RootOpts::MoveToAlumni { + username, + teams, + create_pr, + } => { + let specific_teams = !teams.is_empty(); + let removed_teams = move_person_to_alumni(&data, &cli.data_dir, &username, teams)?; + if !removed_teams.is_empty() + && let Some(create_pr) = create_pr + { + let removed_teams_str = { + let mut teams = removed_teams.iter().map(|t| t.name()).collect::>(); + teams.sort(); + teams.join(", ") + }; + + let title_suffix = if specific_teams { + format!(" in {removed_teams_str}") + } else { + String::new() + }; + let title = format!("Move `{username}` to alumni{title_suffix}"); + + let date = Utc::now() + chrono::Duration::days(10); + let body = match create_pr { + CreateAlumniPr::Myself => { + format!( + r#"Move myself to alumni{title_suffix}. + +@rustbot label +alumni +"# + ) + } + CreateAlumniPr::External => format!( + r#"{title}, as they have been inactive on GitHub and Zulip for some time. This is in accordance with https://github.com/rust-lang/leadership-council/blob/main/policies/membership/auto-alumni.md. + +`{username}` will be moved to alumni in the following team(s): +{} + +This pull request should be left open at least for 10 days (until `{}`) to allow the contributor to respond. + +CC @{username} + +If you want to keep being a member of Rust teams, please let us know! + +@rustbot label +alumni +"#, + removed_teams + .iter() + .map(|team| { + let mut leads = team + .leads() + .iter() + .map(|username| format!("@{username}")) + .collect::>(); + leads.sort(); + let leads_cc = if !leads.is_empty() { + format!(" (CC {})", leads.join(" ")) + } else { + String::new() + }; + + format!("- {}{}", team.name(), leads_cc) + }) + .collect::>() + .join("\n"), + date.format("%d.%m.%Y") + ), + }; + Command::new("git") + .arg("add") + .arg("teams") + .spawn()? + .wait()?; + Command::new("git") + .arg("commit") + .arg("-m") + .arg(&title) + .spawn()? + .wait()?; + + let mut cmd = Command::new("gh"); + cmd.arg("pr") + .arg("create") + .arg("--body") + .arg(body) + .arg("--title") + .arg(&title) + .arg("--web") + .arg("--repo") + .arg("rust-lang/team"); + cmd.spawn()?.wait()?; + } + } RootOpts::EncryptEmail => { let plain: String = dialoguer::Input::new() .with_prompt("Plaintext address") diff --git a/src/sync/team_api.rs b/src/sync/team_api.rs index 04a10e3d2..7c8d70929 100644 --- a/src/sync/team_api.rs +++ b/src/sync/team_api.rs @@ -40,11 +40,17 @@ impl TeamApi { } pub(crate) async fn get_zulip_groups(&self) -> anyhow::Result { - debug!("loading GitHub id to Zulip id map from the Team API"); + debug!("loading Zulip groups from the Team API"); self.req::("zulip-groups.json") .await } + pub(crate) async fn get_zulip_map(&self) -> anyhow::Result { + debug!("loading GitHub id to Zulip id map from the Team API"); + self.req::("zulip-map.json") + .await + } + pub(crate) async fn get_zulip_streams( &self, ) -> anyhow::Result { diff --git a/src/archive.rs b/src/toml_manipulation.rs similarity index 65% rename from src/archive.rs rename to src/toml_manipulation.rs index a78a7f63d..8cdbb3537 100644 --- a/src/archive.rs +++ b/src/toml_manipulation.rs @@ -1,7 +1,10 @@ +use crate::data::Data; +use crate::schema::Team; use anyhow::{Context, bail, format_err}; use indexmap::IndexSet; use log::info; use std::path::Path; +use toml_edit::{Array, Item, Value}; fn get_access_teams(doc: &mut toml_edit::DocumentMut) -> Option<&mut toml_edit::Table> { doc.get_mut("access")?.get_mut("teams")?.as_table_mut() @@ -71,8 +74,9 @@ pub fn archive_repo(data_dir: &Path, name: &str) -> anyhow::Result<()> { /// 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 { +fn collect_all_team_members(people_table: &toml_edit::Table) -> Vec { let mut all = IndexSet::new(); + let mut values = Vec::new(); for key in ["leads", "members", "alumni"] { let Some(arr) = people_table.get(key).and_then(|v| v.as_array()) else { continue; @@ -88,22 +92,23 @@ fn collect_all_team_members(people_table: &toml_edit::Table) -> IndexSet } else { continue; }; - if !username.is_empty() { - all.insert(username); + if !username.is_empty() && all.insert(username) { + values.push(item.clone()); } } } - all + values } /// 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 { +fn build_alumni_array(values: &[toml_edit::Value]) -> 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); + for value in values { + let mut value = value.clone(); + value.decor_mut().set_prefix("\n "); + value.decor_mut().set_suffix(""); + arr.push_formatted(value); } arr.set_trailing("\n"); arr.set_trailing_comma(true); @@ -192,3 +197,95 @@ fn remove_team_from_repository(team_name: &str, repo_path: &Path) -> anyhow::Res } Ok(()) } + +pub fn move_person_to_alumni<'a>( + data: &'a Data, + data_dir: &Path, + username: &str, + team_filter: Vec, +) -> anyhow::Result> { + let username = username.to_lowercase(); + + let mut teams = data.teams().collect::>(); + if !team_filter.is_empty() { + teams.retain(|t| team_filter.iter().any(|f| f == t.name())); + } + + teams.retain(|t| { + t.members(data) + .unwrap() + .iter() + .any(|m| m.to_lowercase() == username.to_lowercase()) + && t.name() != "all" + && t.name() != "leads" + }); + teams.sort_by_key(|t| t.name()); + println!( + "User {username} found in {} team(s): {}", + teams.len(), + teams + .iter() + .map(|t| t.name()) + .collect::>() + .join(", ") + ); + + fn find_index(array: &Array, username: &str) -> Option { + array + .iter() + .enumerate() + .filter_map(|(index, entry)| { + if let Some(name) = entry.as_str() + && name.to_lowercase() == username + { + Some(index) + } else if let Some(table) = entry.as_inline_table() + && let Some(name) = table.get("github") + && let Some(name) = name.as_str() + && name.to_lowercase() == username + { + Some(index) + } else { + None + } + }) + .next() + } + + for team in teams.iter() { + let path = data_dir.join("teams").join(format!("{}.toml", team.name())); + if !path.is_file() { + return Err(anyhow::anyhow!("Cannot find {path:?}")); + } + let mut document = read_toml_mut(&path)?; + let Some(people) = document.get_mut("people").and_then(|t| t.as_table_mut()) else { + continue; + }; + + if let Some(leads) = people.get_mut("leads").and_then(|t| t.as_array_mut()) + && let Some(index) = find_index(leads, &username) + { + leads.remove(index); + } + + let Some(members) = people.get_mut("members").and_then(|t| t.as_array_mut()) else { + continue; + }; + let Some(index) = find_index(members, &username) else { + println!("{username} not found in {}", path.display()); + continue; + }; + let entry = members.remove(index); + + let alumni = people + .entry("alumni") + .or_insert(Item::Value(Value::Array(Array::new()))) + .as_array_mut() + .unwrap(); + alumni.push_formatted(entry); + *alumni = build_alumni_array(&alumni.iter().cloned().collect::>()); + + std::fs::write(path, document.to_string())?; + } + Ok(teams) +}