Skip to content

Commit 8e30e03

Browse files
committed
add org members allow list
1 parent 3e935a6 commit 8e30e03

File tree

14 files changed

+229
-19
lines changed

14 files changed

+229
-19
lines changed

config.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ allowed-github-orgs = [
1515
"rust-analyzer",
1616
]
1717

18+
# GitHub organizations where member management is independent
19+
# and should not be automatically synchronized
20+
independent-github-orgs = [
21+
"rust-embedded",
22+
]
23+
1824
permissions-bors-repos = [
1925
"bors-kindergarten",
2026
"rust",
@@ -27,3 +33,24 @@ permissions-bools = [
2733
"dev-desktop",
2834
"sync-team-confirmation",
2935
]
36+
37+
# GitHub accounts that are allowed to stay in the GitHub organizations,
38+
# even if they may not be members of any team.
39+
# Note: Infra admins are automatically included from the infra-admins team.
40+
special-org-members = [
41+
# Bots.
42+
"bors",
43+
"craterbot",
44+
"rust-embedded-bot",
45+
"rust-highfive",
46+
"rust-lang-core-team-agenda-bot",
47+
"rust-lang-glacier-bot",
48+
"rust-lang-owner",
49+
"rust-timer",
50+
"rustbot",
51+
52+
# Ferrous systems employees who manage the github sponsors
53+
# for rust-analyzer.
54+
"missholmes",
55+
"skade",
56+
]

src/data.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,26 @@ impl Data {
217217
.flatten()
218218
.any(|github_team| github_team.name == team_name && github_team.org == org)
219219
}
220+
221+
pub(crate) fn get_sync_team_config(&self) -> anyhow::Result<sync_team::Config> {
222+
let mut special_org_members = self.config.special_org_members().clone();
223+
224+
// Add infra admins from the infra-admins team
225+
let infra_admins_team = self
226+
.team("infra-admins")
227+
.context("cannot find infra-admins team")?;
228+
let infra_admins_usernames = infra_admins_team
229+
.raw_people()
230+
.members
231+
.iter()
232+
.map(|m| m.github.clone());
233+
special_org_members.extend(infra_admins_usernames);
234+
235+
Ok(sync_team::Config {
236+
special_org_members,
237+
independent_github_orgs: self.config.independent_github_orgs().clone(),
238+
})
239+
}
220240
}
221241

222242
fn load_file<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {

src/main.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,5 +590,11 @@ fn perform_sync(opts: SyncOpts, data: Data) -> anyhow::Result<()> {
590590
let subcmd = opts.command.unwrap_or(SyncCommand::DryRun);
591591
let only_print_plan = matches!(subcmd, SyncCommand::PrintPlan);
592592
let dry_run = only_print_plan || matches!(subcmd, SyncCommand::DryRun);
593-
run_sync_team(team_api, &services, dry_run, only_print_plan)
593+
run_sync_team(
594+
team_api,
595+
&services,
596+
dry_run,
597+
only_print_plan,
598+
data.get_sync_team_config()?,
599+
)
594600
}

src/schema.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ pub(crate) use crate::permissions::Permissions;
33
use anyhow::{bail, format_err, Error};
44
use serde::de::{Deserialize, Deserializer};
55
use serde_untagged::UntaggedEnumVisitor;
6-
use std::collections::{HashMap, HashSet};
6+
use std::collections::{BTreeSet, HashMap, HashSet};
77

88
#[derive(serde_derive::Deserialize, Debug)]
99
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
1010
pub(crate) struct Config {
1111
allowed_mailing_lists_domains: HashSet<String>,
1212
allowed_github_orgs: HashSet<String>,
13+
independent_github_orgs: BTreeSet<String>,
1314
permissions_bors_repos: HashSet<String>,
1415
permissions_bools: HashSet<String>,
16+
// Use a BTreeSet for consistent ordering in tests
17+
special_org_members: BTreeSet<String>,
1518
}
1619

1720
impl Config {
@@ -30,6 +33,14 @@ impl Config {
3033
pub(crate) fn permissions_bools(&self) -> &HashSet<String> {
3134
&self.permissions_bools
3235
}
36+
37+
pub(crate) fn independent_github_orgs(&self) -> &BTreeSet<String> {
38+
&self.independent_github_orgs
39+
}
40+
41+
pub(crate) fn special_org_members(&self) -> &BTreeSet<String> {
42+
&self.special_org_members
43+
}
3344
}
3445

3546
// This is an enum to allow two kinds of values for the email field:

sync-team/src/github/mod.rs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod api;
33
mod tests;
44

55
use self::api::{BranchProtectionOp, TeamPrivacy, TeamRole};
6+
use crate::Config;
67
use crate::github::api::{GithubRead, Login, PushAllowanceActor, RepoPermission, RepoSettings};
78
use log::debug;
89
use rust_team_data::v1::{Bot, BranchProtectionMode, MergeBot};
@@ -18,8 +19,9 @@ pub(crate) fn create_diff(
1819
github: Box<dyn GithubRead>,
1920
teams: Vec<rust_team_data::v1::Team>,
2021
repos: Vec<rust_team_data::v1::Repo>,
22+
config: Config,
2123
) -> anyhow::Result<Diff> {
22-
let github = SyncGitHub::new(github, teams, repos)?;
24+
let github = SyncGitHub::new(github, teams, repos, config)?;
2325
github.diff_all()
2426
}
2527

@@ -29,6 +31,7 @@ struct SyncGitHub {
2931
github: Box<dyn GithubRead>,
3032
teams: Vec<rust_team_data::v1::Team>,
3133
repos: Vec<rust_team_data::v1::Repo>,
34+
config: Config,
3235
usernames_cache: HashMap<u64, String>,
3336
org_owners: HashMap<OrgName, HashSet<u64>>,
3437
org_members: HashMap<OrgName, HashMap<u64, String>>,
@@ -39,6 +42,7 @@ impl SyncGitHub {
3942
github: Box<dyn GithubRead>,
4043
teams: Vec<rust_team_data::v1::Team>,
4144
repos: Vec<rust_team_data::v1::Repo>,
45+
config: Config,
4246
) -> anyhow::Result<Self> {
4347
debug!("caching mapping between user ids and usernames");
4448
let users = teams
@@ -72,6 +76,7 @@ impl SyncGitHub {
7276
github,
7377
teams,
7478
repos,
79+
config,
7580
usernames_cache,
7681
org_owners,
7782
org_members,
@@ -90,7 +95,7 @@ impl SyncGitHub {
9095
})
9196
}
9297

93-
// Collect all org members from the respective teams
98+
/// Collect all org members from the respective teams
9499
fn get_org_members_from_teams(&self) -> HashMap<OrgName, HashSet<u64>> {
95100
let mut org_team_members: HashMap<OrgName, HashSet<u64>> = HashMap::new();
96101

@@ -107,22 +112,24 @@ impl SyncGitHub {
107112
org_team_members
108113
}
109114

110-
// Diff organization memberships between TOML teams and GitHub
115+
/// Diff organization memberships between TOML teams and GitHub
111116
fn diff_org_memberships(&self) -> anyhow::Result<Vec<OrgMembershipDiff>> {
112117
let toml_org_team_members = self.get_org_members_from_teams();
113118

114119
let mut org_diffs: BTreeMap<String, OrgMembershipDiff> = BTreeMap::new();
115120

116121
for (org, toml_members) in toml_org_team_members {
122+
// Skip independent organizations - they manage their own members
123+
if self.config.independent_github_orgs.contains(&org) {
124+
debug!("Skipping member sync for independent organization: {}", org);
125+
continue;
126+
}
127+
117128
let Some(gh_org_members) = self.org_members.get(&org) else {
118129
panic!("GitHub organization {org} not found");
119130
};
120131

121-
// Remove all members that are in TOML teams
122-
let mut members_to_remove = gh_org_members.clone();
123-
for member in toml_members {
124-
members_to_remove.remove(&member);
125-
}
132+
let members_to_remove = self.members_to_remove(toml_members, gh_org_members);
126133

127134
// The rest are members that should be removed
128135
if !members_to_remove.is_empty() {
@@ -142,6 +149,34 @@ impl SyncGitHub {
142149
Ok(org_diffs.into_values().collect())
143150
}
144151

152+
/// Return GitHub members that should be removed from the organization.
153+
fn members_to_remove(
154+
&self,
155+
toml_members: HashSet<u64>,
156+
gh_org_members: &HashMap<u64, String>,
157+
) -> HashMap<u64, String> {
158+
// Initialize `members_to_remove` to all GitHub members in the org.
159+
// Next, we'll delete members from `members_to_remove` that don't respect certain criteria.
160+
let mut members_to_remove = gh_org_members.clone();
161+
162+
// People who belong to a team should stay in the org.
163+
for member in toml_members {
164+
members_to_remove.remove(&member);
165+
}
166+
167+
// Members that are explicitly allowed in the `config.toml` file should stay in the org.
168+
for allowed_member in &self.config.special_org_members {
169+
if let Some(member_to_retain) = members_to_remove
170+
.iter()
171+
.find(|(_, username)| username == &allowed_member)
172+
.map(|(id, _)| *id)
173+
{
174+
members_to_remove.remove(&member_to_retain);
175+
}
176+
}
177+
members_to_remove
178+
}
179+
145180
fn diff_teams(&self) -> anyhow::Result<Vec<TeamDiff>> {
146181
let mut diffs = Vec::new();
147182
let mut unseen_github_teams = HashMap::new();
@@ -735,7 +770,7 @@ struct OrgMembershipDiff {
735770
impl OrgMembershipDiff {
736771
fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> {
737772
for member in &self.members_to_remove {
738-
sync.remove_gh_member_from_org(&self.org, &member)?;
773+
sync.remove_gh_member_from_org(&self.org, member)?;
739774
}
740775

741776
Ok(())

sync-team/src/github/tests/mod.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,16 @@ fn team_dont_add_member_if_invitation_is_pending() {
100100
#[test]
101101
fn remove_org_members() {
102102
let mut model = DataModel::default();
103+
let rust_lang_org = "rust-lang";
103104
let user = model.create_user("sakura");
104-
model.create_team(TeamData::new("team-1").gh_team("rust-lang", "members-gh", &[user]));
105+
model.create_team(TeamData::new("team-1").gh_team(rust_lang_org, "members-gh", &[user]));
105106
let mut gh = model.gh_model();
106-
gh.add_member("rust-lang", "martin");
107+
gh.add_member(rust_lang_org, "martin");
108+
109+
// Add a bot that shouldn't be removed from the org.
110+
let bot = "my-bot";
111+
gh.add_member(rust_lang_org, bot);
112+
model.add_allowed_org_member(bot);
107113

108114
let gh_org_diff = model.diff_org_membership(gh);
109115

@@ -888,3 +894,26 @@ fn repo_remove_branch_protection() {
888894
]
889895
"#);
890896
}
897+
898+
#[test]
899+
fn independent_orgs_are_not_synced() {
900+
let mut model = DataModel::default();
901+
let user = model.create_user("sakura");
902+
903+
let independent_org = "independent-org";
904+
905+
// Create a team, so that membership is synced.
906+
model.create_team(TeamData::new("team").gh_team(independent_org, "team-gh", &[user]));
907+
908+
let mut gh = model.gh_model();
909+
910+
// Add a member who is not part of any team.
911+
gh.add_member(independent_org, "independent-user-1");
912+
913+
model.add_independent_github_org(independent_org);
914+
915+
let gh_org_diff = model.diff_org_membership(gh);
916+
917+
// No members should be removed for independent organizations
918+
insta::assert_debug_snapshot!(gh_org_diff, @"[]");
919+
}

sync-team/src/github/tests/test_utils.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use std::collections::{BTreeSet, HashMap, HashSet};
22

33
use derive_builder::Builder;
4-
use rust_team_data::v1;
54
use rust_team_data::v1::{
6-
Bot, BranchProtectionMode, GitHubTeam, MergeBot, Person, RepoPermission, TeamGitHub, TeamKind,
5+
self, Bot, BranchProtectionMode, GitHubTeam, MergeBot, Person, RepoPermission, TeamGitHub,
6+
TeamKind,
77
};
88

9+
use crate::Config;
910
use crate::github::api::{
1011
BranchProtection, GithubRead, Repo, RepoTeam, RepoUser, Team, TeamMember, TeamPrivacy, TeamRole,
1112
};
@@ -28,6 +29,7 @@ pub struct DataModel {
2829
people: Vec<Person>,
2930
teams: Vec<TeamData>,
3031
repos: Vec<RepoData>,
32+
config: Config,
3133
}
3234

3335
impl DataModel {
@@ -65,6 +67,14 @@ impl DataModel {
6567
.expect("Repo not found")
6668
}
6769

70+
pub fn add_allowed_org_member(&mut self, member: &str) {
71+
self.config.special_org_members.insert(member.to_string());
72+
}
73+
74+
pub fn add_independent_github_org(&mut self, org: &str) {
75+
self.config.independent_github_orgs.insert(org.to_string());
76+
}
77+
6878
/// Creates a GitHub model from the current team data mock.
6979
/// Note that all users should have been created before calling this method, so that
7080
/// GitHub knows about the users' existence.
@@ -196,8 +206,9 @@ impl DataModel {
196206
fn create_sync(&self, github: GithubMock) -> SyncGitHub {
197207
let teams = self.teams.iter().cloned().map(|t| t.into()).collect();
198208
let repos = self.repos.iter().cloned().map(|r| r.into()).collect();
209+
let config = self.config.clone();
199210

200-
SyncGitHub::new(Box::new(github), teams, repos).expect("Cannot create SyncGitHub")
211+
SyncGitHub::new(Box::new(github), teams, repos, config).expect("Cannot create SyncGitHub")
201212
}
202213
}
203214

@@ -435,8 +446,8 @@ impl GithubMock {
435446
let user_id = self.users.len() as UserId;
436447
self.users.insert(user_id, username.to_string());
437448
self.orgs
438-
.get_mut(org)
439-
.unwrap()
449+
.entry(org.to_string())
450+
.or_default()
440451
.members
441452
.insert((user_id, username.to_string()));
442453
}

sync-team/src/lib.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ pub mod team_api;
44
mod utils;
55
mod zulip;
66

7+
use std::collections::BTreeSet;
8+
79
use crate::github::{GitHubApiRead, GitHubWrite, HttpClient, create_diff};
810
use crate::team_api::TeamApi;
911
use crate::zulip::SyncZulip;
@@ -13,11 +15,18 @@ use secrecy::SecretString;
1315

1416
const USER_AGENT: &str = "rust-lang teams sync (https://github.com/rust-lang/sync-team)";
1517

18+
#[derive(Debug, Clone, Default)]
19+
pub struct Config {
20+
pub special_org_members: BTreeSet<String>,
21+
pub independent_github_orgs: BTreeSet<String>,
22+
}
23+
1624
pub fn run_sync_team(
1725
team_api: TeamApi,
1826
services: &[String],
1927
dry_run: bool,
2028
only_print_plan: bool,
29+
config: Config,
2130
) -> anyhow::Result<()> {
2231
if dry_run {
2332
warn!("sync-team is running in dry mode, no changes will be applied.");
@@ -31,7 +40,7 @@ pub fn run_sync_team(
3140
let gh_read = Box::new(GitHubApiRead::from_client(client.clone())?);
3241
let teams = team_api.get_teams()?;
3342
let repos = team_api.get_repos()?;
34-
let diff = create_diff(gh_read, teams, repos)?;
43+
let diff = create_diff(gh_read, teams, repos, config.clone())?;
3544
if !diff.is_empty() {
3645
info!("{diff}");
3746
}

tests/static-api/_expected/v1/people.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
{
22
"people": {
3+
"test-admin": {
4+
"name": "Test Admin",
5+
"email": "[email protected]",
6+
"github_id": 9999
7+
},
38
"user-0": {
49
"name": "Zeroth user",
510
"email": "[email protected]",

0 commit comments

Comments
 (0)