From 415402825a48d0cf6e458215f526846562a53a10 Mon Sep 17 00:00:00 2001 From: David Brownell Date: Wed, 8 Apr 2026 16:05:04 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20[+feature]=20Added=20GitHub=20relea?= =?UTF-8?q?se=20info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/AllGitStatus/MainApp.py | 4 +- src/AllGitStatus/Sources/GitHubSource.py | 105 ++++++ tests/MainApp_integration_test.py | 4 +- .../Sources/GitHubSource_integration_test.py | 12 +- tests/Sources/GitHubSource_test.py | 319 +++++++++++++++++- 5 files changed, 431 insertions(+), 13 deletions(-) diff --git a/src/AllGitStatus/MainApp.py b/src/AllGitStatus/MainApp.py index de0e616..243ffcb 100644 --- a/src/AllGitStatus/MainApp.py +++ b/src/AllGitStatus/MainApp.py @@ -45,7 +45,8 @@ class Column: PullRequestsColumn = Column(9, "PRs", "center") SecurityAlertsColumn = Column(10, "Security", "center") CICDStatusColumn = Column(11, "CI/CD", "center") -ArchivedColumn = Column(12, "Archived", "center") +ReleaseColumn = Column(12, "Release", "center") +ArchivedColumn = Column(13, "Archived", "center") COLUMN_MAP: dict[ tuple[ @@ -66,6 +67,7 @@ class Column: (GitHubSource.__name__, "pull_requests"): PullRequestsColumn, (GitHubSource.__name__, "security_alerts"): SecurityAlertsColumn, (GitHubSource.__name__, "cicd_status"): CICDStatusColumn, + (GitHubSource.__name__, "release"): ReleaseColumn, (GitHubSource.__name__, "archived"): ArchivedColumn, } diff --git a/src/AllGitStatus/Sources/GitHubSource.py b/src/AllGitStatus/Sources/GitHubSource.py index d953176..2c42fbf 100644 --- a/src/AllGitStatus/Sources/GitHubSource.py +++ b/src/AllGitStatus/Sources/GitHubSource.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from datetime import datetime, timedelta, UTC +from http import HTTPStatus import aiohttp @@ -62,6 +63,9 @@ async def Query(self, repo: Repository) -> AsyncGenerator[ResultInfo | ErrorInfo async for info in self._GenerateSecurityAlertInfo(repo, github_url): yield info + async for info in self._GenerateReleaseInfo(repo, github_url): + yield info + default_branch = persisted_info.get("default_branch") if default_branch: @@ -427,6 +431,107 @@ async def _GenerateCICDInfo( except Exception as ex: yield ErrorInfo(repo, key, ex) + # ---------------------------------------------------------------------- + async def _GenerateReleaseInfo( + self, + repo: Repository, + github_url: str, + ) -> AsyncGenerator[ResultInfo | ErrorInfo]: + key = (self.__class__.__name__, "release") + + try: + url = f"https://api.github.com/repos/{repo.github_owner}/{repo.github_repo}/releases/latest" + + async with self._session.get(url) as response: + if response.status == HTTPStatus.NOT_FOUND: + # No releases found + yield ResultInfo( + repo, + key, + "-", + textwrap.dedent( + """\ + Releases: {github_url}/releases + + No releases found. + """, + ).format(github_url=github_url), + ) + return + + response.raise_for_status() + release = await response.json() + + tag_name = release.get("tag_name", "unknown") + release_name = release.get("name", tag_name) + published_at = release.get("published_at", "") + is_prerelease = release.get("prerelease", False) + is_draft = release.get("draft", False) + html_url = release.get("html_url", f"{github_url}/releases/latest") + author = release.get("author", {}).get("login", "unknown") + + # Format the published date + if published_at: + try: + pub_date = datetime.fromisoformat(published_at) + date_str = pub_date.strftime("%Y-%m-%d") + except ValueError: + max_date_length = 10 + + date_str = ( + published_at[:max_date_length] + if len(published_at) >= max_date_length + else published_at + ) + else: + date_str = "unknown" + + # Build display value + if is_draft: + display_value = f"{tag_name} 📝" + elif is_prerelease: + display_value = f"{tag_name} 🚧" + else: + display_value = f"{tag_name} 🏷️" + + # Build additional info + additional_info_lines = [ + f"Releases: {github_url}/releases", + "", + "Latest Release:", + f" Tag: {tag_name}", + f" Name: {release_name}", + f" Published: {date_str}", + f" Author: {author}", + f" URL: {html_url}", + ] + + if is_draft: + additional_info_lines.append(" Status: Draft") + elif is_prerelease: + additional_info_lines.append(" Status: Pre-release") + else: + additional_info_lines.append(" Status: Stable") + + # Add asset information if available + if assets := release.get("assets", []): + additional_info_lines.extend(["", "Assets:"]) + + for asset in assets: + asset_name = asset.get("name", "unknown") + download_count = asset.get("download_count", 0) + additional_info_lines.append(f" - {asset_name} ({download_count} downloads)") + + yield ResultInfo( + repo, + key, + display_value, + "\n".join(additional_info_lines), + ) + + except Exception as ex: + yield ErrorInfo(repo, key, ex) + # ---------------------------------------------------------------------- async def _GeneratePaginatedResults(self, url: str) -> AsyncGenerator[dict]: params = {"state": "open", "per_page": 100} diff --git a/tests/MainApp_integration_test.py b/tests/MainApp_integration_test.py index 4e052f8..f393620 100644 --- a/tests/MainApp_integration_test.py +++ b/tests/MainApp_integration_test.py @@ -27,6 +27,7 @@ MainApp, NameColumn, PullRequestsColumn, + ReleaseColumn, RemoteColumn, SecurityAlertsColumn, StarsColumn, @@ -955,7 +956,7 @@ def test_cicd_status_column_properties(self) -> None: def test_archived_column_properties(self) -> None: """ArchivedColumn has correct properties.""" - assert ArchivedColumn.value == 12 + assert ArchivedColumn.value == 13 assert ArchivedColumn.name == "Archived" assert ArchivedColumn.justify == "center" @@ -978,6 +979,7 @@ def test_column_map_contains_all_columns(self) -> None: PullRequestsColumn, SecurityAlertsColumn, CICDStatusColumn, + ReleaseColumn, ArchivedColumn, } diff --git a/tests/Sources/GitHubSource_integration_test.py b/tests/Sources/GitHubSource_integration_test.py index 97a2931..4b516ac 100644 --- a/tests/Sources/GitHubSource_integration_test.py +++ b/tests/Sources/GitHubSource_integration_test.py @@ -194,7 +194,7 @@ async def test_query_returns_valid_results( # Verify all display values contain numeric counts (for ResultInfo items) for result in results: - if isinstance(result, ResultInfo) and result.key[1] not in ["cicd_status", "archived"]: + if isinstance(result, ResultInfo) and result.key[1] not in ["cicd_status", "archived", "release"]: number_part = result.display_value.split()[0] assert number_part.isdigit(), f"Expected number in '{result.display_value}'" @@ -221,15 +221,17 @@ async def test_query_nonexistent_repo_returns_error(self, session: aiohttp.Clien results = [info async for info in source.Query(repo)] - # Four API calls fail for nonexistent repos (stars, issues, pull_requests, security_alerts) + # Five API calls for nonexistent repos (stars, issues, pull_requests, security_alerts, release) # CI/CD is skipped because we don't have default_branch when repo API fails - assert len(results) == 4 + # Note: release returns ResultInfo with "-" when no releases found (404) + assert len(results) == 5 assert results[0].key == ("GitHubSource", "stars") assert results[1].key == ("GitHubSource", "issues") assert results[2].key == ("GitHubSource", "pull_requests") assert results[3].key == ("GitHubSource", "security_alerts") - # All results are errors - assert all(isinstance(r, ErrorInfo) for r in results) + assert results[4].key == ("GitHubSource", "release") + # First four results are errors, release may be a ResultInfo (404 handled gracefully) + assert all(isinstance(r, ErrorInfo) for r in results[:4]) # ---------------------------------------------------------------------- diff --git a/tests/Sources/GitHubSource_test.py b/tests/Sources/GitHubSource_test.py index 5062a1a..44b38ab 100644 --- a/tests/Sources/GitHubSource_test.py +++ b/tests/Sources/GitHubSource_test.py @@ -1974,6 +1974,7 @@ async def test_other_results_not_affected_by_security_error(self, github_repo: R create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response({}, status=403), # Security alerts API error + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": []}), # CI/CD API ] session = create_mock_session(responses) @@ -1981,12 +1982,12 @@ async def test_other_results_not_affected_by_security_error(self, github_repo: R results = [info async for info in source.Query(github_repo)] - # Should have 8 results total (stars, forks, watchers, issues, PRs, security_alerts error, cicd_status, archived) - assert len(results) == 8 + # Should have 9 results total (stars, forks, watchers, issues, PRs, security_alerts error, release, cicd_status, archived) + assert len(results) == 9 - # Stars, forks, issues, watchers, PRs, cicd_status, archived should all be ResultInfo + # Stars, forks, issues, watchers, PRs, release, cicd_status, archived should all be ResultInfo non_security_results = [r for r in results if r.key[1] != "security_alerts"] - assert len(non_security_results) == 7 + assert len(non_security_results) == 8 assert all(isinstance(r, ResultInfo) for r in non_security_results) @@ -2124,6 +2125,7 @@ async def test_no_workflows_shows_neutral_icon(self, github_repo: Repository) -> create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": []}), # CI/CD API - no workflows ] session = create_mock_session(responses) @@ -2177,6 +2179,7 @@ async def test_all_success_shows_checkmark(self, github_repo: Repository) -> Non create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2222,6 +2225,7 @@ async def test_any_failure_shows_x_icon(self, github_repo: Repository) -> None: create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2278,6 +2282,7 @@ async def test_failure_takes_precedence_over_success(self, github_repo: Reposito create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2318,6 +2323,7 @@ async def test_cancelled_shows_x_icon(self, github_repo: Repository) -> None: create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2363,6 +2369,7 @@ async def test_in_progress_shows_hourglass(self, github_repo: Repository) -> Non create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2411,6 +2418,7 @@ async def test_in_progress_takes_precedence_over_success(self, github_repo: Repo create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2459,6 +2467,7 @@ async def test_failure_takes_precedence_over_in_progress(self, github_repo: Repo create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2504,6 +2513,7 @@ async def test_additional_info_contains_actions_url(self, github_repo: Repositor create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2544,6 +2554,7 @@ async def test_additional_info_shows_default_branch(self, github_repo: Repositor create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2592,6 +2603,7 @@ async def test_additional_info_shows_workflow_names(self, github_repo: Repositor create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2650,6 +2662,7 @@ async def test_additional_info_shows_summary_counts(self, github_repo: Repositor create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({"workflow_runs": workflow_runs}), # CI/CD API ] session = create_mock_session(responses) @@ -2687,6 +2700,7 @@ async def test_api_error_returns_error_info(self, github_repo: Repository) -> No create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({}, status=403), # CI/CD API - Forbidden ] session = create_mock_session(responses) @@ -2716,6 +2730,7 @@ async def test_other_results_not_affected_by_cicd_error(self, github_repo: Repos create_mock_response([]), # Issues API create_mock_response([]), # PRs API create_mock_response([]), # Security alerts API + create_mock_response({"tag_name": "v1.0.0"}), # Release API create_mock_response({}, status=500), # CI/CD API - Server error ] session = create_mock_session(responses) @@ -2723,7 +2738,299 @@ async def test_other_results_not_affected_by_cicd_error(self, github_repo: Repos results = [info async for info in source.Query(github_repo)] - # Stars, forks, watchers, issues, PRs, security_alerts should all be ResultInfo - for key in ["stars", "forks", "watchers", "issues", "pull_requests", "security_alerts"]: + # Stars, forks, watchers, issues, PRs, security_alerts, release should all be ResultInfo + for key in ["stars", "forks", "watchers", "issues", "pull_requests", "security_alerts", "release"]: result = next(r for r in results if r.key[1] == key) assert isinstance(result, ResultInfo) + + +# ---------------------------------------------------------------------- +class TestRelease: + """Tests for GitHub release information.""" + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_release_result_has_correct_key(self, github_repo: Repository) -> None: + """Release result has the correct key structure.""" + + responses = [ + create_mock_response( + { + "stargazers_count": 10, + "forks_count": 5, + "subscribers_count": 3, + "default_branch": "main", + } + ), + create_mock_response([]), # Issues API + create_mock_response([]), # PRs API + create_mock_response([]), # Security alerts API + create_mock_response( + { + "tag_name": "v1.2.3", + "name": "Release 1.2.3", + "published_at": "2024-01-15T10:00:00Z", + "prerelease": False, + "draft": False, + "html_url": "https://github.com/owner/repo/releases/tag/v1.2.3", + "author": {"login": "testuser"}, + "assets": [], + } + ), # Release API + ] + session = create_mock_session(responses) + source = GitHubSource(session) + + results = [info async for info in source.Query(github_repo)] + + release_result = next(r for r in results if r.key[1] == "release") + + assert isinstance(release_result, ResultInfo) + assert release_result.key == ("GitHubSource", "release") + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_release_display_value_format(self, github_repo: Repository) -> None: + """Release display value contains tag name and label icon.""" + + responses = [ + create_mock_response( + { + "stargazers_count": 10, + "forks_count": 5, + "subscribers_count": 3, + "default_branch": "main", + } + ), + create_mock_response([]), # Issues API + create_mock_response([]), # PRs API + create_mock_response([]), # Security alerts API + create_mock_response( + { + "tag_name": "v2.0.0", + "name": "Version 2.0.0", + "published_at": "2024-06-01T12:00:00Z", + "prerelease": False, + "draft": False, + "html_url": "https://github.com/owner/repo/releases/tag/v2.0.0", + "author": {"login": "releasebot"}, + } + ), # Release API + ] + session = create_mock_session(responses) + source = GitHubSource(session) + + results = [info async for info in source.Query(github_repo)] + + release_result = next(r for r in results if r.key[1] == "release") + + assert isinstance(release_result, ResultInfo) + assert "v2.0.0" in release_result.display_value + assert "🏷️" in release_result.display_value + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_prerelease_shows_construction_icon(self, github_repo: Repository) -> None: + """Pre-release display value shows construction icon.""" + + responses = [ + create_mock_response( + { + "stargazers_count": 10, + "forks_count": 5, + "subscribers_count": 3, + "default_branch": "main", + } + ), + create_mock_response([]), # Issues API + create_mock_response([]), # PRs API + create_mock_response([]), # Security alerts API + create_mock_response( + { + "tag_name": "v3.0.0-beta.1", + "name": "Beta 1", + "published_at": "2024-07-01T12:00:00Z", + "prerelease": True, + "draft": False, + "html_url": "https://github.com/owner/repo/releases/tag/v3.0.0-beta.1", + "author": {"login": "testuser"}, + } + ), # Release API + ] + session = create_mock_session(responses) + source = GitHubSource(session) + + results = [info async for info in source.Query(github_repo)] + + release_result = next(r for r in results if r.key[1] == "release") + + assert isinstance(release_result, ResultInfo) + assert "v3.0.0-beta.1" in release_result.display_value + assert "🚧" in release_result.display_value + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_draft_shows_memo_icon(self, github_repo: Repository) -> None: + """Draft release display value shows memo icon.""" + + responses = [ + create_mock_response( + { + "stargazers_count": 10, + "forks_count": 5, + "subscribers_count": 3, + "default_branch": "main", + } + ), + create_mock_response([]), # Issues API + create_mock_response([]), # PRs API + create_mock_response([]), # Security alerts API + create_mock_response( + { + "tag_name": "v4.0.0", + "name": "Upcoming Release", + "published_at": None, + "prerelease": False, + "draft": True, + "html_url": "https://github.com/owner/repo/releases/tag/v4.0.0", + "author": {"login": "testuser"}, + } + ), # Release API + ] + session = create_mock_session(responses) + source = GitHubSource(session) + + results = [info async for info in source.Query(github_repo)] + + release_result = next(r for r in results if r.key[1] == "release") + + assert isinstance(release_result, ResultInfo) + assert "v4.0.0" in release_result.display_value + assert "📝" in release_result.display_value + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_no_releases_shows_dash(self, github_repo: Repository) -> None: + """No releases returns dash display value.""" + + # Create a special mock for 404 that doesn't raise (so we can check status first) + release_404_response = MagicMock() + release_404_response.status = 404 + release_404_response.json = AsyncMock(return_value={}) + release_404_response.raise_for_status = MagicMock() # Doesn't raise for 404 check + release_404_response.headers = {} + + responses = [ + create_mock_response( + { + "stargazers_count": 10, + "forks_count": 5, + "subscribers_count": 3, + "default_branch": "main", + } + ), + create_mock_response([]), # Issues API + create_mock_response([]), # PRs API + create_mock_response([]), # Security alerts API + release_404_response, # Release API - 404 Not Found (handled gracefully) + ] + session = create_mock_session(responses) + source = GitHubSource(session) + + results = [info async for info in source.Query(github_repo)] + + release_result = next(r for r in results if r.key[1] == "release") + + assert isinstance(release_result, ResultInfo) + assert release_result.display_value == "-" + assert "No releases found" in cast(str, release_result.additional_info) + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_release_additional_info_contains_details(self, github_repo: Repository) -> None: + """Release additional info contains release details.""" + + responses = [ + create_mock_response( + { + "stargazers_count": 10, + "forks_count": 5, + "subscribers_count": 3, + "default_branch": "main", + } + ), + create_mock_response([]), # Issues API + create_mock_response([]), # PRs API + create_mock_response([]), # Security alerts API + create_mock_response( + { + "tag_name": "v1.0.0", + "name": "First Release", + "published_at": "2024-03-15T10:30:00Z", + "prerelease": False, + "draft": False, + "html_url": "https://github.com/owner/repo/releases/tag/v1.0.0", + "author": {"login": "releasemaker"}, + } + ), # Release API + ] + session = create_mock_session(responses) + source = GitHubSource(session) + + results = [info async for info in source.Query(github_repo)] + + release_result = next(r for r in results if r.key[1] == "release") + + assert isinstance(release_result, ResultInfo) + additional_info = cast(str, release_result.additional_info) + assert "Tag: v1.0.0" in additional_info + assert "Name: First Release" in additional_info + assert "Published: 2024-03-15" in additional_info + assert "Author: releasemaker" in additional_info + assert "Status: Stable" in additional_info + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_release_with_assets_shows_downloads(self, github_repo: Repository) -> None: + """Release with assets shows download counts.""" + + responses = [ + create_mock_response( + { + "stargazers_count": 10, + "forks_count": 5, + "subscribers_count": 3, + "default_branch": "main", + } + ), + create_mock_response([]), # Issues API + create_mock_response([]), # PRs API + create_mock_response([]), # Security alerts API + create_mock_response( + { + "tag_name": "v1.5.0", + "name": "Release with Assets", + "published_at": "2024-05-01T08:00:00Z", + "prerelease": False, + "draft": False, + "html_url": "https://github.com/owner/repo/releases/tag/v1.5.0", + "author": {"login": "testuser"}, + "assets": [ + {"name": "app-linux.tar.gz", "download_count": 1500}, + {"name": "app-windows.zip", "download_count": 2300}, + ], + } + ), # Release API + ] + session = create_mock_session(responses) + source = GitHubSource(session) + + results = [info async for info in source.Query(github_repo)] + + release_result = next(r for r in results if r.key[1] == "release") + + assert isinstance(release_result, ResultInfo) + additional_info = cast(str, release_result.additional_info) + assert "Assets:" in additional_info + assert "app-linux.tar.gz (1500 downloads)" in additional_info + assert "app-windows.zip (2300 downloads)" in additional_info