DEVX-7: fix: make all warnings into errors across devx tools

This commit is contained in:
2026-06-22 20:54:25 +00:00
parent 621b9936c8
commit 8411c92c95
20 changed files with 827 additions and 304 deletions
+49 -13
View File
@@ -77,9 +77,10 @@ class TestValidatePrTitle:
class TestValidatePrTitleMatchesVikunja:
@patch.dict("os.environ", {}, clear=True)
def test_skips_when_no_token(self) -> None:
# Should not raise — just warn
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
def test_raises_when_no_token(self) -> None:
"""Should raise ClickException when VIKUNJA_TOKEN is not set."""
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN is not set"):
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
@@ -192,9 +193,12 @@ class TestRunCmd:
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": ""}, clear=True)
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_full_merge_flow(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_full_merge_flow(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -210,7 +214,7 @@ class TestMain:
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with("7", "DEVX-19: fix: resolve timeout")
mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_no_token_raises(self) -> None:
@@ -240,9 +244,12 @@ class TestMain:
assert result.exit_code != 0
assert "format" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_merge_behind_master_rebases(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -267,9 +274,12 @@ class TestMain:
# Should have fetched, rebased, and pushed
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_merge_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -288,9 +298,12 @@ class TestMain:
assert result.exit_code != 0
assert "Merge failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_no_conventional_msg_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When no conventional commit message is found in PR commits, raises."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -308,8 +321,31 @@ class TestMain:
assert "conventional commit" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
"""Non-integer PR number should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"])
assert result.exit_code != 0
assert "PR number must be an integer" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
"""Repo without owner/name should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"])
assert result.exit_code != 0
assert "owner/name" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_rebase_retry_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
+14 -19
View File
@@ -67,7 +67,7 @@ class TestCheckTranslationSet:
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Dead key" in w for w in result.warnings)
assert any("Dead key" in e for e in result.errors)
def test_missing_language(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
@@ -77,7 +77,7 @@ class TestCheckTranslationSet:
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing languages" in w for w in result.warnings)
assert any("Missing languages" in e for e in result.errors)
def test_missing_translations_file(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
@@ -108,23 +108,19 @@ class TestMain:
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
def test_strict_fails_on_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""--strict should fail if there are missing language warnings."""
warn_result = check_translations.TranslationCheckResult(
def test_errors_fail(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Errors should cause exit code 1."""
error_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
warnings=["Dead key: 'bar'"],
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: warn_result,
errors=["Dead key: 'bar'"],
)
monkeypatch.setattr(check_translations, "check_translation_set", lambda name, src, trans: error_result)
runner = CliRunner()
result = runner.invoke(check_translations.main, ["--strict"])
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 1
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -311,26 +307,25 @@ class TestCollectKeys:
assert "in_progress" in keys
class TestMainWarnings:
def test_passes_with_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS with warnings' message."""
warn_result = check_translations.TranslationCheckResult(
class TestMainCleanPass:
def test_passes_clean(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS:' message when no errors."""
ok_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
warnings=["Dead key: 'bar'"],
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: warn_result,
lambda name, src, trans: ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "PASS with warnings" in result.output
assert "PASS:" in result.output
class TestI18nProjectTranslations:
+29
View File
@@ -120,6 +120,35 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
@patch("devx.ci.discover_runners.requests.get")
def test_query_runners_403_no_warning(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""403 on instance-level runners should not produce a warning (expected without admin scope)."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
captured = capsys.readouterr()
assert "instance-level" not in captured.err
@patch("devx.ci.discover_runners.requests.get")
def test_instance_level_non_403_warns(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""Non-200, non-403 status on instance-level runners should produce a warning."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=500, json=lambda: {"message": "server error"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 2
captured = capsys.readouterr()
assert "instance-level" in captured.err
assert "500" in captured.err
class TestGetRunnerCount:
@patch("devx.ci.discover_runners.query_runners", return_value=5)
+10
View File
@@ -240,6 +240,16 @@ class TestGithubEnv:
assert result.exit_code != 0
class TestRunnerIndexValidation:
def test_runner_index_zero_raises(self) -> None:
"""Runner index < 1 should raise."""
with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
def test_main_module_block() -> None:
import devx.molecule.distribute_molecule as dm
+9
View File
@@ -168,6 +168,15 @@ class TestCli:
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_invalid_pair_format_raises(self) -> None:
"""Pair with fewer than 2 parts should raise."""
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli, ["invalid_no_pipe"])
assert result.exit_code != 0
assert "Invalid pair format" in result.output
def test_failure_exits_nonzero(self) -> None:
from click.testing import CliRunner
+11 -14
View File
@@ -132,13 +132,12 @@ class TestMain:
assert "VIKUNJA_TOKEN" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_task_id_non_release_warns(self) -> None:
"""Non-release commits without DEVX-N prefix should warn, not fail."""
def test_no_task_id_non_release_fails(self) -> None:
"""Non-release commits without DEVX-N prefix should fail."""
runner = CliRunner()
result = runner.invoke(main, ["fix: resolve bug"])
assert result.exit_code == 0
assert result.exit_code != 0
assert "No task ID" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_release_commit_without_task_id_skips(self) -> None:
@@ -179,8 +178,8 @@ class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
def test_post_comment_failure_fails(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should fail — the task was not updated."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
@@ -189,14 +188,13 @@ class TestMain:
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
assert result.exit_code != 0
assert "Vikunja API error" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
def test_mark_done_failure_fails(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should fail — the task was not updated."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
@@ -206,9 +204,8 @@ class TestMain:
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
assert result.exit_code != 0
assert "Vikunja API error" in result.output
class TestGetGitCommitMessage:
+11 -3
View File
@@ -84,6 +84,13 @@ class TestGetBumpedVersion:
with pytest.raises(click.ClickException):
get_bumped_version()
@patch("devx.ci.release.run_cmd")
def test_invalid_version_format_raises(self, mock_run_cmd: MagicMock) -> None:
"""Non-semver version from git-cliff should raise."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="not-a-version\n", stderr="")
with pytest.raises(click.ClickException, match="invalid version format"):
get_bumped_version()
class TestGetChangelog:
@patch("devx.ci.release.run_cmd")
@@ -384,7 +391,7 @@ class TestMain:
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_dry_run_empty_changelog(
def test_dry_run_empty_changelog_fails(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
@@ -397,11 +404,12 @@ class TestMain:
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
"""Empty changelog should fail, not warn."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0
assert "empty changelog" in result.output
assert result.exit_code != 0
assert "empty changelog" in result.output.lower()
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
+23 -8
View File
@@ -5,6 +5,7 @@ import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
@@ -63,6 +64,22 @@ class TestLoadMapping:
with pytest.raises(FileNotFoundError):
load_mapping()
def test_non_dict_mapping_raises(self, tmp_path: Path) -> None:
"""Non-dict mapping.json should raise."""
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('["not", "a", "dict"]')
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
with pytest.raises(click.ClickException, match="must be a dict"):
load_mapping()
def test_non_string_values_raise(self, tmp_path: Path) -> None:
"""Non-string values in mapping.json should raise."""
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('{"file.md": 123}')
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
with pytest.raises(click.ClickException, match="must be strings"):
load_mapping()
class TestReadDocContent:
def test_reads_file(self, tmp_path: Path) -> None:
@@ -332,8 +349,8 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None:
"""Test that missing doc files are skipped with a warning."""
def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that missing doc files cause an error, not a warning."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
@@ -341,14 +358,13 @@ class TestMain:
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert result.exit_code != 0
assert "not found" in result.output
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files are skipped with a warning."""
def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files cause an error, not a warning."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
@@ -356,9 +372,8 @@ class TestMain:
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert result.exit_code != 0
assert "empty" in result.output.lower()
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")