Skip to content

Commit b8049db

Browse files
me-diruKobzol
authored andcommitted
Sync organization members
1 parent 4d4e0d6 commit b8049db

File tree

5 files changed

+188
-5
lines changed

5 files changed

+188
-5
lines changed

sync-team/src/github/api/read.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ pub(crate) trait GithubRead {
1515
/// Get the owners of an org
1616
fn org_owners(&self, org: &str) -> anyhow::Result<HashSet<u64>>;
1717

18+
/// Get the members of an org
19+
fn org_members(&self, org: &str) -> anyhow::Result<HashMap<u64, String>>;
20+
1821
/// Get all teams associated with a org
1922
///
2023
/// Returns a list of tuples of team name and slug
@@ -119,6 +122,26 @@ impl GithubRead for GitHubApiRead {
119122
Ok(owners)
120123
}
121124

125+
fn org_members(&self, org: &str) -> anyhow::Result<HashMap<u64, String>> {
126+
#[derive(serde::Deserialize, Eq, PartialEq, Hash)]
127+
struct User {
128+
id: u64,
129+
login: String,
130+
}
131+
let mut members = HashMap::new();
132+
self.client.rest_paginated(
133+
&Method::GET,
134+
&GitHubUrl::orgs(org, "members")?,
135+
|resp: Vec<User>| {
136+
for user in resp {
137+
members.insert(user.id, user.login);
138+
}
139+
Ok(())
140+
},
141+
)?;
142+
Ok(members)
143+
}
144+
122145
fn org_teams(&self, org: &str) -> anyhow::Result<Vec<(String, String)>> {
123146
let mut teams = Vec::new();
124147

sync-team/src/github/api/write.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,18 @@ impl GitHubWrite {
344344
Ok(())
345345
}
346346

347+
/// Remove a member from an org
348+
pub(crate) fn remove_gh_member_from_org(&self, org: &str, user: &str) -> anyhow::Result<()> {
349+
debug!("Removing user {user} from org {org}");
350+
if !self.dry_run {
351+
let method = Method::DELETE;
352+
let url = GitHubUrl::orgs(org, &format!("members/{user}"))?;
353+
let resp = self.client.req(method.clone(), &url)?.send()?;
354+
allow_not_found(resp, method, url.url())?;
355+
}
356+
Ok(())
357+
}
358+
347359
/// Remove a collaborator from a repo
348360
pub(crate) fn remove_collaborator_from_repo(
349361
&self,

sync-team/src/github/mod.rs

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use self::api::{BranchProtectionOp, TeamPrivacy, TeamRole};
66
use crate::github::api::{GithubRead, Login, PushAllowanceActor, RepoPermission, RepoSettings};
77
use log::debug;
88
use rust_team_data::v1::{Bot, BranchProtectionMode, MergeBot};
9-
use std::collections::{HashMap, HashSet};
9+
use std::collections::{BTreeMap, HashMap, HashSet};
1010
use std::fmt::Write;
1111

1212
pub(crate) use self::api::{GitHubApiRead, GitHubWrite, HttpClient};
@@ -31,6 +31,7 @@ struct SyncGitHub {
3131
repos: Vec<rust_team_data::v1::Repo>,
3232
usernames_cache: HashMap<u64, String>,
3333
org_owners: HashMap<OrgName, HashSet<u64>>,
34+
org_members: HashMap<OrgName, HashMap<u64, String>>,
3435
}
3536

3637
impl SyncGitHub {
@@ -60,9 +61,11 @@ impl SyncGitHub {
6061
.collect::<HashSet<_>>();
6162

6263
let mut org_owners = HashMap::new();
64+
let mut org_members = HashMap::new();
6365

6466
for org in &orgs {
6567
org_owners.insert((*org).to_string(), github.org_owners(org)?);
68+
org_members.insert((*org).to_string(), github.org_members(org)?);
6669
}
6770

6871
Ok(SyncGitHub {
@@ -71,19 +74,74 @@ impl SyncGitHub {
7174
repos,
7275
usernames_cache,
7376
org_owners,
77+
org_members,
7478
})
7579
}
7680

7781
pub(crate) fn diff_all(&self) -> anyhow::Result<Diff> {
7882
let team_diffs = self.diff_teams()?;
7983
let repo_diffs = self.diff_repos()?;
84+
let org_membership_diffs = self.diff_org_memberships()?;
8085

8186
Ok(Diff {
8287
team_diffs,
8388
repo_diffs,
89+
org_membership_diffs,
8490
})
8591
}
8692

93+
// Collect all org members from the respective teams
94+
fn get_org_members_from_teams(&self) -> HashMap<OrgName, HashSet<u64>> {
95+
let mut org_team_members: HashMap<OrgName, HashSet<u64>> = HashMap::new();
96+
97+
for team in &self.teams {
98+
if let Some(gh) = &team.github {
99+
for toml_gh_team in &gh.teams {
100+
org_team_members
101+
.entry(toml_gh_team.org.clone())
102+
.or_default()
103+
.extend(toml_gh_team.members.iter().copied());
104+
}
105+
}
106+
}
107+
org_team_members
108+
}
109+
110+
// Diff organization memberships between TOML teams and GitHub
111+
fn diff_org_memberships(&self) -> anyhow::Result<Vec<OrgMembershipDiff>> {
112+
let toml_org_team_members = self.get_org_members_from_teams();
113+
114+
let mut org_diffs: BTreeMap<String, OrgMembershipDiff> = BTreeMap::new();
115+
116+
for (org, toml_members) in toml_org_team_members {
117+
let Some(gh_org_members) = self.org_members.get(&org) else {
118+
panic!("GitHub organization {org} not found");
119+
};
120+
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+
}
126+
127+
// The rest are members that should be removed
128+
if !members_to_remove.is_empty() {
129+
let mut members_to_remove: Vec<String> = members_to_remove.into_values().collect();
130+
members_to_remove.sort();
131+
132+
org_diffs.insert(
133+
org.clone(),
134+
OrgMembershipDiff {
135+
org,
136+
members_to_remove,
137+
},
138+
);
139+
}
140+
}
141+
142+
Ok(org_diffs.into_values().collect())
143+
}
144+
87145
fn diff_teams(&self) -> anyhow::Result<Vec<TeamDiff>> {
88146
let mut diffs = Vec::new();
89147
let mut unseen_github_teams = HashMap::new();
@@ -584,6 +642,7 @@ const BOTS_TEAMS: &[&str] = &["bors", "highfive", "rfcbot", "bots"];
584642
pub(crate) struct Diff {
585643
team_diffs: Vec<TeamDiff>,
586644
repo_diffs: Vec<RepoDiff>,
645+
org_membership_diffs: Vec<OrgMembershipDiff>,
587646
}
588647

589648
impl Diff {
@@ -595,12 +654,17 @@ impl Diff {
595654
for repo_diff in self.repo_diffs {
596655
repo_diff.apply(sync)?;
597656
}
657+
for org_diff in self.org_membership_diffs {
658+
org_diff.apply(sync)?;
659+
}
598660

599661
Ok(())
600662
}
601663

602664
pub(crate) fn is_empty(&self) -> bool {
603-
self.team_diffs.is_empty() && self.repo_diffs.is_empty()
665+
self.team_diffs.is_empty()
666+
&& self.repo_diffs.is_empty()
667+
&& self.org_membership_diffs.is_empty()
604668
}
605669
}
606670

@@ -620,6 +684,13 @@ impl std::fmt::Display for Diff {
620684
}
621685
}
622686

687+
if !&self.org_membership_diffs.is_empty() {
688+
writeln!(f, "💻 Org membership Diffs:")?;
689+
for org_diff in &self.org_membership_diffs {
690+
write!(f, "{org_diff}")?;
691+
}
692+
}
693+
623694
Ok(())
624695
}
625696
}
@@ -655,6 +726,34 @@ impl std::fmt::Display for RepoDiff {
655726
}
656727
}
657728

729+
#[derive(Debug)]
730+
struct OrgMembershipDiff {
731+
org: OrgName,
732+
members_to_remove: Vec<String>,
733+
}
734+
735+
impl OrgMembershipDiff {
736+
fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> {
737+
for member in &self.members_to_remove {
738+
sync.remove_gh_member_from_org(&self.org, &member)?;
739+
}
740+
741+
Ok(())
742+
}
743+
}
744+
745+
impl std::fmt::Display for OrgMembershipDiff {
746+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
747+
if !self.members_to_remove.is_empty() {
748+
writeln!(f, "❌ Removing the following members from `{}`:", self.org)?;
749+
for member in &self.members_to_remove {
750+
writeln!(f, " - {member}",)?;
751+
}
752+
}
753+
Ok(())
754+
}
755+
}
756+
658757
#[derive(Debug)]
659758
struct CreateRepoDiff {
660759
org: String,

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ fn team_dont_add_member_if_invitation_is_pending() {
9797
insta::assert_debug_snapshot!(team_diff, @"[]");
9898
}
9999

100+
#[test]
101+
fn remove_org_members() {
102+
let mut model = DataModel::default();
103+
let user = model.create_user("sakura");
104+
model.create_team(TeamData::new("team-1").gh_team("rust-lang", "members-gh", &[user]));
105+
let mut gh = model.gh_model();
106+
gh.add_member("rust-lang", "martin");
107+
108+
let gh_org_diff = model.diff_org_membership(gh);
109+
110+
insta::assert_debug_snapshot!(gh_org_diff, @r#"
111+
[
112+
OrgMembershipDiff {
113+
org: "rust-lang",
114+
members_to_remove: [
115+
"martin",
116+
],
117+
},
118+
]
119+
"#);
120+
}
121+
100122
#[test]
101123
fn team_remove_member() {
102124
let mut model = DataModel::default();

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ use crate::github::api::{
1010
BranchProtection, GithubRead, Repo, RepoTeam, RepoUser, Team, TeamMember, TeamPrivacy, TeamRole,
1111
};
1212
use crate::github::{
13-
RepoDiff, SyncGitHub, TeamDiff, api, construct_branch_protection, convert_permission,
13+
OrgMembershipDiff, RepoDiff, SyncGitHub, TeamDiff, api, construct_branch_protection,
14+
convert_permission,
1415
};
1516

1617
pub const DEFAULT_ORG: &str = "rust-lang";
@@ -105,7 +106,13 @@ impl DataModel {
105106
slug: gh_team.name.clone(),
106107
});
107108

108-
org.members.extend(gh_team.members.iter().copied());
109+
org.members.extend(
110+
gh_team
111+
.members
112+
.iter()
113+
.copied()
114+
.map(|user_id| (user_id, users[&user_id].clone())),
115+
);
109116
}
110117
}
111118

@@ -168,6 +175,12 @@ impl DataModel {
168175
GithubMock { users, orgs }
169176
}
170177

178+
pub fn diff_org_membership(&self, github: GithubMock) -> Vec<OrgMembershipDiff> {
179+
self.create_sync(github)
180+
.diff_org_memberships()
181+
.expect("Cannot diff org membership")
182+
}
183+
171184
pub fn diff_teams(&self, github: GithubMock) -> Vec<TeamDiff> {
172185
self.create_sync(github)
173186
.diff_teams()
@@ -416,6 +429,16 @@ pub struct GithubMock {
416429
}
417430

418431
impl GithubMock {
432+
pub fn add_member(&mut self, org: &str, username: &str) {
433+
let user_id = self.users.len() as UserId;
434+
self.users.insert(user_id, username.to_string());
435+
self.orgs
436+
.get_mut(org)
437+
.unwrap()
438+
.members
439+
.insert((user_id, username.to_string()));
440+
}
441+
419442
pub fn add_invitation(&mut self, org: &str, repo: &str, user: &str) {
420443
self.get_org_mut(org)
421444
.team_invitations
@@ -455,6 +478,10 @@ impl GithubRead for GithubMock {
455478
Ok(self.get_org(org).owners.iter().copied().collect())
456479
}
457480

481+
fn org_members(&self, org: &str) -> anyhow::Result<HashMap<u64, String>> {
482+
Ok(self.get_org(org).members.iter().cloned().collect())
483+
}
484+
458485
fn org_teams(&self, org: &str) -> anyhow::Result<Vec<(String, String)>> {
459486
Ok(self
460487
.get_org(org)
@@ -547,7 +574,7 @@ impl GithubRead for GithubMock {
547574

548575
#[derive(Default)]
549576
struct GithubOrg {
550-
members: BTreeSet<UserId>,
577+
members: BTreeSet<(UserId, String)>,
551578
owners: BTreeSet<UserId>,
552579
teams: Vec<Team>,
553580
// Team name -> list of invited users

0 commit comments

Comments
 (0)