From 8f8ec0653aa198c523ac36e85fd661ca4c2583d9 Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Fri, 7 Jun 2024 21:59:52 -0700 Subject: [PATCH] feat: additional metrics (last release and last pr) Signed-off-by: Zack Koppert --- .env-example | 15 +-- README.md | 12 +- stale_repos.py | 158 ++++++++++++++++++++---- test_stale_repos.py | 288 ++++++++++++++++++++++++++++++-------------- 4 files changed, 348 insertions(+), 125 deletions(-) diff --git a/.env-example b/.env-example index 9f159af..a6d6c81 100644 --- a/.env-example +++ b/.env-example @@ -1,7 +1,8 @@ -GH_APP_ID=' ' -GH_APP_INSTALLATION_ID=' ' -GH_APP_PRIVATE_KEY=' ' -GH_ENTERPRISE_URL=' ' -GH_TOKEN=' ' -INACTIVE_DAYS=365 -ORGANIZATION=' ' +ADDITIONAL_METRICS = "" +GH_APP_ID = "" +GH_APP_INSTALLATION_ID = "" +GH_APP_PRIVATE_KEY = "" +GH_ENTERPRISE_URL = "" +GH_TOKEN = "" +INACTIVE_DAYS = 365 +ORGANIZATION = "" diff --git a/README.md b/README.md index 1524a0c..535c2de 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - # Stale Repos Action [![Lint Code Base](https://github.com/github/stale-repos/actions/workflows/linter.yaml/badge.svg)](https://github.com/github/stale-repos/actions/workflows/linter.yaml) @@ -68,6 +67,7 @@ This action can be configured to authenticate with GitHub App Installation or Pe | `EXEMPT_REPOS` | false | | Comma separated list of repositories to exempt from being flagged as stale. Supports Unix shell-style wildcards. ie. `EXEMPT_REPOS = "stale-repos,test-repo,conf-*"` | | `EXEMPT_TOPICS` | false | | Comma separated list of topics to exempt from being flagged as stale | | `ORGANIZATION` | false | | The organization to scan for stale repositories. If no organization is provided, this tool will search through repositories owned by the GH_TOKEN owner | +| `ADDITIONAL_METRICS` | false | | Configure additional metrics like days since last release or days since last pull request. This allows for more detailed reporting on repository activity. To include both metrics, set `ADDITIONAL_METRICS: "release,pr"` | ### Example workflow @@ -102,6 +102,7 @@ jobs: EXEMPT_TOPICS: "keep,template" INACTIVE_DAYS: 365 ACTIVITY_METHOD: "pushed" + ADDITIONAL_METRICS: "release,pr" # This next step updates an existing issue. If you want a new issue every time, remove this step and remove the `issue-number: ${{ env.issue_number }}` line below. - name: Check for the stale report issue @@ -129,9 +130,9 @@ jobs: The following repos have not had a push event for more than 3 days: -| Repository URL | Days Inactive | Last Push Date | Visibility | -| --- | ---: | ---: | ---: | -| https://github.com/github/.github | 5 | 2020-1-30 | private | +| Repository URL | Days Inactive | Last Push Date | Visibility | Days Since Last Release | Days Since Last PR | +| --- | ---: | ---: | ---: | ---: | ---: | +| https://github.com/github/.github | 5 | 2020-1-30 | private | 10 | 7 | ``` ### Using JSON instead of Markdown @@ -165,6 +166,7 @@ jobs: ORGANIZATION: ${{ secrets.ORGANIZATION }} EXEMPT_TOPICS: "keep,template" INACTIVE_DAYS: 365 + ADDITIONAL_METRICS: "release,pr" - name: Print output of stale_repos tool run: echo "${{ steps.stale-repos.outputs.inactiveRepos }}" @@ -212,6 +214,7 @@ jobs: GH_TOKEN: ${{ secrets.GH_TOKEN }} ORGANIZATION: ${{ matrix.org }} INACTIVE_DAYS: 365 + ADDITIONAL_METRICS: "release,pr" ``` ### Authenticating with a GitHub App and Installation @@ -245,6 +248,7 @@ jobs: EXEMPT_TOPICS: "keep,template" INACTIVE_DAYS: 365 ACTIVITY_METHOD: "pushed" + ADDITIONAL_METRICS: "release,pr" ``` ## Local usage without Docker diff --git a/stale_repos.py b/stale_repos.py index e9b3368..3e86db4 100755 --- a/stale_repos.py +++ b/stale_repos.py @@ -47,15 +47,18 @@ def main(): # pragma: no cover "ORGANIZATION environment variable not set, searching all repos owned by token owner" ) + # Fetch additional metrics configuration + additional_metrics = os.getenv("ADDITIONAL_METRICS", "").split(",") + # Iterate over repos in the org, acquire inactive days, # and print out the repo url and days inactive if it's over the threshold (inactive_days) inactive_repos = get_inactive_repos( - github_connection, inactive_days_threshold, organization + github_connection, inactive_days_threshold, organization, additional_metrics ) if inactive_repos: output_to_json(inactive_repos) - write_to_markdown(inactive_repos, inactive_days_threshold) + write_to_markdown(inactive_repos, inactive_days_threshold, additional_metrics) else: print("No stale repos found") @@ -91,7 +94,9 @@ def is_repo_exempt(repo, exempt_repos, exempt_topics): return False -def get_inactive_repos(github_connection, inactive_days_threshold, organization): +def get_inactive_repos( + github_connection, inactive_days_threshold, organization, additional_metrics=None +): """Return and print out the repo url and days inactive if it's over the threshold (inactive_days). @@ -99,6 +104,7 @@ def get_inactive_repos(github_connection, inactive_days_threshold, organization) github_connection: The GitHub connection object. inactive_days_threshold: The threshold (in days) for considering a repo as inactive. organization: The name of the organization to retrieve repositories from. + additional_metrics: A list of additional metrics to include in the report. Returns: A list of tuples containing the repo, days inactive, the date of the last push and @@ -137,10 +143,10 @@ def get_inactive_repos(github_connection, inactive_days_threshold, organization) days_inactive = (datetime.now(timezone.utc) - active_date).days visibility = "private" if repo.private else "public" if days_inactive > int(inactive_days_threshold): - inactive_repos.append( - (repo.html_url, days_inactive, active_date_disp, visibility) + repo_data = set_repo_data( + repo, days_inactive, active_date_disp, visibility, additional_metrics ) - print(f"{repo.html_url}: {days_inactive} days inactive") # type: ignore + inactive_repos.append(repo_data) if organization: print(f"Found {len(inactive_repos)} stale repos in {organization}") else: @@ -148,6 +154,38 @@ def get_inactive_repos(github_connection, inactive_days_threshold, organization) return inactive_repos +def get_days_since_last_release(repo): + """Get the number of days since the last release of the repository. + + Args: + repo: A Github repository object. + + Returns: + The number of days since the last release. + """ + try: + last_release = next(repo.releases()) + return (datetime.now(timezone.utc) - last_release.created_at).days + except StopIteration: + return None + + +def get_days_since_last_pr(repo): + """Get the number of days since the last pull request was made in the repository. + + Args: + repo: A Github repository object. + + Returns: + The number of days since the last pull request was made. + """ + try: + last_pr = next(repo.pull_requests(state="all")) + return (datetime.now(timezone.utc) - last_pr.created_at).days + except StopIteration: + return None + + def get_active_date(repo): """Get the last activity date of the repository. @@ -180,17 +218,23 @@ def get_active_date(repo): return active_date -def write_to_markdown(inactive_repos, inactive_days_threshold, file=None): +def write_to_markdown( + inactive_repos, inactive_days_threshold, additional_metrics=None, file=None +): """Write the list of inactive repos to a markdown file. Args: - inactive_repos: A list of tuples containing the repo, days inactive, - the date of the last push, and repository visibility (public/private). + inactive_repos: A list of dictionaries containing the repo, days inactive, + the date of the last push, repository visibility (public/private), + days since the last release, and days since the last pr inactive_days_threshold: The threshold (in days) for considering a repo as inactive. + additional_metrics: A list of additional metrics to include in the report. file: A file object to write to. If None, a new file will be created. """ - inactive_repos.sort(key=lambda x: x[1], reverse=True) + inactive_repos = sorted( + inactive_repos, key=lambda x: x["days_inactive"], reverse=True + ) with file or open("stale_repos.md", "w", encoding="utf-8") as markdown_file: markdown_file.write("# Inactive Repositories\n\n") markdown_file.write( @@ -198,13 +242,33 @@ def write_to_markdown(inactive_repos, inactive_days_threshold, file=None): f"{inactive_days_threshold} days:\n\n" ) markdown_file.write( - "| Repository URL | Days Inactive | Last Push Date | Visibility |\n" + "| Repository URL | Days Inactive | Last Push Date | Visibility |" ) - markdown_file.write("| --- | --- | --- | ---: |\n") - for repo_url, days_inactive, last_push_date, visibility in inactive_repos: + # Include additional metrics columns if configured + if additional_metrics: + if "release" in additional_metrics: + markdown_file.write(" Days Since Last Release |") + if "pr" in additional_metrics: + markdown_file.write(" Days Since Last PR |") + markdown_file.write("\n| --- | --- | --- | ---: |") + if additional_metrics and ( + "release" in additional_metrics or "pr" in additional_metrics + ): + markdown_file.write(" ---: |") + markdown_file.write("\n") + for repo_data in inactive_repos: markdown_file.write( - f"| {repo_url} | {days_inactive} | {last_push_date} | {visibility} |\n" + f"| {repo_data['url']} \ +| {repo_data['days_inactive']} \ +| {repo_data['last_push_date']} \ +| {repo_data['visibility']} |" ) + if additional_metrics: + if "release" in additional_metrics: + markdown_file.write(f" {repo_data['days_since_last_release']} |") + if "pr" in additional_metrics: + markdown_file.write(f" {repo_data['days_since_last_pr']} |") + markdown_file.write("\n") print("Wrote stale repos to stale_repos.md") @@ -212,9 +276,10 @@ def output_to_json(inactive_repos, file=None): """Convert the list of inactive repos to a json string. Args: - inactive_repos: A list of tuples containing the repo, - days inactive, the date of the last push, and - visiblity of the repository (public/private). + inactive_repos: A list of dictionaries containing the repo, + days inactive, the date of the last push, + visiblity of the repository (public/private), + days since the last release, and days since the last pr. Returns: JSON formatted string of the list of inactive repos. @@ -226,18 +291,23 @@ def output_to_json(inactive_repos, file=None): # "url": "https://github.com/owner/repo", # "daysInactive": 366, # "lastPushDate": "2020-01-01" + # "daysSinceLastRelease": "5" + # "daysSinceLastPR": "10" # } # ] inactive_repos_json = [] - for repo_url, days_inactive, last_push_date, visibility in inactive_repos: - inactive_repos_json.append( - { - "url": repo_url, - "daysInactive": days_inactive, - "lastPushDate": last_push_date, - "visibility": visibility, - } - ) + for repo_data in inactive_repos: + repo_json = { + "url": repo_data["url"], + "daysInactive": repo_data["days_inactive"], + "lastPushDate": repo_data["last_push_date"], + "visibility": repo_data["visibility"], + } + if "release" in repo_data: + repo_json["daysSinceLastRelease"] = repo_data["days_since_last_release"] + if "pr" in repo_data: + repo_json["daysSinceLastPR"] = repo_data["days_since_last_pr"] + inactive_repos_json.append(repo_json) inactive_repos_json = json.dumps(inactive_repos_json) # add output to github action output @@ -298,5 +368,41 @@ def auth_to_github(): return github_connection # type: ignore +def set_repo_data( + repo, days_inactive, active_date_disp, visibility, additional_metrics +): + """ + Constructs a dictionary with repository data + including optional metrics based on additional metrics specified. + + Args: + repo: The repository object. + days_inactive: Number of days the repository has been inactive. + active_date_disp: The display string of the last active date. + visibility: The visibility status of the repository (e.g., private or public). + additional_metrics: A list of strings indicating which additional metrics to include. + + Returns: + A dictionary with the repository data. + """ + repo_data = { + "url": repo.html_url, + "days_inactive": days_inactive, + "last_push_date": active_date_disp, + "visibility": visibility, + } + # Fetch and include additional metrics if configured + repo_data["days_since_last_release"] = None + repo_data["days_since_last_pr"] = None + if additional_metrics: + if "release" in additional_metrics: + repo_data["days_since_last_release"] = get_days_since_last_release(repo) + if "pr" in additional_metrics: + repo_data["days_since_last_pr"] = get_days_since_last_pr(repo) + + print(f"{repo.html_url}: {days_inactive} days inactive") # type: ignore + return repo_data + + if __name__ == "__main__": main() diff --git a/test_stale_repos.py b/test_stale_repos.py index 30d3a49..6244a0b 100644 --- a/test_stale_repos.py +++ b/test_stale_repos.py @@ -27,6 +27,8 @@ from stale_repos import ( auth_to_github, get_active_date, + get_days_since_last_pr, + get_days_since_last_release, get_inactive_repos, get_int_env_var, is_repo_exempt, @@ -47,17 +49,6 @@ class AuthToGithubTestCase(unittest.TestCase): enterprise URL and token, authentication with only a token, missing environment variables, and authentication failures. - Test methods: - - test_auth_to_github_app_with_github_app_installation_env_vars: Tests authencation - to GitHub application with app ID, app private key, and app installation ID. - - test_auth_to_github_with_enterprise_url_and_token: Tests authentication with both - enterprise URL and token. - - test_auth_to_github_with_token: Tests authentication with only a token. - - test_auth_to_github_without_environment_variables: Tests authentication with - missing environment variables. - - test_auth_to_github_without_enterprise_url: Tests authentication without an - enterprise URL. - - test_auth_to_github_authentication_failure: Tests authentication failure. """ @@ -323,12 +314,14 @@ def test_get_inactive_repos_with_inactive_repos(self): # Check that the function returns the expected list of inactive repos expected_inactive_repos = [ - ( - "https://github.com/example/repo2", - 40, - forty_days_ago.date().isoformat(), - "private", - ), + { + "url": "https://github.com/example/repo2", + "days_inactive": 40, + "last_push_date": "2024-04-29", + "visibility": "private", + "days_since_last_release": None, + "days_since_last_pr": None, + } ] assert inactive_repos == expected_inactive_repos @@ -472,12 +465,14 @@ def test_get_inactive_repos_with_no_organization_set(self): # Check that the function returns the expected list of inactive repos expected_inactive_repos = [ - ( - "https://github.com/example/repo2", - 40, - forty_days_ago.date().isoformat(), - "private", - ), + { + "url": "https://github.com/example/repo2", + "days_inactive": 40, + "last_push_date": "2024-04-29", + "visibility": "private", + "days_since_last_release": None, + "days_since_last_pr": None, + } ] assert inactive_repos == expected_inactive_repos @@ -546,12 +541,14 @@ def test_get_inactive_repos_with_default_branch_updated(self): # Check that the function returns the expected list of inactive repos expected_inactive_repos = [ - ( - "https://github.com/example/repo2", - 40, - forty_days_ago.date().isoformat(), - "private", - ), + { + "url": "https://github.com/example/repo2", + "days_inactive": 40, + "last_push_date": "2024-04-29", + "visibility": "private", + "days_since_last_release": None, + "days_since_last_pr": None, + } ] assert inactive_repos == expected_inactive_repos @@ -572,20 +569,20 @@ def test_write_to_markdown(self): """ forty_days_ago = datetime.now(timezone.utc) - timedelta(days=40) thirty_days_ago = datetime.now(timezone.utc) - timedelta(days=30) - # Create a list of inactive repos + # Create an unsorted list of inactive repos inactive_repos = [ - ( - "https://github.com/example/repo2", - 40, - forty_days_ago.date().isoformat(), - "public", - ), - ( - "https://github.com/example/repo1", - 30, - thirty_days_ago.date().isoformat(), - "private", - ), + { + "url": "https://github.com/example/repo1", + "days_inactive": 30, + "last_push_date": thirty_days_ago.date().isoformat(), + "visibility": "private", + }, + { + "url": "https://github.com/example/repo2", + "days_inactive": 40, + "last_push_date": forty_days_ago.date().isoformat(), + "visibility": "public", + }, ] inactive_days_threshold = 365 @@ -603,19 +600,20 @@ def test_write_to_markdown(self): "The following repos have not had a push event for more than 365 days:\n\n" ), call.write( - "| Repository URL | Days Inactive | Last Push Date | Visibility |\n" + "| Repository URL | Days Inactive | Last Push Date | Visibility |" ), - call.write("| --- | --- | --- | ---: |\n"), + call.write("\n| --- | --- | --- | ---: |"), + call.write("\n"), call.write( - f"| https://github.com/example/repo2 | 40 | " - f"{forty_days_ago.date().isoformat()} | " - f"public |\n" + f"| https://github.com/example/repo2 | 40 |\ + {forty_days_ago.date().isoformat()} | public |" ), + call.write("\n"), call.write( - f"| https://github.com/example/repo1 | 30 | " - f"{thirty_days_ago.date().isoformat()} | " - f"private |\n" + f"| https://github.com/example/repo1 | 30 |\ + {thirty_days_ago.date().isoformat()} | private |" ), + call.write("\n"), ] mock_file.__enter__.return_value.assert_has_calls(expected_calls) @@ -661,24 +659,30 @@ def test_output_to_json(self): twenty_nine_days_ago = datetime.now(timezone.utc) - timedelta(days=30) # Create a list of inactive repos inactive_repos = [ - ( - "https://github.com/example/repo1", - 31, - thirty_one_days_ago.date().isoformat(), - "private", - ), - ( - "https://github.com/example/repo2", - 30, - thirty_days_ago.date().isoformat(), - "private", - ), - ( - "https://github.com/example/repo3", - 29, - twenty_nine_days_ago.date().isoformat(), - "public", - ), + { + "url": "https://github.com/example/repo1", + "days_inactive": 31, + "last_push_date": thirty_one_days_ago.date().isoformat(), + "visibility": "private", + "days_since_last_release": 3, + "days_since_last_pr": 2, + }, + { + "url": "https://github.com/example/repo2", + "days_inactive": 30, + "last_push_date": thirty_days_ago.date().isoformat(), + "visibility": "private", + "days_since_last_release": 1, + "days_since_last_pr": None, + }, + { + "url": "https://github.com/example/repo3", + "days_inactive": 29, + "last_push_date": twenty_nine_days_ago.date().isoformat(), + "visibility": "public", + "days_since_last_release": None, + "days_since_last_pr": 5, + }, ] # Call the output_to_json function with the list of inactive repos @@ -718,24 +722,24 @@ def test_json_file(self): twenty_nine_days_ago = datetime.now(timezone.utc) - timedelta(days=30) # Create a list of inactive repos inactive_repos = [ - ( - "https://github.com/example/repo1", - 31, - thirty_one_days_ago.date().isoformat(), - "private", - ), - ( - "https://github.com/example/repo2", - 30, - thirty_days_ago.date().isoformat(), - "private", - ), - ( - "https://github.com/example/repo3", - 29, - twenty_nine_days_ago.date().isoformat(), - "public", - ), + { + "url": "https://github.com/example/repo1", + "days_inactive": 31, + "last_push_date": thirty_one_days_ago.date().isoformat(), + "visibility": "private", + }, + { + "url": "https://github.com/example/repo2", + "days_inactive": 30, + "last_push_date": thirty_days_ago.date().isoformat(), + "visibility": "private", + }, + { + "url": "https://github.com/example/repo3", + "days_inactive": 29, + "last_push_date": twenty_nine_days_ago.date().isoformat(), + "visibility": "public", + }, ] # Call the output_to_json function with the list of inactive repos @@ -850,5 +854,113 @@ def test_not_found_error(self): self.assertFalse(result) -if __name__ == "__main__": - unittest.main() +class TestAdditionalMetrics(unittest.TestCase): + """ + Test suite for verifying the correct calculation and inclusion of days since last release + and last PR made in the report. + """ + + def test_days_since_last_release(self): + """ + Test that the days since the last release + is correctly calculated and included in the report. + """ + # Mock repository with a release date 10 days ago + thirty_days_ago = datetime.now(timezone.utc) - timedelta(days=30) + mock_repo = MagicMock() + mock_repo.releases.return_value.__next__.return_value.created_at = ( + thirty_days_ago + ) + + # Calculate days since last release + days_since_last_release = get_days_since_last_release(mock_repo) + + self.assertEqual(days_since_last_release, 30) + + def test_days_since_last_pr(self): + """ + Test that the days since the last PR made + is correctly calculated and included in the report. + """ + # Mock repository with a PR date 20 days ago + twenty_days_ago = datetime.now(timezone.utc) - timedelta(days=20) + mock_repo = MagicMock() + mock_repo.pull_requests.return_value.__next__.return_value.created_at = ( + twenty_days_ago + ) + + # Calculate days since last PR + days_since_last_pr = get_days_since_last_pr(mock_repo) + + self.assertEqual(days_since_last_pr, 20) + + def test_report_inclusion_with_additional_metrics_configured(self): + """ + Test that the report includes additional metrics when they are configured. + """ + # Mock repository with a release date 10 days ago and a PR date 5 days ago + ten_days_ago = datetime.now(timezone.utc) - timedelta(days=10) + five_days_ago = datetime.now(timezone.utc) - timedelta(days=5) + forty_days_ago = datetime.now(timezone.utc) - timedelta(days=40) + mock_repo = MagicMock( + html_url="https://github.com/example/repo", + pushed_at=forty_days_ago.isoformat(), + archived=False, + ) + mock_repo.releases.return_value.__next__.return_value.created_at = ten_days_ago + mock_repo.pull_requests.return_value.__next__.return_value.created_at = ( + five_days_ago + ) + + # Mock GitHub connection + mock_github = MagicMock() + mock_github.organization.return_value.repositories.return_value = [mock_repo] + + # Generate report with additional metrics configured + inactive_repos = get_inactive_repos( + mock_github, 30, "example", ["release", "pr"] + ) + + # Check that the report includes the additional metrics + expected_inactive_repos = [ + { + "url": "https://github.com/example/repo", + "days_inactive": 40, + "last_push_date": forty_days_ago.date().isoformat(), + "visibility": "private", + "days_since_last_release": 10, + "days_since_last_pr": 5, + }, + ] + self.assertEqual(inactive_repos, expected_inactive_repos) + + def test_report_exclusion_with_additional_metrics_not_configured(self): + """ + Test that the report excludes additional metrics when they are not configured. + """ + forty_days_ago = datetime.now(timezone.utc) - timedelta(days=40) + mock_repo = MagicMock( + html_url="https://github.com/example/repo", + pushed_at=forty_days_ago.isoformat(), + archived=False, + ) + + # Mock GitHub connection + mock_github = MagicMock() + mock_github.organization.return_value.repositories.return_value = [mock_repo] + + # Generate report without additional metrics configured + inactive_repos = get_inactive_repos(mock_github, 30, "example", []) + + # Check that the report excludes the additional metrics + expected_inactive_repos = [ + { + "url": "https://github.com/example/repo", + "days_inactive": 40, + "last_push_date": forty_days_ago.date().isoformat(), + "visibility": "private", + "days_since_last_release": None, + "days_since_last_pr": None, + }, + ] + self.assertEqual(inactive_repos, expected_inactive_repos)