From 4c18311c2c55a46315b5699dd979e0d34500c3c8 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Wed, 1 Jul 2026 22:14:02 -0500 Subject: [PATCH 1/5] refactor: migrate from github3.py to PyGithub ## What/Why Replace github3.py (pinned at 4.0.1) with PyGithub (>=2.6.0) for GitHub API interactions. PyGithub is more actively maintained and provides first-class support for base_url, app auth, and installation tokens. ## Proof it works 176 tests passed, 32 subtests passed, 100% code coverage via `make test`. ## Risk + AI role Medium -- touches all GitHub API interaction code (auth, repo operations, exception handling). All changes AI-generated using Claude Opus 4.6 with human review. ## Review focus 1. Auth flow in auth.py -- AppAuth/installation auth two-step pattern replaces login_as_app_installation 2. dependabot_file.py -- directory_contents (tuples) merged into get_contents (objects with .name), verify the or [] pattern in test mocks handles the combined code path correctly Signed-off-by: jmeridth --- .github/linters/.mypy.ini | 2 +- auth.py | 52 ++++---- dependabot_file.py | 26 ++-- evergreen.py | 42 +++--- exceptions.py | 22 ++-- pyproject.toml | 2 +- test_auth.py | 265 ++++++++++++++++++++++---------------- test_dependabot_file.py | 159 +++++++++++++---------- test_env.py | 2 +- test_evergreen.py | 112 ++++++++-------- test_exceptions.py | 85 +++++------- uv.lock | 100 +++++++------- 12 files changed, 453 insertions(+), 416 deletions(-) diff --git a/.github/linters/.mypy.ini b/.github/linters/.mypy.ini index f0d4703..3e79b06 100644 --- a/.github/linters/.mypy.ini +++ b/.github/linters/.mypy.ini @@ -1,5 +1,5 @@ [mypy] disable_error_code = attr-defined, import-not-found -[mypy-github3.*] +[mypy-github.*] ignore_missing_imports = True diff --git a/auth.py b/auth.py index f4da292..91252de 100644 --- a/auth.py +++ b/auth.py @@ -1,8 +1,7 @@ """This is the module that contains functions related to authenticating to GitHub with a personal access token.""" import env -import github3 -import requests +from github import Auth, Github, GithubIntegration def auth_to_github( @@ -13,7 +12,7 @@ def auth_to_github( ghe: str, gh_app_enterprise_only: bool, ghe_api_url: str = "", -) -> github3.GitHub: +) -> Github: """ Connect to GitHub.com or GitHub Enterprise, depending on env variables. @@ -27,33 +26,27 @@ def auth_to_github( ghe_api_url (str): the full GitHub Enterprise API endpoint URL override Returns: - github3.GitHub: the GitHub connection object + Github: the GitHub connection object """ if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id: + app_auth = Auth.AppAuth(int(gh_app_id), gh_app_private_key_bytes.decode()) + installation_auth = app_auth.get_installation_auth(int(gh_app_installation_id)) if ghe and gh_app_enterprise_only: - gh = github3.github.GitHubEnterprise(url=ghe) - if ghe_api_url: - gh.session.base_url = ghe_api_url + base_url = env.get_api_endpoint(ghe, ghe_api_url) + github_connection = Github(base_url=base_url, auth=installation_auth) else: - gh = github3.github.GitHub() - gh.login_as_app_installation( - gh_app_private_key_bytes, str(gh_app_id), gh_app_installation_id - ) - github_connection = gh + github_connection = Github(auth=installation_auth) elif ghe and token: - github_connection = github3.github.GitHubEnterprise(url=ghe, token=token) - if ghe_api_url: - github_connection.session.base_url = ghe_api_url + base_url = env.get_api_endpoint(ghe, ghe_api_url) + github_connection = Github(base_url=base_url, auth=Auth.Token(token)) elif token: - github_connection = github3.login(token=token) + github_connection = Github(auth=Auth.Token(token)) else: raise ValueError( "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set" ) - if not github_connection: - raise ValueError("Unable to authenticate to GitHub") - return github_connection # type: ignore + return github_connection def get_github_app_installation_token( @@ -77,16 +70,17 @@ def get_github_app_installation_token( Returns: str: the GitHub App token """ - jwt_headers = github3.apps.create_jwt_headers( - gh_app_private_key_bytes, str(gh_app_id) - ) - api_endpoint = env.get_api_endpoint(ghe, ghe_api_url) - url = f"{api_endpoint}/app/installations/{gh_app_installation_id}/access_tokens" - + if not gh_app_id or not gh_app_installation_id: + return None try: - response = requests.post(url, headers=jwt_headers, json=None, timeout=5) - response.raise_for_status() - except requests.exceptions.RequestException as e: + app_auth = Auth.AppAuth(int(gh_app_id), gh_app_private_key_bytes.decode()) + if ghe: + base_url = env.get_api_endpoint(ghe, ghe_api_url) + gi = GithubIntegration(auth=app_auth, base_url=base_url) + else: + gi = GithubIntegration(auth=app_auth) + installation_token = gi.get_access_token(int(gh_app_installation_id)) + return installation_token.token + except Exception as e: # pylint: disable=broad-exception-caught print(f"Request failed: {e}") return None - return response.json().get("token") diff --git a/dependabot_file.py b/dependabot_file.py index af5382c..f69251a 100644 --- a/dependabot_file.py +++ b/dependabot_file.py @@ -5,9 +5,9 @@ import io from collections.abc import Mapping -import github3 import ruamel.yaml from exceptions import OptionalFileNotFoundError, check_optional_file +from github import UnknownObjectException from ruamel.yaml.scalarstring import SingleQuotedScalarString # Define data structure for dependabot.yaml @@ -349,8 +349,8 @@ def build_dependabot_file( # detect package managers with variable file names if "terraform" not in exempt_ecosystems_list: try: - for file in repo.directory_contents("/"): - if file[0].endswith(".tf"): + for file in repo.get_contents("/"): + if file.name.endswith(".tf"): package_managers_found["terraform"] = True make_dependabot_config( "terraform", @@ -363,14 +363,12 @@ def build_dependabot_file( cooldown, ) break - except github3.exceptions.NotFoundError: - # The file does not exist and is not required, - # so we should continue to the next one rather than raising error or logging + except UnknownObjectException: pass if "github-actions" not in exempt_ecosystems_list: try: - for file in repo.directory_contents(".github/workflows"): - if file[0].endswith(".yml") or file[0].endswith(".yaml"): + for file in repo.get_contents(".github/workflows"): + if file.name.endswith(".yml") or file.name.endswith(".yaml"): package_managers_found["github-actions"] = True make_dependabot_config( "github-actions", @@ -383,14 +381,12 @@ def build_dependabot_file( cooldown, ) break - except github3.exceptions.NotFoundError: - # The file does not exist and is not required, - # so we should continue to the next one rather than raising error or logging + except UnknownObjectException: pass if "devcontainers" not in exempt_ecosystems_list: try: - for file in repo.directory_contents(".devcontainer"): - if file[0] == "devcontainer.json": + for file in repo.get_contents(".devcontainer"): + if file.name == "devcontainer.json": package_managers_found["devcontainers"] = True make_dependabot_config( "devcontainers", @@ -403,9 +399,7 @@ def build_dependabot_file( cooldown, ) break - except github3.exceptions.NotFoundError: - # The file does not exist and is not required, - # so we should continue to the next one rather than raising error or logging + except UnknownObjectException: pass if any(package_managers_found.values()): diff --git a/evergreen.py b/evergreen.py index 5c22e6f..79ce97e 100644 --- a/evergreen.py +++ b/evergreen.py @@ -7,11 +7,11 @@ import auth import env -import github3 import requests import ruamel.yaml from dependabot_file import build_dependabot_file, validate_cooldown_config from exceptions import OptionalFileNotFoundError, check_optional_file +from github import UnknownObjectException def main(): # pragma: no cover @@ -290,7 +290,7 @@ def main(): # pragma: no cover print( f"\tLinked pull request to project {project_global_id}" ) - except github3.exceptions.NotFoundError: + except UnknownObjectException: print("\tFailed to create pull request. Check write permissions.") continue @@ -332,11 +332,11 @@ def check_existing_config(repo, filename): repository and return the existing config if it does Args: - repo (github3.repos.repo.Repository): The repository to check + repo: The repository to check filename (str): The configuration filename to check Returns: - github3.repos.contents.Contents | None: The existing config if it exists, otherwise None + The existing config if it exists, otherwise None """ existing_config = None try: @@ -376,36 +376,33 @@ def get_repos_iterator( # Use GitHub search API if REPOSITORY_SEARCH_QUERY is set if search_query: # Return repositories matching the search query - repos = [] - # Search results need to be converted to a list of repositories since they are returned as a search iterator - for repo in github_connection.search_repositories(search_query): - repos.append(repo.repository) + repos = list(github_connection.search_repositories(search_query)) return repos repos = [] # Default behavior: list all organization/team repositories or specific repository list if organization and not repository_list and not team_name: - repos = github_connection.organization(organization).repositories() + repos = github_connection.get_organization(organization).get_repos() elif team_name and organization: # Get the repositories from the team - team = github_connection.organization(organization).team_by_name(team_name) + team = github_connection.get_organization(organization).get_team_by_slug( + team_name + ) if team.repos_count == 0: print(f"Team {team_name} has no repositories") sys.exit(1) - repos = team.repositories() + repos = team.get_repos() else: # Get the repositories from the repository_list for repo in repository_list: - repos.append( - github_connection.repository(repo.split("/")[0], repo.split("/")[1]) - ) + repos.append(github_connection.get_repo(repo)) return repos def check_pending_pulls_for_duplicates(title, repo) -> bool: """Check if there are any open pull requests for dependabot and return the bool skip""" - pull_requests = repo.pull_requests(state="open") + pull_requests = repo.get_pulls(state="open") skip = False for pull_request in pull_requests: if pull_request.title.startswith(title): @@ -417,7 +414,7 @@ def check_pending_pulls_for_duplicates(title, repo) -> bool: def check_pending_issues_for_duplicates(title, repo) -> bool: """Check if there are any open issues for dependabot and return the bool skip""" - issues = repo.issues(state="open") + issues = repo.get_issues(state="open") skip = False for issue in issues: if issue.title.startswith(title): @@ -439,21 +436,22 @@ def commit_changes( """Commit the changes to the repo and open a pull request and return the pull request object""" default_branch = repo.default_branch # Get latest commit sha from default branch - default_branch_commit = repo.ref("heads/" + default_branch).object.sha - front_matter = "refs/heads/" + default_branch_commit = repo.get_git_ref("heads/" + default_branch).object.sha branch_name = "dependabot-" + str(uuid.uuid4()) - repo.create_ref(front_matter + branch_name, default_branch_commit) + repo.create_git_ref(ref="refs/heads/" + branch_name, sha=default_branch_commit) if existing_config: - repo.file_contents(dependabot_filename).update( + repo.update_file( + path=dependabot_filename, message=message, - content=dependabot_file.encode(), # Convert to bytes object + content=dependabot_file, + sha=existing_config.sha, branch=branch_name, ) else: repo.create_file( path=dependabot_filename, message=message, - content=dependabot_file.encode(), # Convert to bytes object + content=dependabot_file, branch=branch_name, ) diff --git a/exceptions.py b/exceptions.py index 112d5a9..3a597ff 100644 --- a/exceptions.py +++ b/exceptions.py @@ -1,18 +1,20 @@ """Custom exceptions for the evergreen application.""" -import github3.exceptions +from github import UnknownObjectException -class OptionalFileNotFoundError(github3.exceptions.NotFoundError): +class OptionalFileNotFoundError(UnknownObjectException): """Exception raised when an optional file is not found. - This exception inherits from github3.exceptions.NotFoundError but provides + This exception inherits from github.UnknownObjectException but provides a more explicit name for cases where missing files are expected and should not be treated as errors. This is typically used for optional configuration files or dependency files that may not exist in all repositories. Args: - resp: The HTTP response object from the failed request + status: The HTTP status code + data: The response data + headers: The response headers """ @@ -35,15 +37,13 @@ def check_optional_file(repo, filename): Other exceptions: For unexpected errors (permissions, network issues, etc.) """ try: - file_contents = repo.file_contents(filename) - # Handle both real file contents objects and test mocks that return boolean + file_contents = repo.get_contents(filename) if hasattr(file_contents, "size"): - # Real file contents object if file_contents.size > 0: return file_contents return None - # Test mock or other truthy value return file_contents if file_contents else None - except github3.exceptions.NotFoundError as e: - # Re-raise as our more specific exception type for better semantic clarity - raise OptionalFileNotFoundError(resp=e.response) from e + except UnknownObjectException as e: + raise OptionalFileNotFoundError( + status=e.status, data=e.data, headers=e.headers + ) from e diff --git a/pyproject.toml b/pyproject.toml index 9b0a1a4..2f1650f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" description = "GitHub Action that enables Dependabot for all repositories in a GitHub organization." requires-python = ">=3.11" dependencies = [ - "github3-py==4.0.1", + "PyGithub>=2.6.0", "python-dotenv==1.2.2", "requests==2.34.2", "ruamel-yaml==0.19.1", diff --git a/test_auth.py b/test_auth.py index 5e9017b..d430e7c 100644 --- a/test_auth.py +++ b/test_auth.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch import auth -import requests class TestAuth(unittest.TestCase): @@ -12,16 +11,17 @@ class TestAuth(unittest.TestCase): Test case for the auth module. """ - @patch("github3.login") - def test_auth_to_github_with_token(self, mock_login): + @patch("auth.Github") + def test_auth_to_github_with_token(self, mock_github_cls): """ Test the auth_to_github function when the token is provided. """ - mock_login.return_value = "Authenticated to GitHub.com" + mock_github_cls.return_value = MagicMock() result = auth.auth_to_github("token", "", "", b"", "", False) - self.assertEqual(result, "Authenticated to GitHub.com") + mock_github_cls.assert_called_once() + self.assertEqual(result, mock_github_cls.return_value) def test_auth_to_github_without_token(self): """ @@ -36,26 +36,30 @@ def test_auth_to_github_without_token(self): "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set", ) - @patch("github3.github.GitHubEnterprise") - def test_auth_to_github_with_ghe(self, mock_ghe): + @patch("auth.Github") + def test_auth_to_github_with_ghe(self, mock_github_cls): """ Test the auth_to_github function when the GitHub Enterprise URL is provided. """ - mock_ghe.return_value = "Authenticated to GitHub Enterprise" + mock_github_cls.return_value = MagicMock() result = auth.auth_to_github( "token", "", "", b"", "https://github.example.com", False ) - self.assertEqual(result, "Authenticated to GitHub Enterprise") + mock_github_cls.assert_called_once() + call_kwargs = mock_github_cls.call_args + self.assertEqual( + call_kwargs.kwargs["base_url"], + "https://github.example.com/api/v3", + ) + self.assertEqual(result, mock_github_cls.return_value) - @patch("github3.github.GitHubEnterprise") - def test_auth_to_github_with_ghe_and_ghe_api_url(self, mock_ghe): + @patch("auth.Github") + def test_auth_to_github_with_ghe_and_ghe_api_url(self, mock_github_cls): """ - Test that session.base_url is overridden when ghe_api_url is provided with token auth. + Test that base_url uses the custom API URL when ghe_api_url is provided with token auth. """ - mock_instance = MagicMock() - mock_instance.session = MagicMock() - mock_ghe.return_value = mock_instance + mock_github_cls.return_value = MagicMock() result = auth.auth_to_github( "token", "", @@ -66,31 +70,57 @@ def test_auth_to_github_with_ghe_and_ghe_api_url(self, mock_ghe): ghe_api_url="https://api.example.ghe.com", ) - self.assertEqual(result, mock_instance) - self.assertEqual(mock_instance.session.base_url, "https://api.example.ghe.com") + mock_github_cls.assert_called_once() + call_kwargs = mock_github_cls.call_args + self.assertEqual( + call_kwargs.kwargs["base_url"], + "https://api.example.ghe.com", + ) + self.assertEqual(result, mock_github_cls.return_value) - @patch("github3.github.GitHubEnterprise") - def test_auth_to_github_with_ghe_and_ghe_app(self, mock_ghe): + @patch("auth.Github") + @patch("auth.Auth.AppAuth") + def test_auth_to_github_with_ghe_and_ghe_app( + self, mock_app_auth_cls, mock_github_cls + ): """ Test the auth_to_github function when the GitHub Enterprise URL is provided and the app was created in GitHub Enterprise URL. """ - mock = mock_ghe.return_value - mock.login_as_app_installation = MagicMock(return_value=True) + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_installation_auth = MagicMock() + mock_app_auth.get_installation_auth.return_value = mock_installation_auth + mock_github_cls.return_value = MagicMock() + result = auth.auth_to_github( "", 123, 456, b"123", "https://github.example.com", True ) - mock.login_as_app_installation.assert_called_once_with(b"123", "123", 456) - self.assertEqual(result, mock) - @patch("github3.github.GitHubEnterprise") - def test_auth_to_github_with_ghe_app_and_ghe_api_url(self, mock_ghe): + mock_app_auth_cls.assert_called_once_with(123, "123") + mock_app_auth.get_installation_auth.assert_called_once_with(456) + mock_github_cls.assert_called_once() + call_kwargs = mock_github_cls.call_args + self.assertEqual( + call_kwargs.kwargs["base_url"], + "https://github.example.com/api/v3", + ) + self.assertEqual(call_kwargs.kwargs["auth"], mock_installation_auth) + self.assertEqual(result, mock_github_cls.return_value) + + @patch("auth.Github") + @patch("auth.Auth.AppAuth") + def test_auth_to_github_with_ghe_app_and_ghe_api_url( + self, mock_app_auth_cls, mock_github_cls + ): """ - Test that session.base_url is overridden when ghe_api_url is provided with app auth. + Test that base_url uses the custom API URL when ghe_api_url is provided with app auth. """ - mock_instance = MagicMock() - mock_instance.session = MagicMock() - mock_instance.login_as_app_installation = MagicMock(return_value=True) - mock_ghe.return_value = mock_instance + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_installation_auth = MagicMock() + mock_app_auth.get_installation_auth.return_value = mock_installation_auth + mock_github_cls.return_value = MagicMock() + result = auth.auth_to_github( "", 123, @@ -100,36 +130,57 @@ def test_auth_to_github_with_ghe_app_and_ghe_api_url(self, mock_ghe): True, ghe_api_url="https://api.example.ghe.com", ) - mock_instance.login_as_app_installation.assert_called_once_with( - b"123", "123", 456 + + mock_app_auth_cls.assert_called_once_with(123, "123") + mock_app_auth.get_installation_auth.assert_called_once_with(456) + mock_github_cls.assert_called_once() + call_kwargs = mock_github_cls.call_args + self.assertEqual( + call_kwargs.kwargs["base_url"], + "https://api.example.ghe.com", ) - self.assertEqual(result, mock_instance) - self.assertEqual(mock_instance.session.base_url, "https://api.example.ghe.com") + self.assertEqual(result, mock_github_cls.return_value) - @patch("github3.github.GitHub") - def test_auth_to_github_with_app(self, mock_gh): + @patch("auth.Github") + @patch("auth.Auth.AppAuth") + def test_auth_to_github_with_app(self, mock_app_auth_cls, mock_github_cls): """ Test the auth_to_github function when app credentials are provided """ - mock = mock_gh.return_value - mock.login_as_app_installation = MagicMock(return_value=True) + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_installation_auth = MagicMock() + mock_app_auth.get_installation_auth.return_value = mock_installation_auth + mock_github_cls.return_value = MagicMock() + result = auth.auth_to_github( "", 123, 456, b"123", "https://github.example.com", False ) - mock.login_as_app_installation.assert_called_once_with(b"123", "123", 456) - self.assertEqual(result, mock) - @patch("github3.github.GitHub") + mock_app_auth_cls.assert_called_once_with(123, "123") + mock_app_auth.get_installation_auth.assert_called_once_with(456) + mock_github_cls.assert_called_once() + call_kwargs = mock_github_cls.call_args + self.assertNotIn("base_url", call_kwargs.kwargs) + self.assertEqual(call_kwargs.kwargs["auth"], mock_installation_auth) + self.assertEqual(result, mock_github_cls.return_value) + + @patch("auth.Github") + @patch("auth.Auth.AppAuth") def test_auth_to_github_with_app_enterprise_only_false_ignores_ghe_api_url( - self, mock_gh + self, mock_app_auth_cls, mock_github_cls ): """ - Test that ghe_api_url does not override session.base_url when + Test that ghe_api_url does not override base_url when gh_app_enterprise_only is False, since the app authenticates against github.com. The ghe_api_url still applies to direct REST/GraphQL calls via get_api_endpoint(). """ - mock = mock_gh.return_value - mock.login_as_app_installation = MagicMock(return_value=True) + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_installation_auth = MagicMock() + mock_app_auth.get_installation_auth.return_value = mock_installation_auth + mock_github_cls.return_value = MagicMock() + result = auth.auth_to_github( "", 123, @@ -139,50 +190,55 @@ def test_auth_to_github_with_app_enterprise_only_false_ignores_ghe_api_url( False, ghe_api_url="https://api.example.ghe.com", ) - mock.login_as_app_installation.assert_called_once_with(b"123", "123", 456) - self.assertEqual(result, mock) - # session.base_url should NOT be overridden — app authenticates against github.com - self.assertNotEqual( - getattr(mock.session, "base_url", None), "https://api.example.ghe.com" - ) - @patch("github3.apps.create_jwt_headers", MagicMock(return_value="gh_token")) - @patch("requests.post") - def test_get_github_app_installation_token(self, mock_post): + mock_app_auth_cls.assert_called_once_with(123, "123") + mock_app_auth.get_installation_auth.assert_called_once_with(456) + mock_github_cls.assert_called_once() + call_kwargs = mock_github_cls.call_args + # base_url should NOT be set -- app authenticates against github.com + self.assertNotIn("base_url", call_kwargs.kwargs) + self.assertEqual(result, mock_github_cls.return_value) + + @patch("auth.GithubIntegration") + @patch("auth.Auth.AppAuth") + def test_get_github_app_installation_token(self, mock_app_auth_cls, mock_gi_cls): """ Test the get_github_app_installation_token function. """ dummy_token = "dummytoken" - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"token": dummy_token} - mock_post.return_value = mock_response + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_gi = MagicMock() + mock_gi_cls.return_value = mock_gi + mock_access_token = MagicMock() + mock_access_token.token = dummy_token + mock_gi.get_access_token.return_value = mock_access_token result = auth.get_github_app_installation_token( ghe="https://github.example.com", - gh_app_id="gh_app_id", + gh_app_id=12345, gh_app_private_key_bytes=b"gh_private_key", - gh_app_installation_id="gh_installation_id", + gh_app_installation_id=678910, ) self.assertEqual(result, dummy_token) - @patch( - "github3.apps.create_jwt_headers", - return_value={"Authorization": "Bearer gh_token"}, - ) - @patch("requests.post") - def test_get_github_app_installation_token_casts_int_app_id_to_str( - self, mock_post, mock_create_jwt + @patch("auth.GithubIntegration") + @patch("auth.Auth.AppAuth") + def test_get_github_app_installation_token_casts_int_app_id( + self, mock_app_auth_cls, mock_gi_cls ): """ - Test that get_github_app_installation_token casts an int gh_app_id to str - before passing it to create_jwt_headers (PyJWT requires iss to be a string). + Test that get_github_app_installation_token casts an int gh_app_id to int + and decodes private key bytes to str for Auth.AppAuth. """ - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"token": "dummytoken"} - mock_post.return_value = mock_response + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_gi = MagicMock() + mock_gi_cls.return_value = mock_gi + mock_access_token = MagicMock() + mock_access_token.token = "dummytoken" + mock_gi.get_access_token.return_value = mock_access_token auth.get_github_app_installation_token( ghe="", @@ -191,18 +247,23 @@ def test_get_github_app_installation_token_casts_int_app_id_to_str( gh_app_installation_id=678910, ) - mock_create_jwt.assert_called_once_with(b"private_key", "12345") + mock_app_auth_cls.assert_called_once_with(12345, "private_key") + mock_gi.get_access_token.assert_called_once_with(678910) - @patch("github3.apps.create_jwt_headers", MagicMock(return_value="gh_token")) - @patch("auth.requests.post") - def test_get_github_app_installation_token_request_failure(self, mock_post): + @patch("auth.GithubIntegration") + @patch("auth.Auth.AppAuth") + def test_get_github_app_installation_token_request_failure( + self, mock_app_auth_cls, mock_gi_cls + ): """ Test the get_github_app_installation_token function returns None when the request fails. """ - # Mock the post request to raise a RequestException - mock_post.side_effect = requests.exceptions.RequestException("Request failed") + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_gi = MagicMock() + mock_gi_cls.return_value = mock_gi + mock_gi.get_access_token.side_effect = Exception("Request failed") - # Call the function with test data result = auth.get_github_app_installation_token( ghe="https://api.github.com", gh_app_id=12345, @@ -210,20 +271,24 @@ def test_get_github_app_installation_token_request_failure(self, mock_post): gh_app_installation_id=678910, ) - # Assert that the result is None self.assertIsNone(result) - @patch("github3.apps.create_jwt_headers", MagicMock(return_value="gh_token")) - @patch("requests.post") - def test_get_github_app_installation_token_with_ghe_api_url(self, mock_post): + @patch("auth.GithubIntegration") + @patch("auth.Auth.AppAuth") + def test_get_github_app_installation_token_with_ghe_api_url( + self, mock_app_auth_cls, mock_gi_cls + ): """ Test that get_github_app_installation_token uses the custom API URL when provided. """ dummy_token = "dummytoken" - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"token": dummy_token} - mock_post.return_value = mock_response + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_gi = MagicMock() + mock_gi_cls.return_value = mock_gi + mock_access_token = MagicMock() + mock_access_token.token = dummy_token + mock_gi.get_access_token.return_value = mock_access_token result = auth.get_github_app_installation_token( ghe="https://github.example.com", @@ -234,27 +299,9 @@ def test_get_github_app_installation_token_with_ghe_api_url(self, mock_post): ) self.assertEqual(result, dummy_token) - mock_post.assert_called_once_with( - "https://api.example.ghe.com/app/installations/678910/access_tokens", - headers="gh_token", - json=None, - timeout=5, - ) - - @patch("github3.login") - def test_auth_to_github_invalid_credentials(self, mock_login): - """ - Test the auth_to_github function raises correct ValueError - when credentials are present but incorrect. - """ - mock_login.return_value = None - with self.assertRaises(ValueError) as context_manager: - auth.auth_to_github("not_a_valid_token", "", "", b"", "", False) - - the_exception = context_manager.exception - self.assertEqual( - str(the_exception), - "Unable to authenticate to GitHub", + mock_gi_cls.assert_called_once_with( + auth=mock_app_auth, + base_url="https://api.example.ghe.com", ) diff --git a/test_dependabot_file.py b/test_dependabot_file.py index 0a92119..218be0a 100644 --- a/test_dependabot_file.py +++ b/test_dependabot_file.py @@ -1,4 +1,4 @@ -# pylint: disable=too-many-public-methods +# pylint: disable=too-many-public-methods,too-many-lines """Tests for the dependabot_file.py functions.""" import base64 @@ -6,9 +6,9 @@ import unittest from unittest.mock import MagicMock, patch -import github3 import ruamel.yaml from dependabot_file import add_existing_ecosystem_to_exempt_list, build_dependabot_file +from github import UnknownObjectException yaml = ruamel.yaml.YAML() @@ -21,9 +21,9 @@ class TestDependabotFile(unittest.TestCase): def test_not_found_error(self): """Test that the dependabot.yml file is built correctly with no package manager""" repo = MagicMock() - response = MagicMock() - response.status_code = 404 - repo.file_contents.side_effect = github3.exceptions.NotFoundError(resp=response) + repo.get_contents.side_effect = UnknownObjectException( + status=404, data="Not Found" + ) result = build_dependabot_file(repo, False, [], {}, None, "", "", [], None) self.assertIsNone(result) @@ -34,7 +34,9 @@ def test_build_dependabot_file_with_schedule_day(self): filename_list = ["Gemfile", "Gemfile.lock"] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -55,7 +57,9 @@ def test_build_dependabot_file_with_bundler(self): filename_list = ["Gemfile", "Gemfile.lock"] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -72,7 +76,9 @@ def test_build_dependabot_file_with_bundler(self): def test_build_dependabot_file_with_existing_config_bundler_no_update(self): """Test that the dependabot.yml file is built correctly with bundler""" repo = MagicMock() - repo.file_contents.side_effect = lambda f, filename="Gemfile": f == filename + repo.get_contents.side_effect = ( + lambda f, filename="Gemfile": f == filename or [] + ) # expected_result is None because the existing config already contains the all applicable ecosystems expected_result = None @@ -97,7 +103,9 @@ def test_build_dependabot_file_with_2_space_indent_existing_config_bundler_with_ ): """Test that the dependabot.yml file is built correctly with bundler""" repo = MagicMock() - repo.file_contents.side_effect = lambda f, filename="Gemfile": f == filename + repo.get_contents.side_effect = ( + lambda f, filename="Gemfile": f == filename or [] + ) # expected_result maintains existing ecosystem with custom configuration # and adds new ecosystem @@ -134,7 +142,9 @@ def test_build_dependabot_file_with_2_space_indent_existing_config_bundler_with_ def test_build_dependabot_file_with_duplicate_key_in_existing_config(self): """Test that a duplicate key in existing dependabot config returns None instead of crashing""" repo = MagicMock() - repo.file_contents.side_effect = lambda f, filename="Gemfile": f == filename + repo.get_contents.side_effect = ( + lambda f, filename="Gemfile": f == filename or [] + ) existing_config = MagicMock() existing_config.content = base64.b64encode(b""" @@ -159,7 +169,9 @@ def test_build_dependabot_file_with_weird_space_indent_existing_config_bundler_w ): """Test that the dependabot.yml file is built correctly with bundler""" repo = MagicMock() - repo.file_contents.side_effect = lambda f, filename="Gemfile": f == filename + repo.get_contents.side_effect = ( + lambda f, filename="Gemfile": f == filename or [] + ) # expected_result maintains existing ecosystem with custom configuration # and adds new ecosystem @@ -202,8 +214,8 @@ def test_build_dependabot_file_with_extra_dependabot_config_file(self): """Test that the dependabot.yaml file is built correctly with extra configurations from extra_dependabot_config""" repo = MagicMock() - repo.file_contents.side_effect = ( - lambda f, filename="package.json": f == filename + repo.get_contents.side_effect = ( + lambda f, filename="package.json": f == filename or [] ) # expected_result maintains existing ecosystem with custom configuration @@ -244,7 +256,9 @@ def test_build_dependabot_file_with_npm(self): filename_list = ["package.json", "package-lock.json", "yarn.lock"] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -270,7 +284,9 @@ def test_build_dependabot_file_with_pip(self): ] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -293,7 +309,9 @@ def test_build_dependabot_file_with_cargo(self): ] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -310,7 +328,7 @@ def test_build_dependabot_file_with_cargo(self): def test_build_dependabot_file_with_gomod(self): """Test that the dependabot.yml file is built correctly with Go module""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "go.mod" + repo.get_contents.side_effect = lambda filename: filename == "go.mod" or [] expected_result = yaml.load(b""" version: 2 @@ -334,7 +352,9 @@ def test_build_dependabot_file_with_composer(self): ] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -357,7 +377,9 @@ def test_build_dependabot_file_with_hex(self): ] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -374,7 +396,9 @@ def test_build_dependabot_file_with_hex(self): def test_build_dependabot_file_with_nuget(self): """Test that the dependabot.yml file is built correctly with NuGet""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename.endswith(".csproj") + repo.get_contents.side_effect = ( + lambda filename: filename.endswith(".csproj") or [] + ) expected_result = yaml.load(b""" version: 2 @@ -434,7 +458,9 @@ def test_build_dependabot_file_with_new_ecosystems(self): for ecosystem, manifest_file in ecosystems: with self.subTest(ecosystem=ecosystem, manifest_file=manifest_file): repo = MagicMock() - repo.file_contents.side_effect = lambda f, mf=manifest_file: f == mf + repo.get_contents.side_effect = ( + lambda f, mf=manifest_file: f == mf or [] + ) expected = yaml.load(tmpl.format(ecosystem).encode()) result = build_dependabot_file( repo, False, [], {}, None, "weekly", "", [], None @@ -444,12 +470,9 @@ def test_build_dependabot_file_with_new_ecosystems(self): def test_build_dependabot_file_with_terraform_with_files(self): """Test that the dependabot.yml file is built correctly with Terraform""" repo = MagicMock() - response = MagicMock() - response.status_code = 404 - repo.file_contents.side_effect = github3.exceptions.NotFoundError(resp=response) - repo.directory_contents.side_effect = lambda path: ( - [("main.tf", None)] if path == "/" else [] - ) + tf_file = MagicMock() + tf_file.name = "main.tf" + repo.get_contents.side_effect = lambda path: ([tf_file] if path == "/" else []) expected_result = yaml.load(b""" version: 2 @@ -467,22 +490,17 @@ def test_build_dependabot_file_with_terraform_with_files(self): def test_build_dependabot_file_with_terraform_without_files(self): """Test that the dependabot.yml file is built correctly with Terraform""" repo = MagicMock() - response = MagicMock() - response.status_code = 404 - repo.file_contents.side_effect = github3.exceptions.NotFoundError(resp=response) # Test absence of Terraform files - repo.directory_contents.side_effect = lambda path: [] if path == "/" else [] + repo.get_contents.side_effect = lambda path: [] if path == "/" else [] result = build_dependabot_file( repo, False, [], {}, None, "weekly", "", [], None ) self.assertIsNone(result) # Test empty repository - response = MagicMock() - response.status_code = 404 - repo.directory_contents.side_effect = github3.exceptions.NotFoundError( - resp=response + repo.get_contents.side_effect = UnknownObjectException( + status=404, data="Not Found" ) result = build_dependabot_file( repo, False, [], {}, None, "weekly", "", [], None @@ -492,11 +510,10 @@ def test_build_dependabot_file_with_terraform_without_files(self): def test_build_dependabot_file_with_devcontainers(self): """Test that the dependabot.yml file is built correctly with devcontainers""" repo = MagicMock() - response = MagicMock() - response.status_code = 404 - repo.file_contents.side_effect = github3.exceptions.NotFoundError(resp=response) - repo.directory_contents.side_effect = lambda path: ( - [("devcontainer.json", None)] if path == ".devcontainer" else [] + dc_file = MagicMock() + dc_file.name = "devcontainer.json" + repo.get_contents.side_effect = lambda path: ( + [dc_file] if path == ".devcontainer" else [] ) expected_result = yaml.load(b""" @@ -515,11 +532,10 @@ def test_build_dependabot_file_with_devcontainers(self): def test_build_dependabot_file_with_github_actions(self): """Test that the dependabot.yml file is built correctly with GitHub Actions""" repo = MagicMock() - response = MagicMock() - response.status_code = 404 - repo.file_contents.side_effect = github3.exceptions.NotFoundError(resp=response) - repo.directory_contents.side_effect = lambda path: ( - [("test.yml", None)] if path == ".github/workflows" else [] + yml_file = MagicMock() + yml_file.name = "test.yml" + repo.get_contents.side_effect = lambda path: ( + [yml_file] if path == ".github/workflows" else [] ) expected_result = yaml.load(b""" @@ -538,11 +554,8 @@ def test_build_dependabot_file_with_github_actions(self): def test_build_dependabot_file_with_github_actions_without_files(self): """Test that the dependabot.yml file is None when no YAML files are found in the .github/workflows/ directory.""" repo = MagicMock() - response = MagicMock() - response.status_code = 404 - repo.file_contents.side_effect = github3.exceptions.NotFoundError(resp=response) - repo.directory_contents.side_effect = github3.exceptions.NotFoundError( - resp=response + repo.get_contents.side_effect = UnknownObjectException( + status=404, data="Not Found" ) result = build_dependabot_file( @@ -553,7 +566,7 @@ def test_build_dependabot_file_with_github_actions_without_files(self): def test_build_dependabot_file_with_groups(self): """Test that the dependabot.yml file is built correctly with grouped dependencies""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "Dockerfile" + repo.get_contents.side_effect = lambda filename: filename == "Dockerfile" or [] expected_result = yaml.load(b""" version: 2 @@ -574,7 +587,7 @@ def test_build_dependabot_file_with_groups(self): def test_build_dependabot_file_with_exempt_ecosystems(self): """Test that the dependabot.yml file is built correctly with exempted ecosystems""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "Dockerfile" + repo.get_contents.side_effect = lambda filename: filename == "Dockerfile" or [] result = build_dependabot_file( repo, False, ["docker"], {}, None, "weekly", "", [], None @@ -585,7 +598,7 @@ def test_build_dependabot_file_with_repo_specific_exempt_ecosystems(self): """Test that the dependabot.yml file is built correctly with exempted ecosystems""" repo = MagicMock() repo.full_name = "test/test" - repo.file_contents.side_effect = lambda filename: filename == "Dockerfile" + repo.get_contents.side_effect = lambda filename: filename == "Dockerfile" or [] result = build_dependabot_file( repo, False, [], {"test/test": ["docker"]}, None, "weekly", "", [], None @@ -618,8 +631,8 @@ def test_build_dependabot_file_for_multiple_repos_with_few_existing_config(self) """ existing_config_repo = MagicMock() - existing_config_repo.file_contents.side_effect = ( - lambda f, filename="Gemfile": f == filename + existing_config_repo.get_contents.side_effect = ( + lambda f, filename="Gemfile": f == filename or [] ) existing_config = MagicMock() @@ -649,8 +662,8 @@ def test_build_dependabot_file_for_multiple_repos_with_few_existing_config(self) no_existing_config_repo = MagicMock() filename_list = ["package.json", "package-lock.json", "yarn.lock"] for filename in filename_list: - no_existing_config_repo.file_contents.side_effect = ( - lambda f, filename=filename: f == filename + no_existing_config_repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] ) yaml.preserve_quotes = True expected_result = yaml.load(b""" @@ -679,7 +692,9 @@ def test_check_multiple_repos_with_no_dependabot_config(self): Test the case where there is a single repo """ mock_repo_1 = MagicMock() - mock_repo_1.file_contents.side_effect = lambda filename: filename == "go.mod" + mock_repo_1.get_contents.side_effect = ( + lambda filename: filename == "go.mod" or [] + ) expected_result = yaml.load(b""" version: 2 @@ -698,8 +713,8 @@ def test_check_multiple_repos_with_no_dependabot_config(self): no_existing_config_repo = MagicMock() filename_list = ["package.json", "package-lock.json", "yarn.lock"] for filename in filename_list: - no_existing_config_repo.file_contents.side_effect = ( - lambda f, filename=filename: f == filename + no_existing_config_repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] ) expected_result = yaml.load(b""" version: 2 @@ -728,7 +743,9 @@ def test_build_dependabot_file_with_label(self): filename_list = ["Gemfile", "Gemfile.lock"] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -750,7 +767,9 @@ def test_build_dependabot_file_with_labels(self): filename_list = ["Gemfile", "Gemfile.lock"] for filename in filename_list: - repo.file_contents.side_effect = lambda f, filename=filename: f == filename + repo.get_contents.side_effect = ( + lambda f, filename=filename: f == filename or [] + ) expected_result = yaml.load(b""" version: 2 updates: @@ -779,7 +798,7 @@ def test_build_dependabot_file_with_labels(self): def test_build_dependabot_file_preserves_existing_registries(self): """Test that existing registries are preserved when adding new ecosystems""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "Gemfile" + repo.get_contents.side_effect = lambda filename: filename == "Gemfile" or [] # Create existing config with registries but no bundler ecosystem existing_config = MagicMock() @@ -825,7 +844,7 @@ def test_build_dependabot_file_preserves_existing_registries(self): def test_build_dependabot_file_with_cooldown_default_days_only(self): """Test that cooldown with only default-days is added correctly""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "Dockerfile" + repo.get_contents.side_effect = lambda filename: filename == "Dockerfile" or [] cooldown = {"default-days": 3} expected_result = yaml.load(b""" @@ -846,7 +865,7 @@ def test_build_dependabot_file_with_cooldown_default_days_only(self): def test_build_dependabot_file_with_cooldown_all_params(self): """Test that cooldown with all semver day parameters is added correctly""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "Dockerfile" + repo.get_contents.side_effect = lambda filename: filename == "Dockerfile" or [] cooldown = { "default-days": 3, @@ -875,7 +894,7 @@ def test_build_dependabot_file_with_cooldown_all_params(self): def test_build_dependabot_file_with_cooldown_include_exclude(self): """Test that cooldown with include/exclude lists is added correctly""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "Dockerfile" + repo.get_contents.side_effect = lambda filename: filename == "Dockerfile" or [] cooldown = { "default-days": 5, @@ -905,7 +924,7 @@ def test_build_dependabot_file_with_cooldown_include_exclude(self): def test_build_dependabot_file_with_cooldown_and_groups(self): """Test that cooldown works alongside grouped dependencies""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "Dockerfile" + repo.get_contents.side_effect = lambda filename: filename == "Dockerfile" or [] cooldown = {"default-days": 3} expected_result = yaml.load(b""" @@ -931,7 +950,9 @@ def test_build_dependabot_file_with_cooldown_and_groups(self): def test_build_dependabot_file_with_cooldown_and_registries(self): """Test that cooldown works alongside private registries""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "package.json" + repo.get_contents.side_effect = ( + lambda filename: filename == "package.json" or [] + ) extra_config = yaml.load(b""" npm: @@ -963,7 +984,7 @@ def test_build_dependabot_file_with_cooldown_and_registries(self): def test_build_dependabot_file_without_cooldown(self): """Test that no cooldown section is added when cooldown is None""" repo = MagicMock() - repo.file_contents.side_effect = lambda filename: filename == "Dockerfile" + repo.get_contents.side_effect = lambda filename: filename == "Dockerfile" or [] expected_result = yaml.load(b""" version: 2 diff --git a/test_env.py b/test_env.py index 109a5a7..dbde483 100644 --- a/test_env.py +++ b/test_env.py @@ -518,7 +518,7 @@ def test_get_env_vars_with_update_existing(self): result = get_env_vars(True) self.assertEqual(result, expected_result) - @patch.dict(os.environ, {}) + @patch.dict(os.environ, {}, clear=True) def test_get_env_vars_missing_org_or_repo(self): """Test that an error is raised if required environment variables are not set""" with self.assertRaises(ValueError) as cm: diff --git a/test_evergreen.py b/test_evergreen.py index de5e5af..56b96b0 100644 --- a/test_evergreen.py +++ b/test_evergreen.py @@ -5,7 +5,6 @@ import uuid from unittest.mock import MagicMock, patch -import github3 import requests from evergreen import ( append_to_github_summary, @@ -22,6 +21,7 @@ is_repo_created_date_before, link_item_to_project, ) +from github import UnknownObjectException class TestDependabotSecurityUpdates(unittest.TestCase): @@ -214,8 +214,10 @@ def test_commit_changes(self, mock_uuid): ) # Mock UUID generation mock_repo = MagicMock() # Mock repo object mock_repo.default_branch = "main" - mock_repo.ref.return_value.object.sha = "abc123" # Mock SHA for latest commit - mock_repo.create_ref.return_value = True + mock_repo.get_git_ref.return_value.object.sha = ( + "abc123" # Mock SHA for latest commit + ) + mock_repo.create_git_ref.return_value = True mock_repo.create_file.return_value = True mock_repo.create_pull.return_value = "MockPullRequest" dependabot_file_name = ".github/dependabot.yml" @@ -235,13 +237,13 @@ def test_commit_changes(self, mock_uuid): ) # Assert that the methods were called with the correct arguments - mock_repo.create_ref.assert_called_once_with( - f"refs/heads/{branch_name}", "abc123" + mock_repo.create_git_ref.assert_called_once_with( + ref=f"refs/heads/{branch_name}", sha="abc123" ) mock_repo.create_file.assert_called_once_with( path=dependabot_file_name, message=commit_message, - content=dependabot_file.encode(), + content=dependabot_file, branch=branch_name, ) mock_repo.create_pull.assert_called_once_with( @@ -260,8 +262,8 @@ def test_commit_changes_with_existing_config(self, mock_uuid): mock_uuid.return_value = uuid.UUID("12345678123456781234567812345678") mock_repo = MagicMock() mock_repo.default_branch = "main" - mock_repo.ref.return_value.object.sha = "abc123" - mock_repo.create_ref.return_value = True + mock_repo.get_git_ref.return_value.object.sha = "abc123" + mock_repo.create_git_ref.return_value = True mock_repo.create_pull.return_value = "MockPullRequest" dependabot_file_name = ".github/dependabot.yml" @@ -270,7 +272,8 @@ def test_commit_changes_with_existing_config(self, mock_uuid): dependabot_file = 'dependencies:\n - package_manager: "python"\n directory: "/"\n update_schedule: "live"' branch_name = "dependabot-12345678-1234-5678-1234-567812345678" commit_message = "Update " + dependabot_file_name - existing_config = b"existing content" + existing_config = MagicMock() + existing_config.sha = "existing_sha_123" result = commit_changes( title, @@ -282,11 +285,12 @@ def test_commit_changes_with_existing_config(self, mock_uuid): existing_config, ) - # Assert that file_contents().update was called instead of create_file - mock_repo.file_contents.assert_called_once_with(dependabot_file_name) - mock_repo.file_contents.return_value.update.assert_called_once_with( + # Assert that update_file was called instead of create_file + mock_repo.update_file.assert_called_once_with( + path=dependabot_file_name, message=commit_message, - content=dependabot_file.encode(), + content=dependabot_file, + sha="existing_sha_123", branch=branch_name, ) mock_repo.create_file.assert_not_called() @@ -307,7 +311,7 @@ def test_check_pending_pulls_for_duplicates_no_duplicates(self): mock_repo = MagicMock() # Mock repo object mock_pull_request = MagicMock() mock_pull_request.title = "not-dependabot-branch" - mock_repo.pull_requests.return_value = [mock_pull_request] + mock_repo.get_pulls.return_value = [mock_pull_request] result = check_pending_pulls_for_duplicates("dependabot-branch", mock_repo) @@ -319,7 +323,7 @@ def test_check_pending_pulls_for_duplicates_with_duplicates(self): mock_repo = MagicMock() # Mock repo object mock_pull_request = MagicMock() mock_pull_request.title = "dependabot-branch" - mock_repo.pull_requests.return_value = [mock_pull_request] + mock_repo.get_pulls.return_value = [mock_pull_request] result = check_pending_pulls_for_duplicates(mock_pull_request.title, mock_repo) @@ -334,11 +338,11 @@ def test_check_pending_issues_for_duplicates_no_duplicates(self): """Test the check_pending_Issues_for_duplicates function where there are no duplicates to be found.""" mock_issue = MagicMock() mock_issue.title = "Other Issue" - mock_issue.issues.return_value = [mock_issue] + mock_issue.get_issues.return_value = [mock_issue] result = check_pending_issues_for_duplicates("Enable Dependabot", mock_issue) - mock_issue.issues.assert_called_once_with(state="open") + mock_issue.get_issues.assert_called_once_with(state="open") # Assert that the function returned the expected result self.assertFalse(result) @@ -347,11 +351,11 @@ def test_check_pending_issues_for_duplicates_with_duplicates(self): """Test the check_pending_issues_for_duplicates function where there are duplicates to be found.""" mock_issue = MagicMock() mock_issue.title = "Enable Dependabot" - mock_issue.issues.return_value = [mock_issue] + mock_issue.get_issues.return_value = [mock_issue] result = check_pending_issues_for_duplicates("Enable Dependabot", mock_issue) - mock_issue.issues.assert_called_once_with(state="open") + mock_issue.get_issues.assert_called_once_with(state="open") # Assert that the function returned the expected result self.assertTrue(result) @@ -360,69 +364,66 @@ def test_check_pending_issues_for_duplicates_with_duplicates(self): class TestGetReposIterator(unittest.TestCase): """Test the get_repos_iterator function in evergreen.py""" - @patch("github3.login") - def test_get_repos_iterator_with_organization(self, mock_github): + def test_get_repos_iterator_with_organization(self): """Test the get_repos_iterator function with an organization""" organization = "my_organization" repository_list = [] search_query = "" - github_connection = mock_github.return_value + github_connection = MagicMock() mock_organization = MagicMock() mock_repositories = MagicMock() - mock_organization.repositories.return_value = mock_repositories - github_connection.organization.return_value = mock_organization + mock_organization.get_repos.return_value = mock_repositories + github_connection.get_organization.return_value = mock_organization result = get_repos_iterator( organization, None, repository_list, search_query, github_connection ) - # Assert that the organization method was called with the correct argument - github_connection.organization.assert_called_once_with(organization) + # Assert that the get_organization method was called with the correct argument + github_connection.get_organization.assert_called_once_with(organization) - # Assert that the repositories method was called on the organization object - mock_organization.repositories.assert_called_once() + # Assert that the get_repos method was called on the organization object + mock_organization.get_repos.assert_called_once() # Assert that the function returned the expected result self.assertEqual(result, mock_repositories) - @patch("github3.login") - def test_get_repos_iterator_with_repository_list(self, mock_github): + def test_get_repos_iterator_with_repository_list(self): """Test the get_repos_iterator function with a repository list""" organization = None repository_list = ["org/repo1", "org/repo2"] search_query = "" - github_connection = mock_github.return_value + github_connection = MagicMock() mock_repository = MagicMock() mock_repository_list = [mock_repository, mock_repository] - github_connection.repository.side_effect = mock_repository_list + github_connection.get_repo.side_effect = mock_repository_list result = get_repos_iterator( organization, None, repository_list, search_query, github_connection ) - # Assert that the repository method was called with the correct arguments for each repository in the list + # Assert that the get_repo method was called with the correct arguments for each repository in the list expected_calls = [ - unittest.mock.call("org", "repo1"), - unittest.mock.call("org", "repo2"), + unittest.mock.call("org/repo1"), + unittest.mock.call("org/repo2"), ] - github_connection.repository.assert_has_calls(expected_calls) + github_connection.get_repo.assert_has_calls(expected_calls) # Assert that the function returned the expected result self.assertEqual(result, mock_repository_list) - @patch("github3.login") - def test_get_repos_iterator_with_team(self, mock_github): + def test_get_repos_iterator_with_team(self): """Test the get_repos_iterator function with a team""" organization = "my_organization" repository_list = [] team_name = "my_team" search_query = "" - github_connection = mock_github.return_value + github_connection = MagicMock() mock_team_repositories = MagicMock() - github_connection.organization.return_value.team_by_name.return_value.repositories.return_value = ( + github_connection.get_organization.return_value.get_team_by_slug.return_value.get_repos.return_value = ( mock_team_repositories ) @@ -434,30 +435,29 @@ def test_get_repos_iterator_with_team(self, mock_github): github_connection, ) - # Assert that the organization method was called with the correct argument - github_connection.organization.assert_called_once_with(organization) + # Assert that the get_organization method was called with the correct argument + github_connection.get_organization.assert_called_once_with(organization) - # Assert that the team_by_name method was called on the organization object - github_connection.organization.return_value.team_by_name.assert_called_once_with( + # Assert that the get_team_by_slug method was called on the organization object + github_connection.get_organization.return_value.get_team_by_slug.assert_called_once_with( team_name ) - # Assert that the repositories method was called on the team object - github_connection.organization.return_value.team_by_name.return_value.repositories.assert_called_once() + # Assert that the get_repos method was called on the team object + github_connection.get_organization.return_value.get_team_by_slug.return_value.get_repos.assert_called_once() # Assert that the function returned the expected result self.assertEqual(result, mock_team_repositories) - @patch("github3.login") - def test_get_repos_iterator_with_team_no_repos(self, mock_github): + def test_get_repos_iterator_with_team_no_repos(self): """Test the get_repos_iterator function with a team that has no repositories""" organization = "my_organization" repository_list = [] team_name = "empty_team" search_query = "" - github_connection = mock_github.return_value + github_connection = MagicMock() - github_connection.organization.return_value.team_by_name.return_value.repos_count = ( + github_connection.get_organization.return_value.get_team_by_slug.return_value.repos_count = ( 0 ) @@ -472,14 +472,13 @@ def test_get_repos_iterator_with_team_no_repos(self, mock_github): self.assertEqual(context.exception.code, 1) - @patch("github3.login") - def test_get_repos_iterator_with_search_query(self, mock_github): + def test_get_repos_iterator_with_search_query(self): """Test the get_repos_iterator function with a search query""" organization = "my_organization" repository_list = [] team_name = None search_query = "org:my-org is:repository archived:false" - github_connection = mock_github.return_value + github_connection = MagicMock() repo1 = MagicMock() repo2 = MagicMock() @@ -822,7 +821,7 @@ def test_check_existing_config_with_existing_config(self): """ mock_repo = MagicMock() filename = "dependabot.yaml" - mock_repo.file_contents.return_value.size = 5 + mock_repo.get_contents.return_value.size = 5 result = check_existing_config(mock_repo, filename) @@ -833,9 +832,8 @@ def test_check_existing_config_without_existing_config(self): Test the case where there is no existing configuration """ mock_repo = MagicMock() - mock_response = MagicMock() - mock_repo.file_contents.side_effect = github3.exceptions.NotFoundError( - mock_response + mock_repo.get_contents.side_effect = UnknownObjectException( + status=404, data="Not Found" ) result = check_existing_config(mock_repo, "dependabot.yml") diff --git a/test_exceptions.py b/test_exceptions.py index a013737..709fd9d 100644 --- a/test_exceptions.py +++ b/test_exceptions.py @@ -3,57 +3,43 @@ import unittest from unittest.mock import Mock -import github3.exceptions from exceptions import OptionalFileNotFoundError, check_optional_file +from github import UnknownObjectException class TestOptionalFileNotFoundError(unittest.TestCase): """Test the OptionalFileNotFoundError exception.""" - def test_optional_file_not_found_error_inherits_from_not_found_error(self): - """Test that OptionalFileNotFoundError inherits from github3.exceptions.NotFoundError.""" - mock_resp = Mock() - mock_resp.status_code = 404 - error = OptionalFileNotFoundError(resp=mock_resp) - self.assertIsInstance(error, github3.exceptions.NotFoundError) + def test_optional_file_not_found_error_inherits_from_unknown_object_exception(self): + """Test that OptionalFileNotFoundError inherits from github.UnknownObjectException.""" + error = OptionalFileNotFoundError(status=404, data="Not Found") + self.assertIsInstance(error, UnknownObjectException) def test_optional_file_not_found_error_creation(self): """Test OptionalFileNotFoundError can be created.""" - mock_resp = Mock() - mock_resp.status_code = 404 - error = OptionalFileNotFoundError(resp=mock_resp) + error = OptionalFileNotFoundError(status=404, data="Not Found") self.assertIsInstance(error, OptionalFileNotFoundError) def test_optional_file_not_found_error_with_response(self): - """Test OptionalFileNotFoundError with HTTP response.""" - mock_resp = Mock() - mock_resp.status_code = 404 - error = OptionalFileNotFoundError(resp=mock_resp) - - # Should be created successfully + """Test OptionalFileNotFoundError with HTTP response data.""" + error = OptionalFileNotFoundError(status=404, data="Not Found", headers={}) self.assertIsInstance(error, OptionalFileNotFoundError) - def test_can_catch_as_github3_not_found_error(self): - """Test that OptionalFileNotFoundError can be caught as github3.exceptions.NotFoundError.""" - mock_resp = Mock() - mock_resp.status_code = 404 - + def test_can_catch_as_unknown_object_exception(self): + """Test that OptionalFileNotFoundError can be caught as github.UnknownObjectException.""" try: - raise OptionalFileNotFoundError(resp=mock_resp) - except github3.exceptions.NotFoundError as e: + raise OptionalFileNotFoundError(status=404, data="Not Found") + except UnknownObjectException as e: self.assertIsInstance(e, OptionalFileNotFoundError) except Exception: # pylint: disable=broad-exception-caught self.fail( - "OptionalFileNotFoundError should be catchable as github3.exceptions.NotFoundError" + "OptionalFileNotFoundError should be catchable as UnknownObjectException" ) def test_can_catch_specifically(self): """Test that OptionalFileNotFoundError can be caught specifically.""" - mock_resp = Mock() - mock_resp.status_code = 404 - try: - raise OptionalFileNotFoundError(resp=mock_resp) + raise OptionalFileNotFoundError(status=404, data="Not Found") except OptionalFileNotFoundError as e: self.assertIsInstance(e, OptionalFileNotFoundError) except Exception: # pylint: disable=broad-exception-caught @@ -61,12 +47,11 @@ def test_can_catch_specifically(self): def test_optional_file_not_found_error_properties(self): """Test OptionalFileNotFoundError has expected properties.""" - mock_resp = Mock() - mock_resp.status_code = 404 - - error = OptionalFileNotFoundError(resp=mock_resp) - self.assertEqual(error.code, 404) - self.assertEqual(error.response, mock_resp) + error = OptionalFileNotFoundError( + status=404, data="Not Found", headers={"X-Test": "value"} + ) + self.assertEqual(error.status, 404) + self.assertEqual(error.data, "Not Found") class TestCheckOptionalFile(unittest.TestCase): @@ -77,59 +62,53 @@ def test_check_optional_file_with_existing_file(self): mock_repo = Mock() mock_file_contents = Mock() mock_file_contents.size = 100 - mock_repo.file_contents.return_value = mock_file_contents + mock_repo.get_contents.return_value = mock_file_contents result = check_optional_file(mock_repo, "config.yml") self.assertEqual(result, mock_file_contents) - mock_repo.file_contents.assert_called_once_with("config.yml") + mock_repo.get_contents.assert_called_once_with("config.yml") def test_check_optional_file_with_empty_file(self): """Test check_optional_file when file exists but is empty.""" mock_repo = Mock() mock_file_contents = Mock() mock_file_contents.size = 0 - mock_repo.file_contents.return_value = mock_file_contents + mock_repo.get_contents.return_value = mock_file_contents result = check_optional_file(mock_repo, "config.yml") self.assertIsNone(result) - mock_repo.file_contents.assert_called_once_with("config.yml") + mock_repo.get_contents.assert_called_once_with("config.yml") def test_check_optional_file_with_missing_file(self): """Test check_optional_file when file doesn't exist.""" mock_repo = Mock() - mock_resp = Mock() - mock_resp.status_code = 404 - original_error = github3.exceptions.NotFoundError(resp=mock_resp) - mock_repo.file_contents.side_effect = original_error + original_error = UnknownObjectException(status=404, data="Not Found") + mock_repo.get_contents.side_effect = original_error with self.assertRaises(OptionalFileNotFoundError) as context: check_optional_file(mock_repo, "missing.yml") - # Check that the original exception is chained self.assertEqual(context.exception.__cause__, original_error) - self.assertEqual(context.exception.response, mock_resp) - mock_repo.file_contents.assert_called_once_with("missing.yml") + mock_repo.get_contents.assert_called_once_with("missing.yml") - def test_check_optional_file_can_catch_as_not_found_error(self): - """Test that OptionalFileNotFoundError from check_optional_file can be caught as NotFoundError.""" + def test_check_optional_file_can_catch_as_unknown_object_exception(self): + """Test that OptionalFileNotFoundError from check_optional_file can be caught as UnknownObjectException.""" mock_repo = Mock() - mock_resp = Mock() - mock_resp.status_code = 404 - mock_repo.file_contents.side_effect = github3.exceptions.NotFoundError( - resp=mock_resp + mock_repo.get_contents.side_effect = UnknownObjectException( + status=404, data="Not Found" ) try: check_optional_file(mock_repo, "missing.yml") - except github3.exceptions.NotFoundError as e: + except UnknownObjectException as e: self.assertIsInstance(e, OptionalFileNotFoundError) except Exception: # pylint: disable=broad-exception-caught self.fail( - "Should be able to catch OptionalFileNotFoundError as NotFoundError" + "Should be able to catch OptionalFileNotFoundError as UnknownObjectException" ) diff --git a/uv.lock b/uv.lock index d7e8072..d9e3382 100644 --- a/uv.lock +++ b/uv.lock @@ -443,7 +443,7 @@ name = "evergreen" version = "1.0.0" source = { virtual = "." } dependencies = [ - { name = "github3-py" }, + { name = "pygithub" }, { name = "python-dotenv" }, { name = "requests" }, { name = "ruamel-yaml" }, @@ -465,7 +465,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "github3-py", specifier = "==4.0.1" }, + { name = "pygithub", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "requests", specifier = "==2.34.2" }, { name = "ruamel-yaml", specifier = "==0.19.1" }, @@ -499,21 +499,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] -[[package]] -name = "github3-py" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/91/603bcaf8cd1b3927de64bf56c3a8915f6653ea7281919140c5bcff2bfe7b/github3.py-4.0.1.tar.gz", hash = "sha256:30d571076753efc389edc7f9aaef338a4fcb24b54d8968d5f39b1342f45ddd36", size = 36214038, upload-time = "2023-04-26T17:56:37.677Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/2394d4fb542574678b0ba342daf734d4d811768da3c2ee0c84d509dcb26c/github3.py-4.0.1-py3-none-any.whl", hash = "sha256:a89af7de25650612d1da2f0609622bcdeb07ee8a45a1c06b2d16a05e4234e753", size = 151800, upload-time = "2023-04-26T17:56:25.015Z" }, -] - [[package]] name = "idna" version = "3.15" @@ -746,6 +731,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] +[[package]] +name = "pygithub" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -787,6 +788,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -817,18 +853,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -896,15 +920,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "tomli" version = "2.4.0" @@ -998,15 +1013,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" From 5c402a325cfb30e6c8b180c3f672b3f7496e9f45 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Wed, 1 Jul 2026 22:18:49 -0500 Subject: [PATCH 2/5] test: add coverage for missing app id/installation id guard Signed-off-by: jmeridth --- test_auth.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test_auth.py b/test_auth.py index d430e7c..c391744 100644 --- a/test_auth.py +++ b/test_auth.py @@ -304,6 +304,30 @@ def test_get_github_app_installation_token_with_ghe_api_url( base_url="https://api.example.ghe.com", ) + def test_get_github_app_installation_token_missing_app_id(self): + """ + Test that get_github_app_installation_token returns None when gh_app_id is None. + """ + result = auth.get_github_app_installation_token( + ghe="", + gh_app_id=None, + gh_app_private_key_bytes=b"private_key", + gh_app_installation_id=678910, + ) + self.assertIsNone(result) + + def test_get_github_app_installation_token_missing_installation_id(self): + """ + Test that get_github_app_installation_token returns None when gh_app_installation_id is None. + """ + result = auth.get_github_app_installation_token( + ghe="", + gh_app_id=12345, + gh_app_private_key_bytes=b"private_key", + gh_app_installation_id=None, + ) + self.assertIsNone(result) + if __name__ == "__main__": unittest.main() From a6cba0b0c40f4a8884a2c5bf3ae6122224cf679d Mon Sep 17 00:00:00 2001 From: jmeridth Date: Thu, 2 Jul 2026 08:12:27 -0500 Subject: [PATCH 3/5] docs: fix return type in get_github_app_installation_token docstring Signed-off-by: jmeridth --- auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth.py b/auth.py index 91252de..1cd4011 100644 --- a/auth.py +++ b/auth.py @@ -68,7 +68,7 @@ def get_github_app_installation_token( ghe_api_url (str): the full GitHub Enterprise API endpoint URL override Returns: - str: the GitHub App token + str | None: the GitHub App token, or None if IDs are missing or the request fails """ if not gh_app_id or not gh_app_installation_id: return None From 6e1b200fecb7e19c3bc9ae5c330eee7724f7171e Mon Sep 17 00:00:00 2001 From: jmeridth Date: Thu, 2 Jul 2026 19:56:46 -0500 Subject: [PATCH 4/5] fix: narrow exception handling in get_github_app_installation_token ## What/Why Narrow except clause from bare Exception to GithubException so that misconfiguration errors (bad int() cast, JWT construction failures) propagate instead of silently returning None and causing confusing 401s downstream. Addresses PR review feedback from @zkoppert. ## Proof it works All 14 tests in test_auth.py pass. Updated the request_failure test to raise GithubException instead of bare Exception to match the narrowed catch. Pylint clean (no new warnings). ## Risk + AI role Low. AI-generated (Claude Opus 4.6) with human review. ## Review focus Whether GithubException covers all the API/network error cases we want to catch, or if additional specific exceptions should be included. Signed-off-by: jmeridth --- auth.py | 4 ++-- test_auth.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/auth.py b/auth.py index 1cd4011..c3daf5d 100644 --- a/auth.py +++ b/auth.py @@ -1,7 +1,7 @@ """This is the module that contains functions related to authenticating to GitHub with a personal access token.""" import env -from github import Auth, Github, GithubIntegration +from github import Auth, Github, GithubException, GithubIntegration def auth_to_github( @@ -81,6 +81,6 @@ def get_github_app_installation_token( gi = GithubIntegration(auth=app_auth) installation_token = gi.get_access_token(int(gh_app_installation_id)) return installation_token.token - except Exception as e: # pylint: disable=broad-exception-caught + except GithubException as e: print(f"Request failed: {e}") return None diff --git a/test_auth.py b/test_auth.py index c391744..4393bf4 100644 --- a/test_auth.py +++ b/test_auth.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import auth +from github import GithubException class TestAuth(unittest.TestCase): @@ -262,7 +263,9 @@ def test_get_github_app_installation_token_request_failure( mock_app_auth_cls.return_value = mock_app_auth mock_gi = MagicMock() mock_gi_cls.return_value = mock_gi - mock_gi.get_access_token.side_effect = Exception("Request failed") + mock_gi.get_access_token.side_effect = GithubException( + 500, "Request failed", None + ) result = auth.get_github_app_installation_token( ghe="https://api.github.com", From 086af44493b879e4fa3ef4dd069b449f794de7e9 Mon Sep 17 00:00:00 2001 From: jmeridth Date: Thu, 2 Jul 2026 20:11:15 -0500 Subject: [PATCH 5/5] fix: use repo.owner.login for security-updates URLs and handle datetime in created_after filter ## What/Why Fix two bugs exposed by the PyGithub migration: (1) repo.owner is now a NamedUser object, not a string, so security-updates URLs rendered as "repos/NamedUser(login=...)/..." and 404'd silently; (2) repo.created_at is a datetime object but is_repo_created_date_before called fromisoformat() on it, which raises TypeError. ## Proof it works All 180 tests pass including 2 new tests for datetime input to is_repo_created_date_before (both before and after filter date). ## Risk + AI role Medium -- the repo.owner.login fix touches the security-updates code path which is pragma: no cover. AI-generated (Claude Opus 4.6) with human review. ## Review focus Whether repo.owner.login is the correct attribute for all PyGithub Repository objects (org-owned vs user-owned repos). Signed-off-by: jmeridth --- evergreen.py | 15 +++++++++++---- test_evergreen.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/evergreen.py b/evergreen.py index 79ce97e..200d374 100644 --- a/evergreen.py +++ b/evergreen.py @@ -213,10 +213,10 @@ def main(): # pragma: no cover # Get dependabot security updates enabled if possible if config.enable_security_updates: if not is_dependabot_security_updates_enabled( - config.ghe, config.ghe_api_url, repo.owner, repo.name, token + config.ghe, config.ghe_api_url, repo.owner.login, repo.name, token ): enable_dependabot_security_updates( - config.ghe, config.ghe_api_url, repo.owner, repo.name, token + config.ghe, config.ghe_api_url, repo.owner.login, repo.name, token ) if config.follow_up_type == "issue": @@ -300,9 +300,16 @@ def main(): # pragma: no cover append_to_github_summary(summary_content) -def is_repo_created_date_before(repo_created_at: str, created_after_date: str): +def is_repo_created_date_before( + repo_created_at: str | datetime, created_after_date: str +): """Check if the repository was created before the created_after_date""" - repo_created_at_date = datetime.fromisoformat(repo_created_at).replace(tzinfo=None) + if isinstance(repo_created_at, datetime): + repo_created_at_date = repo_created_at.replace(tzinfo=None) + else: + repo_created_at_date = datetime.fromisoformat(repo_created_at).replace( + tzinfo=None + ) return created_after_date and repo_created_at_date < datetime.strptime( created_after_date, "%Y-%m-%d" ) diff --git a/test_evergreen.py b/test_evergreen.py index 56b96b0..93342ff 100644 --- a/test_evergreen.py +++ b/test_evergreen.py @@ -3,6 +3,7 @@ import unittest import uuid +from datetime import datetime, timezone from unittest.mock import MagicMock, patch import requests @@ -801,6 +802,24 @@ def test_is_repo_created_date_is_before_created_after_date_without_timezone_agai self.assertTrue(result) + def test_is_repo_created_date_before_with_datetime_object(self): + """Test that a datetime object (as returned by PyGithub) is handled correctly.""" + repo_created_at = datetime(2020, 1, 1, 5, 0, 0, tzinfo=timezone.utc) + created_after_date = "2021-01-01" + + result = is_repo_created_date_before(repo_created_at, created_after_date) + + self.assertTrue(result) + + def test_is_repo_created_date_after_with_datetime_object(self): + """Test that a datetime object after the filter date returns False.""" + repo_created_at = datetime(2022, 1, 1, 5, 0, 0, tzinfo=timezone.utc) + created_after_date = "2021-01-01" + + result = is_repo_created_date_before(repo_created_at, created_after_date) + + self.assertFalse(result) + def test_is_repo_created_date_and_created_after_date_is_not_a_date(self): """Test the repo.created_at date and the created_after_date argument is not a date.""" repo_created_at = "2018-01-01"