Source code for tests.test_lint_repo

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Tests for repository linting module."""

from __future__ import annotations

import json
from unittest.mock import patch

from repomatic.github.token import (
    PatPermissionResults,
    check_pat_contents_permission,
    check_pat_issues_permission,
    check_pat_pull_requests_permission,
    check_pat_vulnerability_alerts_permission,
    check_pat_workflows_permission,
)
from repomatic.lint_repo import (
    check_description_matches,
    check_funding_file,
    check_package_name_vs_repo,
    check_stale_draft_releases,
    check_topics_subset_of_keywords,
    check_website_for_sphinx,
    get_repo_metadata,
    run_repo_lint,
)


[docs] def test_successful_fetch(): """Fetch and parse repo metadata.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = ( '{"homepageUrl": "https://example.com", "description": "A package"}' ) result = get_repo_metadata("owner/repo") assert result == { "homepageUrl": "https://example.com", "description": "A package", }
[docs] def test_empty_fields(): """Handle empty fields.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = '{"homepageUrl": "", "description": ""}' result = get_repo_metadata("owner/repo") assert result == {"homepageUrl": None, "description": None}
[docs] def test_api_failure(): """Handle API failure.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("gh command failed") result = get_repo_metadata("owner/repo") assert result == {"homepageUrl": None, "description": None}
[docs] def test_json_parse_error(): """Handle JSON parse error.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = "not json" result = get_repo_metadata("owner/repo") assert result == {"homepageUrl": None, "description": None}
[docs] def test_names_match(): """No warning when names match.""" warning, msg = check_package_name_vs_repo("my-package", "my-package") assert warning is None assert "matches" in msg
[docs] def test_names_differ(): """Warning when names differ.""" warning, _msg = check_package_name_vs_repo("my-package", "my-repo") assert warning is not None assert "differs" in warning assert "my-package" in warning assert "my-repo" in warning
[docs] def test_no_package_name(): """Skip when no package name.""" warning, msg = check_package_name_vs_repo(None, "my-repo") assert warning is None assert "skipped" in msg
[docs] def test_not_sphinx(): """Skip when not a Sphinx project.""" warning, msg = check_website_for_sphinx("owner/repo", is_sphinx=False) assert warning is None assert "skipped" in msg
[docs] def test_sphinx_with_website(): """No warning when Sphinx project has website.""" warning, msg = check_website_for_sphinx( "owner/repo", is_sphinx=True, homepage_url="https://docs.example.com" ) assert warning is None assert "https://docs.example.com" in msg
[docs] def test_sphinx_without_website(): """Warning when Sphinx project has no website.""" warning, _msg = check_website_for_sphinx( "owner/repo", is_sphinx=True, homepage_url=None ) assert warning is not None assert "Sphinx" in warning assert "not set" in warning
[docs] def test_sphinx_fetches_metadata(): """Fetch metadata when homepage_url not provided.""" with patch("repomatic.lint_repo.get_repo_metadata") as mock_get: mock_get.return_value = {"homepageUrl": "https://example.com"} warning, _msg = check_website_for_sphinx("owner/repo", is_sphinx=True) assert warning is None mock_get.assert_called_once_with("owner/repo")
[docs] def test_descriptions_match(): """No error when descriptions match.""" error, msg = check_description_matches( "owner/repo", project_description="A cool package", repo_description="A cool package", ) assert error is None assert "matches" in msg
[docs] def test_descriptions_differ(): """Error when descriptions differ.""" error, _msg = check_description_matches( "owner/repo", project_description="A cool package", repo_description="Different description", ) assert error is not None assert "!=" in error
[docs] def test_no_project_description(): """Skip when no project description.""" error, msg = check_description_matches( "owner/repo", project_description=None, repo_description="Something" ) assert error is None assert "skipped" in msg
[docs] def test_fetches_metadata(): """Fetch metadata when repo_description not provided.""" with patch("repomatic.lint_repo.get_repo_metadata") as mock_get: mock_get.return_value = {"description": "A cool package"} error, _msg = check_description_matches( "owner/repo", project_description="A cool package" ) assert error is None mock_get.assert_called_once_with("owner/repo")
[docs] def test_all_checks_pass(capsys): """Return 0 when all checks pass.""" with patch("repomatic.lint_repo.get_repo_metadata") as mock_get: mock_get.return_value = { "homepageUrl": "https://example.com", "description": "A cool package", } exit_code = run_repo_lint( package_name="my-package", repo_name="my-package", is_sphinx=True, project_description="A cool package", repo="owner/repo", ) assert exit_code == 0
[docs] def test_description_mismatch(capsys): """Return 1 when description mismatches.""" with patch("repomatic.lint_repo.get_repo_metadata") as mock_get: mock_get.return_value = { "homepageUrl": None, "description": "Different description", } exit_code = run_repo_lint( project_description="A cool package", repo="owner/repo", ) assert exit_code == 1 captured = capsys.readouterr() assert "::error::" in captured.out
[docs] def test_package_name_warning(capsys): """Emit warning for package name mismatch but still pass.""" exit_code = run_repo_lint( package_name="my-package", repo_name="different-repo", ) assert exit_code == 0 captured = capsys.readouterr() assert "::warning::" in captured.out
[docs] def test_website_warning(capsys): """Emit warning for missing website but still pass.""" with patch("repomatic.lint_repo.get_repo_metadata") as mock_get: mock_get.return_value = {"homepageUrl": None, "description": None} exit_code = run_repo_lint( is_sphinx=True, repo="owner/repo", ) assert exit_code == 0 captured = capsys.readouterr() assert "::warning::" in captured.out
[docs] def test_minimal_run(capsys): """Run with no checks enabled.""" exit_code = run_repo_lint() assert exit_code == 0
[docs] def test_topics_no_keywords(): """Skip when no keywords provided.""" warning, msg = check_topics_subset_of_keywords("owner/repo", keywords=None) assert warning is None assert "skipped" in msg
[docs] def test_topics_all_in_keywords(): """No warning when all topics are in keywords.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = "python\nautomation\n" warning, msg = check_topics_subset_of_keywords( "owner/repo", keywords=["python", "automation", "cli"] ) assert warning is None assert "2" in msg
[docs] def test_topics_extra_not_in_keywords(): """Warning when topics exist that are not in keywords.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = "python\nunknown-topic\n" warning, _msg = check_topics_subset_of_keywords( "owner/repo", keywords=["python", "cli"] ) assert warning is not None assert "unknown-topic" in warning
[docs] def test_topics_api_failure(): """Skip gracefully when API call fails.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("gh command failed") warning, msg = check_topics_subset_of_keywords( "owner/repo", keywords=["python"] ) assert warning is None assert "skipped" in msg
[docs] def test_topics_empty_response(): """Skip when no topics are set on the repo.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = "" warning, msg = check_topics_subset_of_keywords( "owner/repo", keywords=["python"] ) assert warning is None assert "skipped" in msg
def _graphql_response(*, is_fork: bool = False, has_sponsors: bool = True) -> str: """Build a mock GraphQL response for funding checks.""" return json.dumps({ "data": { "repository": {"isFork": is_fork}, "repositoryOwner": {"hasSponsorsListing": has_sponsors}, }, })
[docs] def test_funding_file_exists(tmp_path, monkeypatch): """No warning when funding file already exists.""" monkeypatch.chdir(tmp_path) (tmp_path / ".github").mkdir() (tmp_path / ".github" / "FUNDING.yml").write_text("github: owner\n") warning, msg = check_funding_file("owner/repo") assert warning is None assert "found" in msg
[docs] def test_funding_file_exists_lowercase(tmp_path, monkeypatch): """Detect funding file regardless of case.""" monkeypatch.chdir(tmp_path) (tmp_path / ".github").mkdir() (tmp_path / ".github" / "funding.yml").write_text("github: owner\n") warning, msg = check_funding_file("owner/repo") assert warning is None assert "found" in msg
[docs] def test_funding_missing_with_sponsors(tmp_path, monkeypatch): """Warning when owner has sponsors but no funding file.""" monkeypatch.chdir(tmp_path) with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = _graphql_response(has_sponsors=True) warning, _msg = check_funding_file("owner/repo") assert warning is not None assert "FUNDING.yml" in warning assert "Sponsor" in warning
[docs] def test_funding_skipped_for_fork(tmp_path, monkeypatch): """Skip funding check for forked repositories.""" monkeypatch.chdir(tmp_path) with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = _graphql_response(is_fork=True) warning, msg = check_funding_file("owner/repo") assert warning is None assert "fork" in msg
[docs] def test_funding_skipped_no_sponsors(tmp_path, monkeypatch): """Skip when owner has no GitHub Sponsors listing.""" monkeypatch.chdir(tmp_path) with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = _graphql_response(has_sponsors=False) warning, msg = check_funding_file("owner/repo") assert warning is None assert "no GitHub Sponsors" in msg
[docs] def test_funding_api_failure(tmp_path, monkeypatch): """Skip gracefully when GraphQL API call fails.""" monkeypatch.chdir(tmp_path) with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("gh command failed") warning, msg = check_funding_file("owner/repo") assert warning is None assert "skipped" in msg
[docs] def test_funding_json_parse_error(tmp_path, monkeypatch): """Skip gracefully when API returns invalid JSON.""" monkeypatch.chdir(tmp_path) with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = "not json" warning, msg = check_funding_file("owner/repo") assert warning is None assert "skipped" in msg
# --- Stale draft releases check unit tests ---
[docs] def test_stale_drafts_detected(): """Warn about draft releases that are not dev pre-releases.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = json.dumps([ {"tagName": "v6.1.2", "isDraft": True}, {"tagName": "v6.2.0", "isDraft": False}, {"tagName": "v6.3.0.dev0", "isDraft": True}, ]) warning, _msg = check_stale_draft_releases("owner/repo") assert warning is not None assert "v6.1.2" in warning assert "v6.3.0.dev0" not in warning
[docs] def test_stale_drafts_none(): """No warning when only dev pre-release drafts exist.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = json.dumps([ {"tagName": "v6.2.0", "isDraft": False}, {"tagName": "v6.3.0.dev0", "isDraft": True}, ]) warning, msg = check_stale_draft_releases("owner/repo") assert warning is None assert "No stale" in msg
[docs] def test_stale_drafts_api_failure(): """Skip gracefully when API call fails.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("gh command failed") warning, msg = check_stale_draft_releases("owner/repo") assert warning is None assert "skipped" in msg
[docs] def test_stale_drafts_multiple(): """List all stale draft tags in the warning.""" with patch("repomatic.lint_repo.run_gh_command") as mock_gh: mock_gh.return_value = json.dumps([ {"tagName": "v6.1.2", "isDraft": True}, {"tagName": "v6.2.0-rc1", "isDraft": True}, ]) warning, _msg = check_stale_draft_releases("owner/repo") assert warning is not None assert "v6.1.2" in warning assert "v6.2.0-rc1" in warning
# --- PAT capability check unit tests ---
[docs] def test_pat_contents_permission_pass(): """Pass when contents API call succeeds.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.return_value = "[]" passed, msg = check_pat_contents_permission("owner/repo") assert passed is True assert "Contents" in msg
[docs] def test_pat_contents_permission_fail(): """Fail when contents API call raises.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("403") passed, msg = check_pat_contents_permission("owner/repo") assert passed is False assert "Contents" in msg
[docs] def test_pat_issues_permission_pass(): """Pass when issues API call succeeds.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.return_value = "[]" passed, msg = check_pat_issues_permission("owner/repo") assert passed is True assert "Issues" in msg
[docs] def test_pat_issues_permission_fail(): """Fail when issues API call raises.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("403") passed, msg = check_pat_issues_permission("owner/repo") assert passed is False assert "Issues" in msg
[docs] def test_pat_pull_requests_permission_pass(): """Pass when pulls API call succeeds.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.return_value = "[]" passed, msg = check_pat_pull_requests_permission("owner/repo") assert passed is True assert "Pull requests" in msg
[docs] def test_pat_pull_requests_permission_fail(): """Fail when pulls API call raises.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("403") passed, msg = check_pat_pull_requests_permission("owner/repo") assert passed is False assert "Pull requests" in msg
[docs] def test_pat_vulnerability_alerts_permission_pass(): """Pass when vulnerability-alerts API call succeeds.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.return_value = "" passed, msg = check_pat_vulnerability_alerts_permission("owner/repo") assert passed is True assert "Dependabot alerts" in msg
[docs] def test_pat_vulnerability_alerts_permission_fail(): """Fail when vulnerability-alerts API call raises.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("403") passed, msg = check_pat_vulnerability_alerts_permission("owner/repo") assert passed is False assert "Dependabot alerts" in msg
[docs] def test_pat_workflows_permission_pass(): """Pass when workflows API call succeeds.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.return_value = "" passed, msg = check_pat_workflows_permission("owner/repo") assert passed is True assert "Workflows" in msg
[docs] def test_pat_workflows_permission_fail(): """Fail when workflows API call raises.""" with patch("repomatic.github.token.run_gh_command") as mock_gh: mock_gh.side_effect = RuntimeError("403") passed, msg = check_pat_workflows_permission("owner/repo") assert passed is False assert "Workflows" in msg
# --- PAT checks in run_repo_lint ---
[docs] def test_pat_checks_skipped_without_pat(capsys): """PAT checks are skipped when has_pat is False.""" exit_code = run_repo_lint(has_pat=False) assert exit_code == 0 captured = capsys.readouterr() assert "skipped (no REPOMATIC_PAT)" in captured.out
def _all_pass_pat_results(sha: str | None = "abc123") -> PatPermissionResults: """Build a PatPermissionResults where every check passes.""" return PatPermissionResults( contents=(True, "Contents: token has access"), issues=(True, "Issues: token has access"), pull_requests=(True, "Pull requests: token has access"), vulnerability_alerts=( True, "Dependabot alerts: token has access, alerts enabled", ), workflows=(True, "Workflows: token has access"), commit_statuses=(True, "Commit statuses: token has access") if sha else None, ) def _all_fail_pat_results() -> PatPermissionResults: """Build a PatPermissionResults where every check fails.""" return PatPermissionResults( contents=(False, "Cannot access repository contents."), issues=(False, "Cannot access repository issues."), pull_requests=(False, "Cannot access repository pull requests."), vulnerability_alerts=( False, "Token lacks 'Dependabot alerts: Read-only' permission.", ), workflows=(False, "Cannot access repository workflows."), commit_statuses=(False, "Cannot verify commit statuses permission."), )
[docs] def test_pat_checks_all_pass(capsys): """Return 0 when all PAT capability checks pass.""" with ( patch("repomatic.lint_repo.run_gh_command", return_value=""), patch( "repomatic.lint_repo.check_all_pat_permissions", return_value=_all_pass_pat_results(), ), ): exit_code = run_repo_lint( repo="owner/repo", has_pat=True, sha="abc123", ) assert exit_code == 0 captured = capsys.readouterr() assert "Contents: token has access" in captured.out assert "Issues: token has access" in captured.out assert "Pull requests: token has access" in captured.out assert "Dependabot alerts: token has access" in captured.out assert "Commit statuses: token has access" in captured.out assert "Workflows: token has access" in captured.out
[docs] def test_pat_checks_fail_on_missing_permission(capsys): """Return 1 when a PAT capability check fails.""" with ( patch("repomatic.lint_repo.run_gh_command", return_value=""), patch( "repomatic.lint_repo.check_all_pat_permissions", return_value=_all_fail_pat_results(), ), ): exit_code = run_repo_lint( repo="owner/repo", has_pat=True, sha="abc123", ) assert exit_code == 1 captured = capsys.readouterr() assert "::error::" in captured.out
[docs] def test_pat_checks_no_sha(capsys): """Commit statuses check is skipped when no SHA provided.""" with ( patch("repomatic.lint_repo.run_gh_command", return_value=""), patch( "repomatic.lint_repo.check_all_pat_permissions", return_value=_all_pass_pat_results(sha=None), ), ): exit_code = run_repo_lint( repo="owner/repo", has_pat=True, sha=None, ) assert exit_code == 0 captured = capsys.readouterr() assert "skipped (no SHA provided)" in captured.out # Other PAT checks still run. assert "Contents: token has access" in captured.out