Skip to content

Commit 9d1af74

Browse files
committed
TutorTask555: Add rate-limited gh auto-invite code (from Krishna's latest checkpoint) and some unit tests using mocking
Pre-commit checks: All checks passed ✅
1 parent 69059e1 commit 9d1af74

2 files changed

Lines changed: 159 additions & 31 deletions

File tree

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,49 @@
1+
#!/usr/bin/env python
12
"""
3+
Invite GitHub collaborators listed in a Google Sheet while obeying the
4+
50-invite / 24-hour cap.
5+
6+
> automate_collaborator_invitations.py \
7+
--drive_url "https://docs.google.com/spreadsheets/d/1Ez5uRvOgvDMkFc9c6mI21kscTKnpiCSh4UkUh_ifLIw
8+
/edit?gid=0#gid=0" \
9+
--gh_token "$GH_PAT" \
10+
--org_name causify-ai \
11+
--repo_name tutorials
12+
13+
214
Import as:
315
416
import DATA605.TutorialsTask76_automate_collaborator_invitations_from_gsheet as dtacifrgs
517
"""
6-
18+
import argparse
19+
import datetime
720
import logging
821
import subprocess
22+
import sys
923
from typing import List
1024

11-
# Install pygithub.
12-
subprocess.run(
13-
["sudo", "/venv/bin/pip", "install", "--quiet", " pygithub"], check=True
14-
)
15-
import helpers.hgoogle_file_api as hgofiapi
16-
from github import Github
25+
# Install required packages and configure.
26+
packages = [
27+
"pygithub",
28+
"google-api-python-client",
29+
"oauth2client",
30+
"gspread",
31+
"ratelimit",
32+
]
33+
for pkg in packages:
34+
subprocess.run(
35+
[sys.executable, "-m", "pip", "install", "--quiet", "--upgrade", pkg],
36+
check=True,
37+
)
1738

1839
_LOG = logging.getLogger(__name__)
1940

20-
# Globals.
21-
DRIVE_URL = "https://docs.google.com/spreadsheets/d/1Ez5uRvOgvDMkFc9c6mI21kscTKnpiCSh4UkUh_ifLIw/edit?gid=0#gid=0"
22-
GH_ACCESS_TOKEN = ""
23-
REPO_NAME = "tutorials"
24-
ORG_NAME = "causify-ai"
41+
import github
42+
import helpers.hgoogle_drive_api as hgodrapi
43+
import ratelimit
44+
45+
_INVITES_PER_WINDOW = 50
46+
_WINDOW_SECONDS = int(datetime.timedelta(hours=24).total_seconds())
2547

2648

2749
def extract_usernames_from_gsheet(gsheet_url: str) -> List[str]:
@@ -31,15 +53,31 @@ def extract_usernames_from_gsheet(gsheet_url: str) -> List[str]:
3153
:param gsheet_url: URL of the Google Sheet
3254
:return: github usernames
3355
"""
34-
credentials = hgofiapi.get_credentials(
56+
credentials = hgodrapi.get_credentials(
3557
service_key_path="/app/DATA605/google_secret.json"
3658
)
37-
df = hgofiapi.read_google_file(url, credentials=credentials)
38-
usernames = df["github_username"].tolist()
59+
df = hgodrapi.read_google_file(gsheet_url, credentials=credentials)
60+
usernames = [
61+
user for user in df["GitHub user"].tolist() if user and user.strip()
62+
]
3963
_LOG.info("Usernames = \n %s", usernames)
4064
return usernames
4165

4266

67+
@ratelimit.sleep_and_retry
68+
@ratelimit.limits(calls=_INVITES_PER_WINDOW, period=_WINDOW_SECONDS)
69+
def _invite(repo, username: str, *, permission: str = "write") -> None:
70+
"""
71+
Invite one user, limiting it to 50 invites/24h.
72+
73+
:param repo: path to repo
74+
:param username: username to add
75+
:param permission: type of permission
76+
"""
77+
repo.add_to_collaborators(username, permission=permission)
78+
_LOG.info("Invitation sent to %s", username)
79+
80+
4381
def send_invitations(
4482
usernames: List[str],
4583
gh_access_token: str,
@@ -54,29 +92,53 @@ def send_invitations(
5492
:param repo_url: URL of the target repository
5593
"""
5694
# Initialize GitHub API.
57-
g = Github(gh_access_token)
95+
gh = github.Github(gh_access_token)
5896
# Get the repository.
59-
repo = g.get_repo(f"{org_name}/{repo_name}")
97+
repo = gh.get_repo(f"{org_name}/{repo_name}")
6098
# Send invitations.
6199
for username in usernames:
62100
try:
63-
# Send invitation by adding as collaborator.
64-
repo.add_to_collaborators(
65-
username,
66-
# TODO Krishna: Update the permission accordingly.
67-
permission="pull",
101+
_invite(repo, username)
102+
except github.GithubException as exc:
103+
_LOG.error(
104+
"Failed to invite %s: %s", username, exc.data.get("message")
68105
)
69-
_LOG.info(f"Invitation sent to {username}")
70-
except Exception as e:
71-
_LOG.error(f"Failed to invite {username}: {str(e)}")
72106

73107

74-
def main():
75-
# Extract usernames from Google Sheet.
76-
usernames = extract_usernames_from_gsheet(DRIVE_URL)
77-
# Send invitations.
78-
send_invitations(usernames, GH_ACCESS_TOKEN, REPO_NAME, ORG_NAME)
108+
def _parse() -> argparse.Namespace:
109+
parser = argparse.ArgumentParser(
110+
description="Invite GitHub collaborators from a Google Sheet, respecting the 50‑per‑day limit.", # noqa: E501
111+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
112+
)
113+
parser.add_argument(
114+
"--drive_url",
115+
required=True,
116+
help="Google‑Sheet URL with a 'GitHub user' column",
117+
)
118+
parser.add_argument(
119+
"--gh_token",
120+
required=True,
121+
help="GitHub personal‑access token (repo scope)",
122+
)
123+
parser.add_argument(
124+
"--repo_name", required=True, help="Target repository name (without org)"
125+
)
126+
parser.add_argument(
127+
"--org_name", required=True, help="GitHub organisation name"
128+
)
129+
parser.add_argument(
130+
"--log_level", type=int, default=logging.INFO, help="Logging verbosity"
131+
)
132+
return parser.parse_args()
133+
134+
135+
def _main(args: argparse.Namespace) -> None:
136+
logging.basicConfig(
137+
level=args.log_level, format="%(asctime)s %(levelname)s %(message)s"
138+
)
139+
usernames = extract_usernames_from_gsheet(args.drive_url)
140+
send_invitations(usernames, args.gh_token, args.repo_name, args.org_name)
79141

80142

81143
if __name__ == "__main__":
82-
main()
144+
_main(_parse())
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import unittest.mock as mock
2+
from typing import List
3+
4+
import helpers.hunit_test as hunitest
5+
import pandas as pd
6+
7+
MODULE_PATH = "TutorialsTask76_automate_collaborator_invitations_from_gsheet"
8+
9+
10+
# #############################################################################
11+
# Test_extract_usernames_from_gsheet
12+
# #############################################################################
13+
14+
15+
class Test_extract_usernames_from_gsheet(hunitest.TestCase):
16+
"""
17+
Test that github usernames are correctly pulled.
18+
"""
19+
20+
def test1(self) -> None:
21+
# Mock and import.
22+
with (
23+
mock.patch(
24+
f"{MODULE_PATH}.hgodrapi.get_credentials", return_value="creds"
25+
),
26+
mock.patch(
27+
f"{MODULE_PATH}.hgodrapi.read_google_file",
28+
return_value=pd.DataFrame(
29+
{"GitHub user": ["alice", "bob", None, ""]}
30+
),
31+
),
32+
):
33+
mod = __import__(
34+
MODULE_PATH, fromlist=["extract_usernames_from_gsheet"]
35+
)
36+
extract = mod.extract_usernames_from_gsheet
37+
actual = extract("dummy_url")
38+
expected: List[str] = ["alice", "bob"]
39+
self.assertEqual(actual, expected)
40+
41+
42+
# #############################################################################
43+
# Test_send_invitations
44+
# #############################################################################
45+
46+
47+
class Test_send_invitations(hunitest.TestCase):
48+
"""
49+
Test that an invitation is sent once per user.
50+
"""
51+
52+
def test2(self) -> None:
53+
usernames = ["alice", "bob"]
54+
# Mock Github SDK, its repo, and our internal _invite helper.
55+
with mock.patch(f"{MODULE_PATH}.github.Github") as m_github:
56+
with mock.patch(f"{MODULE_PATH}._invite") as m_invite:
57+
dummy_repo = mock.Mock()
58+
m_github.return_value.get_repo.return_value = dummy_repo
59+
# Import after patches so they take effect.
60+
mod = __import__(MODULE_PATH, fromlist=["send_invitations"])
61+
send_invitations = mod.send_invitations
62+
send_invitations(usernames, "fake_token", "myrepo", "myorg")
63+
# Assert one invite call per username.
64+
calls = [mock.call(dummy_repo, u) for u in usernames]
65+
m_invite.assert_has_calls(calls, any_order=False)
66+
self.assertEqual(m_invite.call_count, len(usernames))

0 commit comments

Comments
 (0)