Skip to content
This repository was archived by the owner on Mar 31, 2025. It is now read-only.

Commit 17878b6

Browse files
committed
Add functionality to sync org members on GitHub
Signed-off-by: Rohit Dandamudi <[email protected]>
1 parent 1480ace commit 17878b6

File tree

5 files changed

+219
-2
lines changed

5 files changed

+219
-2
lines changed

src/github/api/read.rs

+20
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ pub(crate) trait GithubRead {
1313
/// Get the owners of an org
1414
fn org_owners(&self, org: &str) -> anyhow::Result<HashSet<u64>>;
1515

16+
/// Get the members of an org
17+
fn org_members(&self, org: &str) -> anyhow::Result<HashSet<u64>>;
18+
1619
/// Get the app installations of an org
1720
fn org_app_installations(&self, org: &str) -> anyhow::Result<Vec<OrgAppInstallation>>;
1821

@@ -120,6 +123,23 @@ impl GithubRead for GitHubApiRead {
120123
Ok(owners)
121124
}
122125

126+
fn org_members(&self, org: &str) -> anyhow::Result<HashSet<u64>> {
127+
#[derive(serde::Deserialize, Eq, PartialEq, Hash)]
128+
struct User {
129+
id: u64,
130+
}
131+
let mut members = HashSet::new();
132+
self.client.rest_paginated(
133+
&Method::GET,
134+
format!("orgs/{org}/members"),
135+
|resp: Vec<User>| {
136+
members.extend(resp.into_iter().map(|u| u.id));
137+
Ok(())
138+
},
139+
)?;
140+
Ok(members)
141+
}
142+
123143
fn org_app_installations(&self, org: &str) -> anyhow::Result<Vec<OrgAppInstallation>> {
124144
#[derive(serde::Deserialize, Debug)]
125145
struct InstallationPage {

src/github/api/write.rs

+12
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,18 @@ impl GitHubWrite {
375375
Ok(())
376376
}
377377

378+
/// Remove a member from an org
379+
pub(crate) fn remove_gh_member_from_org(&self, org: &str, user: &str) -> anyhow::Result<()> {
380+
debug!("Removing user {user} from org {org}");
381+
if !self.dry_run {
382+
let method = Method::DELETE;
383+
let url = &format!("orgs/{org}/members/{user}");
384+
let resp = self.client.req(method.clone(), url)?.send()?;
385+
allow_not_found(resp, method, url)?;
386+
}
387+
Ok(())
388+
}
389+
378390
/// Remove a collaborator from a repo
379391
pub(crate) fn remove_collaborator_from_repo(
380392
&self,

src/github/mod.rs

+121-1
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ mod tests;
44

55
use self::api::{BranchProtectionOp, TeamPrivacy, TeamRole};
66
use crate::github::api::{GithubRead, Login, PushAllowanceActor, RepoPermission, RepoSettings};
7+
use anyhow::anyhow;
78
use log::debug;
8-
use rust_team_data::v1::{Bot, BranchProtectionMode, MergeBot};
9+
use rust_team_data::v1::{Bot, BranchProtectionMode, GitHubTeam, MergeBot};
910
use std::collections::{HashMap, HashSet};
1011
use std::fmt::{Display, Formatter, Write};
1112

@@ -73,6 +74,7 @@ struct SyncGitHub {
7374
repos: Vec<rust_team_data::v1::Repo>,
7475
usernames_cache: HashMap<u64, String>,
7576
org_owners: HashMap<OrgName, HashSet<u64>>,
77+
org_members: HashMap<OrgName, HashSet<u64>>,
7678
org_apps: HashMap<OrgName, Vec<OrgAppInstallation>>,
7779
}
7880

@@ -103,10 +105,12 @@ impl SyncGitHub {
103105
.collect::<HashSet<_>>();
104106

105107
let mut org_owners = HashMap::new();
108+
let mut org_members = HashMap::new();
106109
let mut org_apps = HashMap::new();
107110

108111
for org in &orgs {
109112
org_owners.insert((*org).to_string(), github.org_owners(org)?);
113+
org_members.insert((*org).to_string(), github.org_members(org)?);
110114

111115
let mut installations: Vec<OrgAppInstallation> = vec![];
112116

@@ -134,17 +138,21 @@ impl SyncGitHub {
134138
repos,
135139
usernames_cache,
136140
org_owners,
141+
org_members,
137142
org_apps,
138143
})
139144
}
140145

141146
pub(crate) fn diff_all(&self) -> anyhow::Result<Diff> {
142147
let team_diffs = self.diff_teams()?;
143148
let repo_diffs = self.diff_repos()?;
149+
let org_team_members = self.map_teams_to_org()?;
150+
let toml_github_diffs = self.diff_teams_gh_org(org_team_members)?;
144151

145152
Ok(Diff {
146153
team_diffs,
147154
repo_diffs,
155+
toml_github_diffs,
148156
})
149157
}
150158

@@ -195,6 +203,66 @@ impl SyncGitHub {
195203
Ok(diffs)
196204
}
197205

206+
// collect all org and respective teams members in a HashMap
207+
fn map_teams_to_org(&self) -> anyhow::Result<HashMap<String, HashSet<u64>>> {
208+
let mut org_team_members: HashMap<String, HashSet<u64>> = HashMap::new();
209+
210+
for team in &self.teams {
211+
let team_org;
212+
// get the team github org through the corresponding github team
213+
if let Some(gh) = &team.github {
214+
let github_teams = &gh.teams;
215+
let github_team: &GitHubTeam = github_teams
216+
.iter()
217+
.find(|&gt| gt.name == team.name)
218+
.expect("Team Not Found");
219+
team_org = github_team.org.clone();
220+
} else {
221+
return Err(anyhow!(
222+
"TeamGitHub object not found, got {:?}",
223+
&team.github
224+
));
225+
}
226+
227+
let team_members_github_id: HashSet<u64> =
228+
team.members.iter().map(|member| member.github_id).collect();
229+
230+
org_team_members
231+
.entry(team_org)
232+
.or_default()
233+
.extend(team_members_github_id);
234+
}
235+
Ok(org_team_members)
236+
}
237+
238+
// create diff against github org members against toml team members
239+
fn diff_teams_gh_org(
240+
&self,
241+
org_team_members: HashMap<String, HashSet<u64>>,
242+
) -> anyhow::Result<OrgMembershipDiff> {
243+
let mut org_with_members_to_be_removed: HashMap<String, HashSet<String>> = HashMap::new();
244+
245+
for (gh_org, toml_members_across_teams) in org_team_members.into_iter() {
246+
let gh_org_members = self.org_members.get(&gh_org).unwrap();
247+
248+
let mut members_to_be_removed = HashSet::new();
249+
250+
for toml_member in toml_members_across_teams {
251+
if !gh_org_members.contains(&toml_member.clone()) {
252+
members_to_be_removed.insert(self.usernames_cache[&toml_member].clone());
253+
}
254+
}
255+
org_with_members_to_be_removed
256+
.entry(gh_org)
257+
.or_default()
258+
.extend(members_to_be_removed);
259+
}
260+
261+
Ok(OrgMembershipDiff::Delete(DeleteOrgMembershipDiff {
262+
org_with_members: org_with_members_to_be_removed,
263+
}))
264+
}
265+
198266
fn diff_team(&self, github_team: &rust_team_data::v1::GitHubTeam) -> anyhow::Result<TeamDiff> {
199267
// Ensure the team exists and is consistent
200268
let team = match self.github.team(&github_team.org, &github_team.name)? {
@@ -667,6 +735,7 @@ const BOTS_TEAMS: &[&str] = &["bors", "highfive", "rfcbot", "bots"];
667735
pub(crate) struct Diff {
668736
team_diffs: Vec<TeamDiff>,
669737
repo_diffs: Vec<RepoDiff>,
738+
toml_github_diffs: OrgMembershipDiff,
670739
}
671740

672741
impl Diff {
@@ -679,6 +748,8 @@ impl Diff {
679748
repo_diff.apply(sync)?;
680749
}
681750

751+
self.toml_github_diffs.apply(sync)?;
752+
682753
Ok(())
683754
}
684755
}
@@ -720,6 +791,55 @@ impl std::fmt::Display for RepoDiff {
720791
}
721792
}
722793

794+
#[derive(Debug)]
795+
796+
enum OrgMembershipDiff {
797+
Delete(DeleteOrgMembershipDiff),
798+
}
799+
800+
impl OrgMembershipDiff {
801+
fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> {
802+
match self {
803+
OrgMembershipDiff::Delete(d) => d.apply(sync)?,
804+
}
805+
806+
Ok(())
807+
}
808+
}
809+
810+
impl std::fmt::Display for OrgMembershipDiff {
811+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
812+
match self {
813+
OrgMembershipDiff::Delete(d) => write!(f, "{d}"),
814+
}
815+
}
816+
}
817+
818+
#[derive(Debug)]
819+
820+
struct DeleteOrgMembershipDiff {
821+
org_with_members: HashMap<String, HashSet<String>>,
822+
}
823+
824+
impl DeleteOrgMembershipDiff {
825+
fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> {
826+
for (gh_org, members) in self.org_with_members.iter() {
827+
for member in members {
828+
sync.remove_gh_member_from_org(gh_org, member)?;
829+
}
830+
}
831+
832+
Ok(())
833+
}
834+
}
835+
836+
impl std::fmt::Display for DeleteOrgMembershipDiff {
837+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
838+
writeln!(f, "❌ Deleting members '{:?}'", self.org_with_members)?;
839+
Ok(())
840+
}
841+
}
842+
723843
struct CreateRepoDiff {
724844
org: String,
725845
name: String,

src/github/tests/mod.rs

+34
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashSet;
2+
13
use crate::github::tests::test_utils::{DataModel, TeamData};
24

35
mod test_utils;
@@ -116,6 +118,38 @@ fn team_dont_add_member_if_invitation_is_pending() {
116118
"###);
117119
}
118120

121+
#[test]
122+
fn org_member_not_sync() {
123+
let mut model = DataModel::default();
124+
let user = model.create_user("sakura");
125+
let user2 = model.create_user("hitori");
126+
model.create_team(TeamData::new("team-1").gh_team("members-gh", &[user, user2]));
127+
let gh = model.gh_model();
128+
129+
// let gh_members = gh.org_members("rust-lang").unwrap();
130+
131+
let gh_members = HashSet::from([user2]);
132+
133+
println!("User data {:?}", gh_members);
134+
model
135+
.get_team("team-1")
136+
.remove_gh_member("members-gh", user);
137+
138+
let gh_org_diff = model.diff_teams_gh_org(gh, gh_members);
139+
140+
insta::assert_debug_snapshot!(gh_org_diff, @r###"
141+
Delete(
142+
DeleteOrgMembershipDiff {
143+
org_with_members: {
144+
"rust-lang": {
145+
"hitori",
146+
},
147+
},
148+
},
149+
)
150+
"###);
151+
}
152+
119153
#[test]
120154
fn team_remove_member() {
121155
let mut model = DataModel::default();

src/github/tests/test_utils.rs

+32-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::github::api::{
77
BranchProtection, GithubRead, OrgAppInstallation, Repo, RepoAppInstallation, RepoTeam,
88
RepoUser, Team, TeamMember, TeamPrivacy, TeamRole,
99
};
10-
use crate::github::{api, SyncGitHub, TeamDiff};
10+
use crate::github::{api, OrgMembershipDiff, SyncGitHub, TeamDiff};
1111

1212
const DEFAULT_ORG: &str = "rust-lang";
1313

@@ -92,12 +92,30 @@ impl DataModel {
9292
GithubMock {
9393
users,
9494
owners: Default::default(),
95+
members: Default::default(),
9596
teams,
9697
team_memberships,
9798
team_invitations: Default::default(),
9899
}
99100
}
100101

102+
pub fn diff_teams_gh_org(
103+
&self,
104+
github: GithubMock,
105+
members: HashSet<u64>,
106+
) -> OrgMembershipDiff {
107+
let teams = self.teams.iter().map(|r| r.to_data()).collect();
108+
let repos = vec![];
109+
110+
let read = Box::new(github);
111+
let sync = SyncGitHub::new(read, teams, repos).expect("Cannot create SyncGitHub");
112+
let mut org_team_members = HashMap::new();
113+
org_team_members.insert("rust-lang".to_string(), members);
114+
115+
sync.diff_teams_gh_org(org_team_members)
116+
.expect("Cannot diff toml teams")
117+
}
118+
101119
pub fn diff_teams(&self, github: GithubMock) -> Vec<TeamDiff> {
102120
let teams = self.teams.iter().map(|r| r.to_data()).collect();
103121
let repos = vec![];
@@ -184,6 +202,9 @@ pub struct GithubMock {
184202
// org name -> user ID
185203
owners: HashMap<String, Vec<UserId>>,
186204
teams: Vec<Team>,
205+
206+
// org name -> user ID (members)
207+
members: HashMap<String, Vec<UserId>>,
187208
// Team name -> members
188209
team_memberships: HashMap<String, HashMap<UserId, TeamMember>>,
189210
// Team name -> list of invited users
@@ -219,6 +240,16 @@ impl GithubRead for GithubMock {
219240
.collect())
220241
}
221242

243+
fn org_members(&self, org: &str) -> anyhow::Result<HashSet<u64>> {
244+
Ok(self
245+
.members
246+
.get(org)
247+
.cloned()
248+
.unwrap_or_default()
249+
.into_iter()
250+
.collect())
251+
}
252+
222253
fn org_app_installations(&self, _org: &str) -> anyhow::Result<Vec<OrgAppInstallation>> {
223254
Ok(vec![])
224255
}

0 commit comments

Comments
 (0)