Public Access
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 50s
Post-merge / publish (push) Successful in 32s
Post-merge / badges (push) Failing after 36s
- Fix badge system: clean .badges dir from orphan branch, add version verification, make badges job depend on release (avoids stale version badge race condition) - Add check_doc_versions.py: lint tool that verifies docs version references match current __version__, with --fix for auto-update - Integrate check_doc_versions into release process (auto-updates docs on every release commit) - Add Vale prose linter integration: .vale.ini, custom styles for terminology and code block language, CI step, make target - Fix stale version references in docs (0.27.0 → 0.33.4) - Fix e.g. → for example in docs (Google.Latin Vale rule) - Add CI steps for check_doc_versions and Vale to quality workflow - Add make targets: devx-check-doc-versions, devx-vale Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
342 lines
15 KiB
Python
342 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from click import ClickException
|
|
from click.testing import CliRunner
|
|
|
|
import devx.ci.push_badges as push_badges
|
|
|
|
|
|
class TestFetchLatestMaster:
|
|
def test_fetch_and_reset(self) -> None:
|
|
with patch("subprocess.run") as mock_run:
|
|
push_badges.fetch_latest_master("master")
|
|
# Should call git fetch and git reset --hard
|
|
calls = [str(c.args[0]) for c in mock_run.call_args_list]
|
|
assert any("fetch" in c for c in calls)
|
|
assert any("reset" in c for c in calls)
|
|
|
|
def test_custom_branch(self) -> None:
|
|
with patch("subprocess.run") as mock_run:
|
|
push_badges.fetch_latest_master("develop")
|
|
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
|
# fetch call should include the branch name
|
|
fetch_call = [c for c in calls if "fetch" in c][0]
|
|
assert "develop" in fetch_call
|
|
# reset call should include origin/develop
|
|
reset_call = [c for c in calls if "reset" in c][0]
|
|
assert "origin/develop" in reset_call
|
|
|
|
def test_fetch_failure_raises(self) -> None:
|
|
import subprocess
|
|
|
|
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
|
with pytest.raises(subprocess.CalledProcessError):
|
|
push_badges.fetch_latest_master()
|
|
|
|
|
|
class TestGenerateBadges:
|
|
def test_success(self, tmp_path: Path) -> None:
|
|
output_dir = tmp_path / ".badges"
|
|
output_dir.mkdir()
|
|
(output_dir / "badge1.svg").touch()
|
|
|
|
with patch("subprocess.run") as mock_run:
|
|
push_badges.generate_badges(str(output_dir))
|
|
mock_run.assert_called_once()
|
|
|
|
def test_no_badges_generated(self, tmp_path: Path) -> None:
|
|
output_dir = tmp_path / ".badges"
|
|
output_dir.mkdir()
|
|
|
|
with patch("subprocess.run"):
|
|
with pytest.raises(ClickException, match="No badge SVG files generated"):
|
|
push_badges.generate_badges(str(output_dir))
|
|
|
|
|
|
class TestPushToBadgesBranch:
|
|
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir()
|
|
svg = badges_dir / "badge1.svg"
|
|
svg.write_text("<svg></svg>")
|
|
|
|
sha_result = MagicMock()
|
|
sha_result.stdout = "abc123\n"
|
|
diff_result = MagicMock()
|
|
diff_result.stdout = "coverage.svg\n"
|
|
default_result = MagicMock()
|
|
with patch(
|
|
"subprocess.run",
|
|
side_effect=[default_result] * 6 + [diff_result] + [default_result, default_result, sha_result],
|
|
) as mock_run:
|
|
sha = push_badges.push_to_badges_branch(str(badges_dir))
|
|
|
|
# Should have called git config x2, checkout, rm, add, commit, push, rev-parse
|
|
assert mock_run.call_count >= 8
|
|
# Verify the SVG was copied to cwd
|
|
assert (tmp_path / "badge1.svg").exists()
|
|
assert sha == "abc123"
|
|
|
|
|
|
class TestUpdateBadgeUrls:
|
|
def test_replaces_branch_url(self) -> None:
|
|
content = "[]"
|
|
result = push_badges.update_badge_urls(content, "abc123def456")
|
|
assert "raw/commit/abc123def456/tests.svg" in result
|
|
assert "raw/branch/badges" not in result
|
|
|
|
def test_replaces_commit_url(self) -> None:
|
|
"""Old commit SHA URLs should be replaced with the new one."""
|
|
old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars
|
|
new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars
|
|
content = f"[]"
|
|
result = push_badges.update_badge_urls(content, new_sha)
|
|
assert f"raw/commit/{new_sha}/tests.svg" in result
|
|
assert old_sha not in result
|
|
|
|
def test_no_badge_urls(self) -> None:
|
|
content = "# No badges here\nJust text."
|
|
result = push_badges.update_badge_urls(content, "abc123")
|
|
assert result == content
|
|
|
|
def test_multiple_badges(self) -> None:
|
|
content = (
|
|
"[]\n"
|
|
"[]\n"
|
|
"[]"
|
|
)
|
|
result = push_badges.update_badge_urls(content, "abc123def456")
|
|
assert result.count("raw/commit/abc123def456/") == 3
|
|
assert "raw/branch/badges" not in result
|
|
|
|
def test_preserves_non_badge_urls(self) -> None:
|
|
content = "[]"
|
|
result = push_badges.update_badge_urls(content, "abc123")
|
|
assert result == content
|
|
|
|
|
|
class TestUpdateReadmeWithBadgeSha:
|
|
def test_updates_readme(self, tmp_path: Path) -> None:
|
|
readme = tmp_path / "README.md"
|
|
readme.write_text("[]")
|
|
with patch("subprocess.run"):
|
|
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
content = readme.read_text()
|
|
assert "raw/commit/abc123def456/tests.svg" in content
|
|
|
|
def test_no_badge_urls_skips_commit(self, tmp_path: Path) -> None:
|
|
readme = tmp_path / "README.md"
|
|
readme.write_text("# No badges here")
|
|
with patch("subprocess.run") as mock_run:
|
|
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
# Should checkout master but not commit/push
|
|
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
|
assert not any("commit" in c for c in calls)
|
|
assert not any("push" in c for c in calls)
|
|
|
|
def test_missing_readme_skips(self, tmp_path: Path) -> None:
|
|
with patch("subprocess.run"):
|
|
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
# Should not raise
|
|
|
|
def test_version_verification_stale(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
readme = tmp_path / "README.md"
|
|
readme.write_text("[]")
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir(exist_ok=True)
|
|
(badges_dir / "version.svg").write_text("version: v0.27.0")
|
|
with patch("subprocess.run"):
|
|
with patch("devx.tools.generate_badges.detect_package_name", return_value="devx"):
|
|
with patch("devx.tools.generate_badges.read_version", return_value="0.33.4"):
|
|
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
|
|
def test_version_verification_current(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
readme = tmp_path / "README.md"
|
|
readme.write_text("[]")
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir(exist_ok=True)
|
|
(badges_dir / "version.svg").write_text("version: v0.33.4")
|
|
with patch("subprocess.run"):
|
|
with patch("devx.tools.generate_badges.detect_package_name", return_value="devx"):
|
|
with patch("devx.tools.generate_badges.read_version", return_value="0.33.4"):
|
|
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
|
|
def test_version_verification_no_badges_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
readme = tmp_path / "README.md"
|
|
readme.write_text("[]")
|
|
monkeypatch.chdir(tmp_path)
|
|
# No .badges/version.svg exists — should skip verification gracefully
|
|
with patch("subprocess.run"):
|
|
with patch("devx.tools.generate_badges.detect_package_name", return_value="devx"):
|
|
with patch("devx.tools.generate_badges.read_version", return_value="0.33.4"):
|
|
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
|
|
|
|
class TestMain:
|
|
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir()
|
|
(badges_dir / "badge1.svg").touch()
|
|
|
|
runner = CliRunner()
|
|
with patch("subprocess.run"):
|
|
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
|
assert result.exit_code == 0
|
|
|
|
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir()
|
|
|
|
runner = CliRunner()
|
|
with patch("subprocess.run"):
|
|
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
|
assert result.exit_code != 0
|
|
|
|
def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir()
|
|
(badges_dir / "badge1.svg").touch()
|
|
|
|
runner = CliRunner()
|
|
with patch("subprocess.run") as mock_run:
|
|
result = runner.invoke(
|
|
push_badges.main,
|
|
["--output-dir", str(badges_dir), "--branch", "develop", "--no-readme-update"],
|
|
)
|
|
assert result.exit_code == 0
|
|
# Verify fetch was called with the custom branch
|
|
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
|
fetch_call = [c for c in calls if "fetch" in c][0]
|
|
assert "develop" in fetch_call
|
|
|
|
def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
import subprocess
|
|
|
|
runner = CliRunner()
|
|
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
|
result = runner.invoke(push_badges.main, ["--no-readme-update"])
|
|
assert result.exit_code != 0
|
|
|
|
def test_readme_update_called_by_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Without --no-readme-update, update_readme_with_badge_sha is called."""
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir()
|
|
(badges_dir / "badge1.svg").touch()
|
|
|
|
runner = CliRunner()
|
|
with (
|
|
patch("subprocess.run"),
|
|
patch("devx.ci.push_badges.update_readme_with_badge_sha") as mock_update,
|
|
):
|
|
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
|
assert result.exit_code == 0
|
|
mock_update.assert_called_once()
|
|
|
|
def test_readme_update_skipped_with_flag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""With --no-readme-update, update_readme_with_badge_sha is not called."""
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir()
|
|
(badges_dir / "badge1.svg").touch()
|
|
|
|
runner = CliRunner()
|
|
with (
|
|
patch("subprocess.run"),
|
|
patch("devx.ci.push_badges.update_readme_with_badge_sha") as mock_update,
|
|
):
|
|
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
|
assert result.exit_code == 0
|
|
mock_update.assert_not_called()
|
|
|
|
def test_retries_success_on_second_attempt(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""With --retries 3, first attempt fails but second succeeds."""
|
|
monkeypatch.chdir(tmp_path)
|
|
badges_dir = tmp_path / ".badges"
|
|
badges_dir.mkdir()
|
|
(badges_dir / "badge1.svg").touch()
|
|
|
|
import subprocess
|
|
|
|
call_count = [0]
|
|
|
|
def side_effect(*args: Any, **kwargs: Any) -> Any:
|
|
call_count[0] += 1
|
|
# First call (git fetch) fails, rest succeed
|
|
if call_count[0] == 1:
|
|
raise subprocess.CalledProcessError(1, "git fetch")
|
|
return MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
runner = CliRunner()
|
|
with (
|
|
patch("subprocess.run", side_effect=side_effect),
|
|
patch("devx.ci.push_badges.update_readme_with_badge_sha"),
|
|
patch("time.sleep"),
|
|
):
|
|
result = runner.invoke(
|
|
push_badges.main,
|
|
["--output-dir", str(badges_dir), "--no-readme-update", "--retries", "3"],
|
|
)
|
|
assert result.exit_code == 0
|
|
|
|
def test_retries_exhausted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""With --retries 2, all attempts fail and exit code is non-zero."""
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
import subprocess
|
|
|
|
runner = CliRunner()
|
|
with (
|
|
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")),
|
|
patch("time.sleep"),
|
|
):
|
|
result = runner.invoke(
|
|
push_badges.main,
|
|
["--no-readme-update", "--retries", "2"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "failed after 2" in result.output
|
|
|
|
def test_default_retries_is_one(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Without --retries, only one attempt is made (no retry on failure)."""
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
import subprocess
|
|
|
|
runner = CliRunner()
|
|
with (
|
|
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")),
|
|
patch("time.sleep") as mock_sleep,
|
|
):
|
|
result = runner.invoke(push_badges.main, ["--no-readme-update"])
|
|
assert result.exit_code != 0
|
|
mock_sleep.assert_not_called()
|
|
|
|
|
|
class TestRepoRoot:
|
|
def test_uses_github_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
|
|
assert push_badges._repo_root() == tmp_path
|
|
|
|
def test_falls_back_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
assert push_badges._repo_root() == tmp_path
|
|
|
|
def test_falls_back_when_workspace_invalid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent")
|
|
monkeypatch.chdir(tmp_path)
|
|
assert push_badges._repo_root() == tmp_path
|