From 2837fe868ba3fd19cfb6290e4be30c0a317bfe11 Mon Sep 17 00:00:00 2001 From: Willem Jan Glerum Date: Mon, 6 Jul 2026 12:33:19 +0200 Subject: [PATCH 1/2] fix: skip empty repositories instead of crashing check_optional_file() only caught UnknownObjectException, but an empty repository (one with no commits) returns a 404 that PyGithub raises as the base GithubException ("This repository is empty."). That escaped the handler and crashed the whole run when iterating an organization that contains a freshly-created, never-pushed repo. Handle a 404 GithubException the same way as a missing optional file so the repository is skipped. Non-404 GithubExceptions are re-raised unchanged. Adds tests for both cases. Co-Authored-By: Claude Opus 4.8 --- exceptions.py | 12 +++++++++++- test_exceptions.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/exceptions.py b/exceptions.py index 3a597ff..ce03000 100644 --- a/exceptions.py +++ b/exceptions.py @@ -1,6 +1,6 @@ """Custom exceptions for the evergreen application.""" -from github import UnknownObjectException +from github import GithubException, UnknownObjectException class OptionalFileNotFoundError(UnknownObjectException): @@ -47,3 +47,13 @@ def check_optional_file(repo, filename): raise OptionalFileNotFoundError( status=e.status, data=e.data, headers=e.headers ) from e + except GithubException as e: + # An empty repository (one with no commits) returns a 404 that PyGithub + # raises as the base GithubException ("This repository is empty.") + # rather than UnknownObjectException. Treat it the same as a missing + # optional file so the repository is skipped instead of crashing the run. + if e.status == 404: + raise OptionalFileNotFoundError( + status=e.status, data=e.data, headers=e.headers + ) from e + raise diff --git a/test_exceptions.py b/test_exceptions.py index 709fd9d..4a6c99d 100644 --- a/test_exceptions.py +++ b/test_exceptions.py @@ -4,7 +4,7 @@ from unittest.mock import Mock from exceptions import OptionalFileNotFoundError, check_optional_file -from github import UnknownObjectException +from github import GithubException, UnknownObjectException class TestOptionalFileNotFoundError(unittest.TestCase): @@ -94,6 +94,40 @@ def test_check_optional_file_with_missing_file(self): self.assertEqual(context.exception.__cause__, original_error) mock_repo.get_contents.assert_called_once_with("missing.yml") + def test_check_optional_file_with_empty_repository(self): + """Test check_optional_file when the repository is empty. + + An empty repository returns a 404 that PyGithub raises as the base + GithubException rather than UnknownObjectException. It should be + translated to OptionalFileNotFoundError so the repository is skipped. + """ + mock_repo = Mock() + + original_error = GithubException( + status=404, data={"message": "This repository is empty."} + ) + mock_repo.get_contents.side_effect = original_error + + with self.assertRaises(OptionalFileNotFoundError) as context: + check_optional_file(mock_repo, "config.yml") + + self.assertEqual(context.exception.__cause__, original_error) + mock_repo.get_contents.assert_called_once_with("config.yml") + + def test_check_optional_file_reraises_non_404_github_exception(self): + """Test check_optional_file re-raises non-404 GithubExceptions unchanged.""" + mock_repo = Mock() + + original_error = GithubException(status=403, data={"message": "Forbidden"}) + mock_repo.get_contents.side_effect = original_error + + with self.assertRaises(GithubException) as context: + check_optional_file(mock_repo, "config.yml") + + self.assertNotIsInstance(context.exception, OptionalFileNotFoundError) + self.assertEqual(context.exception.status, 403) + mock_repo.get_contents.assert_called_once_with("config.yml") + 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() From d579f49edc3f436a7e71025d5a7d6e489da2b5e1 Mon Sep 17 00:00:00 2001 From: Willem Jan Glerum Date: Wed, 8 Jul 2026 11:01:16 +0200 Subject: [PATCH 2/2] Skip empty repositories early in the main loop Addresses review feedback: the previous change only hardened check_optional_file(), but empty repositories also 404 in dependabot_file.py (repo.get_contents on "/", ".github/workflows", ".devcontainer"), which only catch UnknownObjectException and would still crash the run. Add an is_empty_repo() helper (repo.size == 0) and skip empty repos at the top of the main loop, before any content lookup, so none of the downstream get_contents paths are reached for them. The check_optional_file GithubException handling is kept as a defensive backstop. Co-Authored-By: Claude Opus 4.8 --- evergreen.py | 14 ++++++++++++++ test_evergreen.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/evergreen.py b/evergreen.py index 200d374..c4de7cf 100644 --- a/evergreen.py +++ b/evergreen.py @@ -111,6 +111,9 @@ def main(): # pragma: no cover if repo.archived: print(f"Skipping {repo.full_name} (archived)") continue + if is_empty_repo(repo): + print(f"Skipping {repo.full_name} (empty repository)") + continue if repo.visibility.lower() not in config.filter_visibility: print(f"Skipping {repo.full_name} (visibility-filtered)") continue @@ -315,6 +318,17 @@ def is_repo_created_date_before( ) +def is_empty_repo(repo): + """Check whether the repository is empty (has no commits). + + Empty repositories cannot hold or receive a dependabot configuration, and + every ``repo.get_contents(...)`` call against them returns a 404 + ("This repository is empty."). Detecting them up front lets the caller skip + them before any content lookup, rather than crashing partway through. + """ + return repo.size == 0 + + def is_dependabot_security_updates_enabled(ghe, ghe_api_url, owner, repo, access_token): """ Check if Dependabot security updates are enabled at the /repos/:owner/:repo/automated-security-fixes endpoint using the requests library diff --git a/test_evergreen.py b/test_evergreen.py index 93342ff..bd93955 100644 --- a/test_evergreen.py +++ b/test_evergreen.py @@ -19,6 +19,7 @@ get_global_project_id, get_repos_iterator, is_dependabot_security_updates_enabled, + is_empty_repo, is_repo_created_date_before, link_item_to_project, ) @@ -362,6 +363,22 @@ def test_check_pending_issues_for_duplicates_with_duplicates(self): self.assertTrue(result) +class TestIsEmptyRepo(unittest.TestCase): + """Test the is_empty_repo function in evergreen.py""" + + def test_is_empty_repo_true_for_size_zero(self): + """A repository with no commits reports size 0 and is considered empty.""" + mock_repo = MagicMock() + mock_repo.size = 0 + self.assertTrue(is_empty_repo(mock_repo)) + + def test_is_empty_repo_false_for_non_zero_size(self): + """A repository with content is not considered empty.""" + mock_repo = MagicMock() + mock_repo.size = 42 + self.assertFalse(is_empty_repo(mock_repo)) + + class TestGetReposIterator(unittest.TestCase): """Test the get_repos_iterator function in evergreen.py"""