Skip to content

Commit fbaa302

Browse files
authored
Merge pull request #118 from github-community-projects/migrate-to-pygithub
refactor: migrate from github3-py to PyGithub
2 parents b93186d + 11bdd75 commit fbaa302

20 files changed

Lines changed: 325 additions & 369 deletions

.github/linters/.mypy.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[mypy]
22
disable_error_code = attr-defined, import-not-found
33

4-
[mypy-github3.*]
4+
[mypy-github.*]
55
ignore_missing_imports = True

auth.py

Lines changed: 35 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,6 @@
11
"""This is the module that contains functions related to authenticating to GitHub with a personal access token."""
22

3-
import github3
4-
import requests
5-
from requests.adapters import HTTPAdapter
6-
from urllib3.util.retry import Retry
7-
8-
# Retry strategy: 5 retries with exponential backoff for transient errors
9-
RETRY_STRATEGY = Retry(
10-
total=5,
11-
backoff_factor=1, # 1s, 2s, 4s, 8s, 16s
12-
status_forcelist=[429, 500, 502, 503, 504],
13-
raise_on_status=False,
14-
)
15-
REQUEST_TIMEOUT = 30 # seconds
16-
17-
18-
def _configure_session(github_connection: github3.GitHub) -> None:
19-
"""Mount retry adapter and set timeout on the github3 session."""
20-
session = getattr(github_connection, "session", None)
21-
if session is None:
22-
return
23-
adapter = HTTPAdapter(max_retries=RETRY_STRATEGY)
24-
session.mount("https://", adapter)
25-
session.mount("http://", adapter)
26-
session.request = _timeout_wrapper(session.request, REQUEST_TIMEOUT) # type: ignore[method-assign]
27-
28-
29-
def _timeout_wrapper(original_request, default_timeout):
30-
"""Wrap a session.request method to inject a default timeout."""
31-
32-
def wrapper(*args, **kwargs):
33-
kwargs.setdefault("timeout", default_timeout)
34-
return original_request(*args, **kwargs)
35-
36-
return wrapper
3+
from github import Auth, Github, GithubIntegration
374

385

396
def auth_to_github(
@@ -43,7 +10,7 @@ def auth_to_github(
4310
gh_app_private_key_bytes: bytes,
4411
ghe: str,
4512
gh_app_enterprise_only: bool,
46-
) -> github3.GitHub:
13+
) -> Github:
4714
"""
4815
Connect to GitHub.com or GitHub Enterprise, depending on env variables.
4916
@@ -56,30 +23,34 @@ def auth_to_github(
5623
gh_app_enterprise_only (bool): Set this to true if the GH APP is created on GHE and needs to communicate with GHE api only
5724
5825
Returns:
59-
github3.GitHub: the GitHub connection object
26+
Github: the GitHub connection object
6027
"""
28+
ghe = ghe.rstrip("/")
29+
6130
if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id:
31+
app_auth = Auth.AppAuth(int(gh_app_id), gh_app_private_key_bytes.decode())
32+
installation_auth = app_auth.get_installation_auth(int(gh_app_installation_id))
6233
if ghe and gh_app_enterprise_only:
63-
gh = github3.github.GitHubEnterprise(url=ghe)
34+
github_connection = Github(
35+
base_url=f"{ghe}/api/v3",
36+
auth=installation_auth,
37+
)
6438
else:
65-
gh = github3.github.GitHub()
66-
gh.login_as_app_installation(
67-
gh_app_private_key_bytes, str(gh_app_id), gh_app_installation_id
68-
)
69-
github_connection = gh
39+
github_connection = Github(auth=installation_auth)
7040
elif ghe and token:
71-
github_connection = github3.github.GitHubEnterprise(url=ghe, token=token)
41+
github_connection = Github(
42+
base_url=f"{ghe}/api/v3",
43+
auth=Auth.Token(token),
44+
)
7245
elif token:
73-
github_connection = github3.login(token=token)
46+
github_connection = Github(auth=Auth.Token(token))
7447
else:
7548
raise ValueError(
76-
"GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set"
49+
"GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, "
50+
"GH_APP_PRIVATE_KEY] environment variables are not set"
7751
)
7852

79-
if not github_connection:
80-
raise ValueError("Unable to authenticate to GitHub")
81-
_configure_session(github_connection)
82-
return github_connection # type: ignore
53+
return github_connection
8354

8455

8556
def get_github_app_installation_token(
@@ -99,31 +70,32 @@ def get_github_app_installation_token(
9970
gh_app_installation_id (str): the GitHub App Installation ID
10071
10172
Returns:
102-
str: the GitHub App token
73+
str | None: the GitHub App token, or None if the request fails
10374
"""
104-
jwt_headers = github3.apps.create_jwt_headers(gh_app_private_key_bytes, gh_app_id)
105-
api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com"
106-
url = f"{api_endpoint}/app/installations/{gh_app_installation_id}/access_tokens"
107-
10875
try:
109-
response = requests.post(url, headers=jwt_headers, json=None, timeout=5)
110-
response.raise_for_status()
111-
except requests.exceptions.RequestException as e:
76+
ghe = ghe.rstrip("/")
77+
app_auth = Auth.AppAuth(int(gh_app_id), gh_app_private_key_bytes.decode())
78+
if ghe:
79+
gi = GithubIntegration(auth=app_auth, base_url=f"{ghe}/api/v3")
80+
else:
81+
gi = GithubIntegration(auth=app_auth)
82+
installation_token = gi.get_access_token(int(gh_app_installation_id))
83+
return installation_token.token
84+
except Exception as e: # pylint: disable=broad-exception-caught
11285
print(f"Request failed: {e}")
11386
return None
114-
return response.json().get("token")
11587

11688

11789
def get_team_members(
118-
github_connection: github3.GitHub,
90+
github_connection: Github,
11991
org: str,
12092
team_slug: str,
12193
) -> list[str]:
12294
"""
12395
Fetch the members of a GitHub team by slug.
12496
12597
Args:
126-
github_connection: Authenticated github3 connection.
98+
github_connection: Authenticated GitHub connection.
12799
org: The organization that owns the team.
128100
team_slug: The team slug (e.g., "nux-reviewers").
129101
@@ -132,21 +104,20 @@ def get_team_members(
132104
Returns an empty list if the team is not found or an error occurs.
133105
"""
134106
try:
135-
organization = github_connection.organization(org)
107+
organization = github_connection.get_organization(org)
136108
if not organization:
137109
print(f" ⚠️ Organization '{org}' not found, skipping team '{team_slug}'")
138110
return []
139111

140-
# team_by_name accepts a slug despite its name (hits /orgs/{org}/teams/{slug})
141-
team = organization.team_by_name(team_slug)
112+
team = organization.get_team_by_slug(team_slug)
142113
if not team:
143114
print(
144115
f" ⚠️ Team '{team_slug}' not found in '{org}', "
145116
"skipping (check token permissions: read:org)"
146117
)
147118
return []
148119

149-
members = [m.login for m in team.members()]
120+
members = [m.login for m in team.get_members()]
150121
print(f" Resolved team {org}/{team_slug}: {len(members)} member(s)")
151122
return members
152123
except Exception as e: # pylint: disable=broad-except

conflict_detector.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,9 @@ def verify_conflict(
156156
when VERIFY_CONFLICTS=true.
157157
"""
158158
try:
159-
repo = github_connection.repository(owner, repo_name) # type: ignore[union-attr]
160-
pr_a = repo.pull_request(conflict.pr_a.number)
161-
pr_b = repo.pull_request(conflict.pr_b.number)
159+
repo = github_connection.get_repo(f"{owner}/{repo_name}") # type: ignore[union-attr]
160+
pr_a = repo.get_pull(conflict.pr_a.number)
161+
pr_b = repo.get_pull(conflict.pr_b.number)
162162

163163
# If either PR is not mergeable on its own, they likely conflict
164164
if pr_a.mergeable is False or pr_b.mergeable is False:

issue_writer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from conflict_detector import ConflictCluster, cluster_conflicts
99

1010
if TYPE_CHECKING:
11-
from github3.repos.repo import Repository
11+
from github.Repository import Repository
1212

1313
logger = logging.getLogger(__name__)
1414

@@ -28,7 +28,7 @@ def create_or_update_issue(
2828
"""Create or update an issue in the repository with conflict information.
2929
3030
Args:
31-
repo: A github3.py repository object.
31+
repo: A PyGithub repository object.
3232
conflicts: List of ConflictResult objects for this repository.
3333
report_title: Title for the issue.
3434
dry_run: If True, log what would happen but make no API calls.
@@ -67,7 +67,7 @@ def create_or_update_issue(
6767

6868
def _find_existing_issue(repo: Repository, title: str):
6969
"""Search open issues for one matching the given title and hidden tag."""
70-
for issue in repo.issues(state="open"):
70+
for issue in repo.get_issues(state="open"):
7171
if issue.title == title and ISSUE_TAG in (issue.body or ""):
7272
return issue
7373
return None

pr_comment.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,7 @@ def post_pr_comments(
7070

7171
for repo_name in all_repo_names:
7272
conflicts = all_conflicts_by_repo.get(repo_name, [])
73-
owner, repo = repo_name.split("/")
74-
repo_obj = github_connection.repository(owner, repo)
73+
repo_obj = github_connection.get_repo(repo_name)
7574

7675
# Group active conflicts by PR
7776
pr_conflicts = group_conflicts_by_pr(conflicts) if conflicts else {}
@@ -163,8 +162,8 @@ def _find_existing_comments(repo: Any, pr_number: int) -> list[Any]:
163162
List of comment objects with the bot signature (may be empty).
164163
"""
165164
try:
166-
pr = repo.pull_request(pr_number)
167-
return [c for c in pr.issue_comments() if COMMENT_SIGNATURE in c.body]
165+
pr = repo.get_pull(pr_number)
166+
return [c for c in pr.get_issue_comments() if COMMENT_SIGNATURE in c.body]
168167
except Exception as e: # pylint: disable=broad-except
169168
logger.warning("Failed to check existing comments on PR #%s: %s", pr_number, e)
170169
return []
@@ -222,8 +221,8 @@ def _post_comment(repo, pr_number: int, body: str) -> bool:
222221
True if successful, False otherwise
223222
"""
224223
try:
225-
pr = repo.pull_request(pr_number)
226-
pr.create_comment(body)
224+
pr = repo.get_pull(pr_number)
225+
pr.create_issue_comment(body=body)
227226
logger.info("Posted comment to PR #%s", pr_number)
228227
return True
229228
except Exception as e: # pylint: disable=broad-except

pr_conflict_detector.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,7 @@ def main():
217217
print("Report issue creation disabled (ENABLE_REPORT_ISSUES=false)")
218218
elif not env_vars.dry_run:
219219
for repo_full_name, conflicts in all_conflicts.items():
220-
owner, rname = repo_full_name.split("/")
221-
repo_obj = github_connection.repository(owner, rname)
220+
repo_obj = github_connection.get_repo(repo_full_name)
222221
issue_url = create_or_update_issue(
223222
repo_obj, conflicts, env_vars.report_title, env_vars.dry_run
224223
)
@@ -273,19 +272,18 @@ def get_repos_iterator(github_connection, env_vars):
273272
"""Get an iterator of repositories to scan.
274273
275274
Args:
276-
github_connection: Authenticated github3 connection.
275+
github_connection: Authenticated PyGithub connection.
277276
env_vars: Environment variables dataclass.
278277
279278
Returns:
280-
Iterator of github3 repository objects.
279+
Iterator of PyGithub repository objects.
281280
"""
282281
if env_vars.organization and not env_vars.repository_list:
283-
return github_connection.organization(env_vars.organization).repositories()
282+
return github_connection.get_organization(env_vars.organization).get_repos()
284283

285284
repos = []
286285
for repo_full_name in env_vars.repository_list:
287-
owner, repo_name = repo_full_name.split("/")
288-
repos.append(github_connection.repository(owner, repo_name))
286+
repos.append(github_connection.get_repo(repo_full_name))
289287
return repos
290288

291289

pr_data.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,17 @@ def parse_patch_line_ranges(patch: str | None) -> list[tuple[int, int]]:
5959

6060
def get_open_prs(repo: object, include_drafts: bool = True) -> list[PullRequestData]:
6161
"""
62-
Fetch all open PRs from a github3-py repository object.
62+
Fetch all open PRs from a PyGithub repository object.
6363
6464
Args:
65-
repo: A github3-py repository object.
65+
repo: A PyGithub repository object.
6666
include_drafts: If False, filter out draft PRs.
6767
6868
Returns:
6969
A list of PullRequestData objects (without changed files populated).
7070
"""
7171
prs: list[PullRequestData] = []
72-
for pr in repo.pull_requests(state="open"): # type: ignore[attr-defined]
72+
for pr in repo.get_pulls(state="open"): # type: ignore[attr-defined]
7373
is_draft = getattr(pr, "draft", False) or False
7474
if not include_drafts and is_draft:
7575
continue
@@ -97,12 +97,12 @@ def get_pr_changed_files( # pylint: disable=unused-argument
9797
"""
9898
Fetch the list of changed files for a given pull request.
9999
100-
Uses the github3-py pull request's files() method, then parses
100+
Uses the PyGithub pull request's get_files() method, then parses
101101
each file's patch to extract modified line ranges.
102102
103103
Args:
104-
pull_request: A github3-py ShortPullRequest or PullRequest object.
105-
github_connection: The github3-py GitHub connection (unused but kept
104+
pull_request: A PyGithub ShortPullRequest or PullRequest object.
105+
github_connection: The PyGithub GitHub connection (unused but kept
106106
for API consistency with other OSPO actions).
107107
owner: The repository owner.
108108
repo_name: The repository name.
@@ -111,7 +111,7 @@ def get_pr_changed_files( # pylint: disable=unused-argument
111111
A list of ChangedFile objects.
112112
"""
113113
changed_files: list[ChangedFile] = []
114-
for f in pull_request.files(): # type: ignore[attr-defined]
114+
for f in pull_request.get_files(): # type: ignore[attr-defined]
115115
patch = getattr(f, "patch", None)
116116
changed_files.append(
117117
ChangedFile(
@@ -137,9 +137,9 @@ def fetch_all_pr_data(
137137
Fetch all open PRs and their changed files from a repository.
138138
139139
Args:
140-
repo: A github3-py repository object.
140+
repo: A PyGithub repository object.
141141
include_drafts: If False, filter out draft PRs.
142-
github_connection: The github3-py GitHub connection.
142+
github_connection: The PyGithub GitHub connection.
143143
owner: The repository owner.
144144
repo_name: The repository name.
145145
filter_authors: If provided, only fetch file changes for PRs authored
@@ -171,8 +171,8 @@ def fetch_all_pr_data(
171171
print(f" Progress: {i + 1}/{total} PRs processed")
172172

173173
try:
174-
# Re-fetch the full PR object to call files()
175-
full_pr = repo.pull_request(pr_data.number) # type: ignore[attr-defined]
174+
# Re-fetch the full PR object to call get_files()
175+
full_pr = repo.get_pull(pr_data.number) # type: ignore[attr-defined]
176176
pr_data.changed_files = get_pr_changed_files(
177177
full_pr, github_connection, owner, repo_name
178178
)

pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ version = "1.0.0"
44
description = "Detect pull request conflicts and notify"
55
requires-python = ">=3.11"
66
dependencies = [
7-
"cryptography==49.0.0",
8-
"github3-py==4.0.1",
9-
"pyjwt==2.13.0",
7+
"PyGithub>=2.6.0",
108
"python-dotenv==1.2.2",
119
"requests==2.34.2",
1210
]

0 commit comments

Comments
 (0)