Skip to content

Commit 0cdf6a3

Browse files
authored
Merge pull request #522 from github-community-projects/zkoppert/raise-coverage-to-100
2 parents 66e5c20 + efac774 commit 0cdf6a3

6 files changed

Lines changed: 270 additions & 8 deletions

File tree

.coveragerc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
[run]
2-
omit = test*.py
2+
omit = test*.py
3+
4+
[report]
5+
exclude_lines =
6+
pragma: no cover
7+
if __name__ == .__main__.:

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.PHONY: test
22
test:
3-
uv run python -m pytest -v --cov=. --cov-config=.coveragerc --cov-fail-under=80 --cov-report term-missing
3+
uv run python -m pytest -v --cov=. --cov-config=.coveragerc --cov-fail-under=100 --cov-report term-missing
44

55
.PHONY: clean
66
clean:

stale_repos.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def main(): # pragma: no cover
7373
)
7474

7575
if inactive_repos or not skip_empty_reports:
76-
output_to_json(inactive_repos)
76+
output_to_json(inactive_repos, additional_metrics=additional_metrics)
7777
write_to_markdown(
7878
inactive_repos,
7979
inactive_days_threshold,
@@ -242,14 +242,20 @@ def get_active_date(repo):
242242
return active_date
243243

244244

245-
def output_to_json(inactive_repos, file=None):
245+
def output_to_json(inactive_repos, file=None, additional_metrics=None):
246246
"""Convert the list of inactive repos to a json string.
247247
248248
Args:
249249
inactive_repos: A list of dictionaries containing the repo,
250250
days inactive, the date of the last push,
251251
visibility of the repository (public/private),
252252
days since the last release, and days since the last pr.
253+
file: An optional open file object to write the JSON to. If not
254+
provided, a new file named "stale_repos.json" is opened.
255+
additional_metrics: An optional list of additional metrics to include
256+
in the JSON. Supported values: "release", "pr". When omitted, only
257+
the core fields are emitted (matching the markdown writer's
258+
behavior).
253259
254260
Returns:
255261
JSON formatted string of the list of inactive repos.
@@ -273,10 +279,13 @@ def output_to_json(inactive_repos, file=None):
273279
"lastPushDate": repo_data["last_push_date"],
274280
"visibility": repo_data["visibility"],
275281
}
276-
if "release" in repo_data:
277-
repo_json["daysSinceLastRelease"] = repo_data["days_since_last_release"]
278-
if "pr" in repo_data:
279-
repo_json["daysSinceLastPR"] = repo_data["days_since_last_pr"]
282+
if additional_metrics:
283+
if "release" in additional_metrics:
284+
repo_json["daysSinceLastRelease"] = repo_data.get(
285+
"days_since_last_release"
286+
)
287+
if "pr" in additional_metrics:
288+
repo_json["daysSinceLastPR"] = repo_data.get("days_since_last_pr")
280289
inactive_repos_json.append(repo_json)
281290
inactive_repos_json = json.dumps(inactive_repos_json)
282291

test_auth.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ def test_auth_to_github_with_app(self, mock_gh):
7777
)
7878
self.assertEqual(result, mock)
7979

80+
@patch("github3.login")
81+
def test_auth_to_github_raises_when_connection_is_none(self, mock_login):
82+
"""
83+
Test the auth_to_github function raises ValueError when github3.login returns None.
84+
"""
85+
mock_login.return_value = None
86+
with self.assertRaises(ValueError) as context_manager:
87+
auth.auth_to_github("token", None, None, b"", "", False)
88+
self.assertEqual(
89+
str(context_manager.exception), "Unable to authenticate to GitHub"
90+
)
91+
8092

8193
if __name__ == "__main__":
8294
unittest.main()

test_env.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ def test_get_env_vars_with_workflow_summary_enabled(self):
195195
result = get_env_vars(True)
196196
self.assertEqual(str(result), str(expected_result))
197197

198+
@patch.dict(
199+
os.environ,
200+
{"GH_TOKEN": "TOKEN"},
201+
clear=True,
202+
)
203+
@patch("env.load_dotenv")
204+
def test_get_env_vars_loads_dotenv_when_not_test(self, mock_load_dotenv):
205+
"""Test that get_env_vars loads the .env file when test is False."""
206+
get_env_vars(test=False)
207+
mock_load_dotenv.assert_called_once()
208+
198209

199210
if __name__ == "__main__":
200211
unittest.main()

test_stale_repos.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import io
2020
import json
2121
import os
22+
import tempfile
2223
import unittest
2324
from datetime import datetime, timedelta, timezone
2425
from unittest.mock import MagicMock, call, patch
@@ -31,6 +32,7 @@
3132
get_inactive_repos,
3233
is_repo_exempt,
3334
output_to_json,
35+
set_repo_data,
3436
)
3537

3638

@@ -699,3 +701,226 @@ def test_report_exclusion_with_additional_metrics_not_configured(self):
699701
},
700702
]
701703
self.assertEqual(inactive_repos, expected_inactive_repos)
704+
705+
706+
class GetInactiveReposWithExemptReposTestCase(unittest.TestCase):
707+
"""Verify get_inactive_repos honors the EXEMPT_REPOS environment variable."""
708+
709+
def setUp(self):
710+
os.environ["EXEMPT_REPOS"] = "exempt_repo, another_exempt_repo"
711+
712+
def tearDown(self):
713+
del os.environ["EXEMPT_REPOS"]
714+
715+
def test_exempt_repos_env_var_is_parsed_and_applied(self):
716+
"""EXEMPT_REPOS env var should exempt matching repos."""
717+
mock_github = MagicMock()
718+
mock_org = MagicMock()
719+
720+
forty_days_ago = datetime.now(timezone.utc) - timedelta(days=40)
721+
exempt_repo = MagicMock(
722+
html_url="https://github.com/example/exempt_repo",
723+
pushed_at=forty_days_ago.isoformat(),
724+
archived=False,
725+
private=True,
726+
)
727+
exempt_repo.name = "exempt_repo"
728+
exempt_repo.topics.return_value.names = []
729+
730+
included_repo = MagicMock(
731+
html_url="https://github.com/example/included_repo",
732+
pushed_at=forty_days_ago.isoformat(),
733+
archived=False,
734+
private=True,
735+
)
736+
included_repo.name = "included_repo"
737+
included_repo.topics.return_value.names = []
738+
739+
mock_github.organization.return_value = mock_org
740+
mock_org.repositories.return_value = [exempt_repo, included_repo]
741+
742+
with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
743+
inactive_repos = get_inactive_repos(mock_github, 30, "example")
744+
output = mock_stdout.getvalue()
745+
746+
self.assertIn("Exempt repos: ['exempt_repo', 'another_exempt_repo']", output)
747+
self.assertEqual(len(inactive_repos), 1)
748+
self.assertEqual(
749+
inactive_repos[0]["url"], "https://github.com/example/included_repo"
750+
)
751+
752+
753+
class GetDaysSinceLastReleaseExceptionTestCase(unittest.TestCase):
754+
"""Cover the exception branches in get_days_since_last_release."""
755+
756+
def test_returns_none_on_type_error(self):
757+
"""A TypeError on the release iterator should return None and log a message."""
758+
mock_repo = MagicMock(html_url="https://github.com/example/repo")
759+
mock_repo.releases.return_value.__next__.side_effect = TypeError(
760+
"ghost user release"
761+
)
762+
763+
with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
764+
result = get_days_since_last_release(mock_repo)
765+
output = mock_stdout.getvalue()
766+
767+
self.assertIsNone(result)
768+
self.assertIn(
769+
"https://github.com/example/repo had an exception trying to get the last release",
770+
output,
771+
)
772+
773+
def test_returns_none_when_no_releases(self):
774+
"""If the releases iterator is empty, return None without raising."""
775+
mock_repo = MagicMock()
776+
mock_repo.releases.return_value.__next__.side_effect = StopIteration
777+
778+
result = get_days_since_last_release(mock_repo)
779+
780+
self.assertIsNone(result)
781+
782+
783+
class GetDaysSinceLastPrExceptionTestCase(unittest.TestCase):
784+
"""Cover the exception branches in get_days_since_last_pr."""
785+
786+
def test_returns_none_when_no_pull_requests(self):
787+
"""If the pull_requests iterator is empty, return None without raising."""
788+
mock_repo = MagicMock()
789+
mock_repo.pull_requests.return_value.__next__.side_effect = StopIteration
790+
791+
result = get_days_since_last_pr(mock_repo)
792+
793+
self.assertIsNone(result)
794+
795+
796+
class GetActiveDateUnsupportedMethodTestCase(unittest.TestCase):
797+
"""Cover the ValueError branch in get_active_date when ACTIVITY_METHOD is bogus."""
798+
799+
@patch.dict(os.environ, {"ACTIVITY_METHOD": "not_a_real_method"})
800+
def test_unsupported_activity_method_raises_value_error(self):
801+
"""Unsupported ACTIVITY_METHOD should raise ValueError (the raise sits
802+
outside the github3 exception handler, so it propagates to the caller)."""
803+
repo = MagicMock(html_url="https://github.com/example/repo")
804+
with self.assertRaises(ValueError) as ctx:
805+
get_active_date(repo)
806+
self.assertIn(
807+
"ACTIVITY_METHOD environment variable has unsupported value",
808+
str(ctx.exception),
809+
)
810+
811+
812+
class OutputToJsonOptionalKeysTestCase(unittest.TestCase):
813+
"""Cover the optional release/pr key branches in output_to_json."""
814+
815+
def test_includes_release_and_pr_keys_when_additional_metrics_set(self):
816+
"""When additional_metrics includes 'release' and 'pr', the JSON output
817+
must include daysSinceLastRelease and daysSinceLastPR populated from the
818+
production-shaped repo_data dict that set_repo_data emits."""
819+
inactive_repos = [
820+
{
821+
"url": "https://github.com/example/repo",
822+
"days_inactive": 40,
823+
"last_push_date": "2024-01-01",
824+
"visibility": "public",
825+
"days_since_last_release": 5,
826+
"days_since_last_pr": 2,
827+
}
828+
]
829+
830+
result_json = output_to_json(
831+
inactive_repos, MagicMock(), additional_metrics=["release", "pr"]
832+
)
833+
result = json.loads(result_json)
834+
835+
self.assertEqual(result[0]["daysSinceLastRelease"], 5)
836+
self.assertEqual(result[0]["daysSinceLastPR"], 2)
837+
838+
def test_release_only_metric_omits_pr_field(self):
839+
"""Only the requested metric should appear in the JSON; the unrequested
840+
one (pr) should be absent even if days_since_last_pr is in the dict."""
841+
inactive_repos = [
842+
{
843+
"url": "https://github.com/example/repo",
844+
"days_inactive": 40,
845+
"last_push_date": "2024-01-01",
846+
"visibility": "public",
847+
"days_since_last_release": 5,
848+
"days_since_last_pr": 2,
849+
}
850+
]
851+
852+
result_json = output_to_json(
853+
inactive_repos, MagicMock(), additional_metrics=["release"]
854+
)
855+
result = json.loads(result_json)
856+
857+
self.assertEqual(result[0]["daysSinceLastRelease"], 5)
858+
self.assertNotIn("daysSinceLastPR", result[0])
859+
860+
861+
class OutputToJsonGithubOutputTestCase(unittest.TestCase):
862+
"""Cover the GITHUB_OUTPUT environment variable branch in output_to_json."""
863+
864+
def test_writes_to_github_output_when_env_var_set(self):
865+
"""When GITHUB_OUTPUT is set, output_to_json should append a
866+
`inactiveRepos=` line to the file path it points at."""
867+
inactive_repos = [
868+
{
869+
"url": "https://github.com/example/repo",
870+
"days_inactive": 40,
871+
"last_push_date": "2024-01-01",
872+
"visibility": "public",
873+
}
874+
]
875+
876+
with tempfile.NamedTemporaryFile(
877+
mode="w", delete=False, suffix=".out"
878+
) as github_output_file:
879+
github_output_path = github_output_file.name
880+
881+
try:
882+
with patch.dict(os.environ, {"GITHUB_OUTPUT": github_output_path}):
883+
output_to_json(inactive_repos, MagicMock())
884+
885+
with open(github_output_path, "r", encoding="utf-8") as handle:
886+
contents = handle.read()
887+
self.assertIn("inactiveRepos=", contents)
888+
self.assertIn("https://github.com/example/repo", contents)
889+
finally:
890+
os.remove(github_output_path)
891+
892+
893+
class SetRepoDataGithubExceptionTestCase(unittest.TestCase):
894+
"""Cover the GitHubException branches in set_repo_data."""
895+
896+
def test_github_exception_on_release_is_logged(self):
897+
"""A GitHubException raised when fetching releases should be caught and
898+
logged; days_since_last_release should stay None."""
899+
repo = MagicMock(html_url="https://github.com/example/repo")
900+
repo.releases.side_effect = github3.exceptions.GitHubException("release boom")
901+
902+
with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
903+
result = set_repo_data(repo, 10, "2024-01-01", "public", ["release"])
904+
output = mock_stdout.getvalue()
905+
906+
self.assertIsNone(result["days_since_last_release"])
907+
self.assertIn(
908+
"https://github.com/example/repo had an exception trying to get the last release",
909+
output,
910+
)
911+
912+
def test_github_exception_on_pr_is_logged(self):
913+
"""A GitHubException raised when fetching PRs should be caught and
914+
logged; days_since_last_pr should stay None."""
915+
repo = MagicMock(html_url="https://github.com/example/repo")
916+
repo.pull_requests.side_effect = github3.exceptions.GitHubException("pr boom")
917+
918+
with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
919+
result = set_repo_data(repo, 10, "2024-01-01", "public", ["pr"])
920+
output = mock_stdout.getvalue()
921+
922+
self.assertIsNone(result["days_since_last_pr"])
923+
self.assertIn(
924+
"https://github.com/example/repo had an exception trying to get the last PR",
925+
output,
926+
)

0 commit comments

Comments
 (0)