|
19 | 19 | import io |
20 | 20 | import json |
21 | 21 | import os |
| 22 | +import tempfile |
22 | 23 | import unittest |
23 | 24 | from datetime import datetime, timedelta, timezone |
24 | 25 | from unittest.mock import MagicMock, call, patch |
|
31 | 32 | get_inactive_repos, |
32 | 33 | is_repo_exempt, |
33 | 34 | output_to_json, |
| 35 | + set_repo_data, |
34 | 36 | ) |
35 | 37 |
|
36 | 38 |
|
@@ -699,3 +701,226 @@ def test_report_exclusion_with_additional_metrics_not_configured(self): |
699 | 701 | }, |
700 | 702 | ] |
701 | 703 | 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