# 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_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_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_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_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_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