Skip to content

Commit 70a60ac

Browse files
Merge pull request #27 from wesley-dean-flexion/abstract_github_interactions
2 parents 0d98848 + 780bcdb commit 70a60ac

3 files changed

Lines changed: 201 additions & 19 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ will query GitHub for the list of members of the
66
organization and add them to a team under that
77
organization.
88

9+
* **NOTE**: This tool does not modify organizational membership,
10+
nor does it change any settings for any repository. It
11+
only affects the one team that's specified and its
12+
membership.
13+
914
## Reason for this tool
1015

1116
Access to repositories can be provided to individual
@@ -19,6 +24,14 @@ resources. This tool fills in the gap -- the
1924
missing ability to specify organization-wide
2025
access -- by creating a team that may be used.
2126

27+
Also, for organizations using GitHub Enterprise Cloud
28+
(GHEC), there exists
29+
[native functionality](https://docs.github.com/en/enterprise-cloud@latest/organizations/organizing-members-into-teams/synchronizing-a-team-with-an-identity-provider-group)
30+
for synchronizing team membership with a supported
31+
Identity Provider (IdP). The native functionality
32+
would likely represent a better approach than using
33+
this tool.
34+
2235
## Installing the tool
2336

2437
There are several Python modules required to run the
@@ -49,6 +62,8 @@ environment variables or through a file named
4962
* **API_URL**: the URL to the API to be queried; update this to
5063
support GitHub Enterprise (GHE) installations
5164
* **DELAY**: the number of seconds to delay between each user operation
65+
* **LOG_LEVEL**: the threshold for displaying log messages
66+
10 = Debug, 20 = Info (default), 30 = Warning, 40 = Error, 50 = Critcal
5267
* **USER_FILTERS**: a JSON object that allows for the filtering of
5368
users who may be members of the team.
5469

env.sample

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,24 @@ PAT=ghp_....
1111
# users to determine if they should be allowed in to
1212
# a group or if they should be excluded
1313
USER_FILTERS='{"login": {"reject": [ "^w" ], "allow": [ "n$" ] }}'
14+
15+
# the level for which events are logged with lower numbers meaning
16+
# more verbosity
17+
#
18+
# DEBUG: 10
19+
# INFO: 20 (default)
20+
# WARNING: 30
21+
# ERROR: 40
22+
# CRITICAL: 50
23+
LOG_LEVEL = 20
24+
25+
# If DRY_RUN is false, perform read/write operations; otherwise
26+
# only perform read-only operations
27+
DRY_RUN = True
28+
29+
# The URL to the API; the default is the URL to GitHub.com's API
30+
# (note the absence of a trailing '/')
31+
API_URL = https://api.github.com
32+
33+
# How many seconds to delay between API calls
34+
DELAY = 3.0

sync.py

Lines changed: 165 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,68 @@
2222
import logging
2323
import os
2424
import re
25+
import sys
2526
import time
2627

2728
from dotenv import load_dotenv
2829
from github import Github
2930

30-
logging.basicConfig(level=logging.DEBUG)
31-
3231
load_dotenv()
3332

34-
PAT = str(os.getenv("PAT"))
35-
ORG = str(os.environ["ORG"])
33+
##
34+
# @var str PAT
35+
# @brief Personal Access Token for interacting with GitHub
36+
PAT = str(os.getenv("PAT", None))
37+
38+
##
39+
# @var str ORG
40+
# @brief name of GitHub organization to query
41+
ORG = str(os.getenv("ORG", None))
3642

37-
TEAM_NAME = os.environ["TEAM_NAME"]
43+
##
44+
# @var str TEAM_NAME
45+
# @brief the name of the team to process
46+
TEAM_NAME = os.getenv("TEAM_NAME", None)
47+
48+
##
49+
# @var str DRY_RUN
50+
# @brief when False, perform read-write operations; otherwise, read-only
3851
DRY_RUN = os.getenv("DRY_RUN", "True")
52+
53+
##
54+
# @var str API_URL
55+
# @brief the URL to the API; default is GitHub.com's API
3956
API_URL = os.getenv("API_URL", "https://api.github.com")
57+
58+
##
59+
# @var str USER_FILTERS
60+
# @brief a JSON object describing the filters to apply to determine membership
4061
USER_FILTERS = json.loads(os.getenv("USER_FILTERS", "[]"))
62+
63+
##
64+
# @var float DELAY
65+
# @brief the number of seconds to wait between API calls
4166
DELAY = float(os.getenv("DELAY", "3"))
4267

68+
##
69+
# @var int LOG_LEVEL
70+
# @brief the threshold for displaying logs; higher is quieter
71+
LOG_LEVEL = int(os.getenv("LOGGING", "20"))
72+
73+
logging.basicConfig(level=LOG_LEVEL)
74+
75+
if PAT is None:
76+
logging.critical("PAT was undefined")
77+
sys.exit(1)
78+
79+
if ORG is None:
80+
logging.critical("ORG was undefined")
81+
sys.exit(1)
82+
83+
if TEAM_NAME is None:
84+
logging.critical("TEAM_NAME was undefined")
85+
sys.exit(1)
86+
4387

4488
def create_team_if_not_exists(team_name, organization):
4589
"""
@@ -259,6 +303,106 @@ def matches_regexes(member, field, key):
259303
return False
260304

261305

306+
def is_dry_run(value=DRY_RUN):
307+
"""
308+
@fn is_dry_run()
309+
@brief determine if we're running in dry run or not
310+
@details
311+
A "dry run" is one where we don't actually make any
312+
changes and it's determined by the global `DRY_RUN`.
313+
When `DRY_RUN` is set to `False` or `no`, then we'll
314+
return False (perform write operations); when it's
315+
set to `True` or `yes, we return `True` (do NOT
316+
perform write operations); if it's set to anything else,
317+
we return `True` (do NOT perform write operations).
318+
319+
By default, we return True (don't perform write
320+
operations) just to be safe.
321+
@param value the value to evaluate (default: DRY_RUN)
322+
@retval False perform write operations
323+
@retval True don't perform write operations
324+
@par Examples
325+
@code
326+
if is_dry_run():
327+
print("We won't do the thing")
328+
else:
329+
print("We're going to do the thing!!!")
330+
@endcode
331+
"""
332+
333+
normalized_value = value.lower()
334+
335+
if normalized_value in ("true", "yes"):
336+
return True
337+
if normalized_value in ("false", "no"):
338+
return False
339+
340+
return True
341+
342+
343+
def add_member_to_team(member_object, team_object):
344+
"""
345+
@fn add_member_to_team()
346+
@brief add a member to a team
347+
@details
348+
This will add a member object to a team object
349+
@param member_object the object that represents the user
350+
@param team_object the object that represents the team
351+
@retval True if the addition was successful
352+
@retval False if the addition failed
353+
@retval None otherwise (e.g., dry run)
354+
@par Examples
355+
@code
356+
add_member_to_team(wes_object, awesome_people_team_object)
357+
@endcode
358+
"""
359+
360+
team_name = team_object.name
361+
member_name = member_object.login
362+
363+
logging.info("Received member '%s' to add to team '%s'", member_name, team_name)
364+
365+
if is_dry_run():
366+
logging.debug("In DRY_RUN, so not interacting with GitHub API")
367+
return None
368+
369+
logging.debug("Not in DRY_RUN, so interacting with GitHub API")
370+
return team_object.add_membership(member_name)
371+
372+
373+
def remove_member_from_team(member_object, team_object):
374+
"""
375+
@fn remove_member_from_team()
376+
@brief remove a member from a team
377+
@details
378+
This will remove a member object from a team object. It's the
379+
opposite of add_member_to_team().
380+
@param member_object the object that represents the user
381+
@param team_object the object that represents the team
382+
@retval True if the removal was successful
383+
@retval False if the removal failed
384+
@retval None otherwise (e.g., dry run)
385+
@par Examples
386+
@code
387+
remove_member_from_team(bad_guy_object, winners_team_object)
388+
@endcode
389+
"""
390+
391+
team_name = team_object.name
392+
member_name = member_object.login
393+
394+
logging.info(
395+
"Received member '%s' to remove from team '%s'", member_name, team_name
396+
)
397+
398+
if is_dry_run():
399+
logging.debug("In DRY_RUN, so not interacting with GitHub API")
400+
return None
401+
402+
logging.debug("Not in DRY_RUN, so interacting with GitHub API")
403+
return team_object.remove_member(member_name)
404+
405+
262406
def main():
263407
"""
264408
@fn main()
@@ -271,36 +415,38 @@ def main():
271415

272416
github = Github(login_or_token=PAT, base_url=API_URL)
273417

274-
organization = github.get_organization(ORG)
418+
organization_object = github.get_organization(ORG)
275419

276-
team = create_team_if_not_exists(TEAM_NAME, organization)
420+
team_object = create_team_if_not_exists(TEAM_NAME, organization_object)
277421

278-
current_org_members = get_group_logins(organization)
279-
current_team_members = get_group_logins(team)
422+
current_org_members = get_group_logins(organization_object)
423+
current_team_members = get_group_logins(team_object)
424+
425+
member_count = 0
426+
member_total = len(current_org_members)
280427

281428
for member in current_org_members:
282429
member_object = github.get_user(member)
430+
member_count += 1
431+
432+
logging.info("%i / %i: %s", member_count, member_total, member)
433+
283434
if current_team_members[member] and allow_user(member_object) is False:
284435
logging.info(
285436
"'%s' is in the team but shouldn't be, so removing them", member
286437
)
287-
if not DRY_RUN.lower == "false":
288-
team.remove_membership(member)
289-
else:
290-
logging.info("dry run so not removing the user")
438+
remove_member_from_team(member_object, team_object)
291439
elif not current_team_members[member] and allow_user(member_object) in (
292440
None,
293441
True,
294442
):
295443
logging.info("'%s' is in the org but not the team, so adding them", member)
296-
if not DRY_RUN.lower == "false":
297-
team.add_membership(member)
298-
else:
299-
logging.info("dry run so not adding the user")
444+
add_member_to_team(member_object, team_object)
300445
else:
301-
logging.debug("No action required for '%s'", member)
446+
logging.info("No action required for '%s'", member)
302447

303-
time.sleep(DELAY)
448+
if member_count != member_total:
449+
time.sleep(DELAY)
304450

305451

306452
if __name__ == "__main__":

0 commit comments

Comments
 (0)