diff --git a/src/devx/api_clients.py b/src/devx/api_clients.py index 4ed9b12..afb4152 100644 --- a/src/devx/api_clients.py +++ b/src/devx/api_clients.py @@ -407,6 +407,25 @@ class VikunjaClient: r = self._request("GET", f"/projects/{project_id}/tasks", params=params) return r.json() + def find_task_by_identifier(self, project_id: int, identifier: str, per_page: int = 50) -> dict[str, Any] | None: + """Find a task by its identifier (e.g. ``DEVX-42``) in a project. + + Paginates through all tasks in the project. Returns the task dict + or None if not found. + """ + page = 1 + while True: + tasks = self.list_project_tasks(project_id, page=page, per_page=per_page) + if not tasks: + break + for t in tasks: + if t.get("identifier") == identifier: + return t + if len(tasks) < per_page: + break + page += 1 + return None + def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]: """Create a task in a project and return the created task dict. diff --git a/src/devx/ci/discover_runners.py b/src/devx/ci/discover_runners.py index 7ab703d..3573a21 100644 --- a/src/devx/ci/discover_runners.py +++ b/src/devx/ci/discover_runners.py @@ -29,7 +29,7 @@ import os import click import requests -from devx.config import GITEA_API_URL +from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER DEFAULT_MAX_RUNNERS = 3 @@ -151,9 +151,9 @@ def main( token = os.environ.get("CI_GITEA_TOKEN", "") if owner is None: - owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") + owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER if repo is None: - repo = os.environ.get("DEVX_REPO_NAME", "devx") + repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME count = get_runner_count(GITEA_API_URL, token, owner, repo) indices = generate_indices(count) diff --git a/src/devx/ci/integration_guard.py b/src/devx/ci/integration_guard.py index 42bd0ad..bafdd1a 100644 --- a/src/devx/ci/integration_guard.py +++ b/src/devx/ci/integration_guard.py @@ -36,6 +36,7 @@ import time import click +from devx.config import REPO_NAME, REPO_OWNER from devx.i18n import _ from devx.molecule.molecule_ci_guard import ( poll_for_other_failures, @@ -53,10 +54,10 @@ def cli(pytest_args: tuple[str, ...]) -> None: run_id = int(os.environ.get("RUN_ID", "0")) job_name = os.environ.get("JOB_NAME", "integration-tests") current_index = int(os.environ.get("MATRIX_INDEX", "0")) - repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx") + repository = os.environ.get("GITEA_REPOSITORY", "") owner, _sep, repo = repository.partition("/") if not owner or not repo: - owner, repo = "oblachno-oss", "devx" + owner, repo = REPO_OWNER, REPO_NAME if not all([gitea_url, token, run_id]): click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) diff --git a/src/devx/ci/publish.py b/src/devx/ci/publish.py index d0a5621..3dea9c7 100644 --- a/src/devx/ci/publish.py +++ b/src/devx/ci/publish.py @@ -28,7 +28,7 @@ from pathlib import Path import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] -from devx.config import GITEA_API_URL +from devx.config import GITEA_API_URL, REPO_OWNER from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.i18n import _ @@ -165,7 +165,7 @@ def _default_gitea_registry_url() -> str: base = base[: -len("/api/v1")] elif base.endswith("/api"): base = base[: -len("/api")] - owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") + owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER return f"{base}/api/packages/{owner}/pypi" diff --git a/src/devx/ci/sync_wiki.py b/src/devx/ci/sync_wiki.py index 6c6d9ab..2c3feea 100644 --- a/src/devx/ci/sync_wiki.py +++ b/src/devx/ci/sync_wiki.py @@ -28,7 +28,7 @@ import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from devx.api_clients import GiteaClient -from devx.config import GITEA_API_URL +from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.exceptions import APIError from devx.i18n import _ @@ -230,8 +230,8 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None: raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) if repo is None: - owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") - repo_name = os.environ.get("DEVX_REPO_NAME", "devx") + owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER + repo_name = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME else: owner, repo_name = repo.split("/") diff --git a/src/devx/molecule/discover_runners.py b/src/devx/molecule/discover_runners.py index b61c7b4..d16688b 100644 --- a/src/devx/molecule/discover_runners.py +++ b/src/devx/molecule/discover_runners.py @@ -29,7 +29,7 @@ import os import click import requests -from devx.config import GITEA_API_URL +from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER DEFAULT_MAX_RUNNERS = 3 @@ -145,9 +145,9 @@ def main( token = os.environ.get("CI_GITEA_TOKEN", "") if owner is None: - owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") + owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER if repo is None: - repo = os.environ.get("DEVX_REPO_NAME", "devx") + repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME count = get_runner_count(GITEA_API_URL, token, owner, repo) indices = generate_indices(count) diff --git a/src/devx/molecule/molecule_ci_guard.py b/src/devx/molecule/molecule_ci_guard.py index e043a3b..7724b5f 100644 --- a/src/devx/molecule/molecule_ci_guard.py +++ b/src/devx/molecule/molecule_ci_guard.py @@ -43,6 +43,7 @@ from pathlib import Path import click import requests +from devx.config import REPO_NAME, REPO_OWNER from devx.i18n import _ POLL_INTERVAL = 10 @@ -167,10 +168,10 @@ def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None: run_id = int(os.environ.get("RUN_ID", "0")) job_name = os.environ.get("JOB_NAME", "molecule-tests") current_index = int(os.environ.get("MATRIX_INDEX", "0")) - repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx") + repository = os.environ.get("GITEA_REPOSITORY", "") owner, _sep, repo = repository.partition("/") if not owner or not repo: - owner, repo = "oblachno-oss", "devx" + owner, repo = REPO_OWNER, REPO_NAME if not all([gitea_url, token, run_id]): click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) diff --git a/src/devx/tools/check_config.py b/src/devx/tools/check_config.py index ce91d5b..dc30cba 100644 --- a/src/devx/tools/check_config.py +++ b/src/devx/tools/check_config.py @@ -13,7 +13,6 @@ Usage:: from __future__ import annotations import re -import sys import tomllib from pathlib import Path @@ -27,8 +26,7 @@ def cli() -> None: """Validate devx configuration in pyproject.toml.""" path = Path("pyproject.toml") if not path.exists(): - click.echo(_("pyproject.toml not found in current directory.")) - sys.exit(1) + raise click.ClickException(_("pyproject.toml not found in current directory.")) with open(path, "rb") as f: # noqa: PTH123 data = tomllib.load(f) @@ -65,7 +63,7 @@ def cli() -> None: if errors: for err in errors: click.echo(f"ERROR: {err}", err=True) - sys.exit(1) + raise click.ClickException(_("Configuration validation failed.")) click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent.")) diff --git a/src/devx/tools/check_test_coverage.py b/src/devx/tools/check_test_coverage.py index 84fe7ab..79454af 100644 --- a/src/devx/tools/check_test_coverage.py +++ b/src/devx/tools/check_test_coverage.py @@ -23,12 +23,12 @@ Usage:: from __future__ import annotations -import argparse import fnmatch import subprocess # nosec B404 -import sys from pathlib import Path +import click + from devx.config import _load_pyproject_devx from devx.i18n import _ @@ -203,49 +203,37 @@ def _find_missing_tests( return missing -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description=_("Check that changed files have corresponding tests"), - ) - parser.add_argument( - "--staged-only", - action="store_true", - help=_("Only check staged files (for pre-commit)"), - ) - parser.add_argument( - "--warn-only", - action="store_true", - help=_("Print warnings but always exit 0"), - ) - args = parser.parse_args(argv) - +@click.command() +@click.option("--staged-only", is_flag=True, help=_("Only check staged files (for pre-commit)")) +@click.option("--warn-only", is_flag=True, help=_("Print warnings but always exit 0")) +def cli(staged_only: bool, warn_only: bool) -> None: + """Check that changed files have corresponding tests.""" repo_root = Path.cwd() rules, skip_patterns, test_indicators, skip_extensions = _load_rules() - files = _changed_files(args.staged_only, repo_root) + files = _changed_files(staged_only, repo_root) if not files: - print(_("[check_test_coverage] No changed files to check.")) - return 0 + click.echo(_("[check_test_coverage] No changed files to check.")) + return missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions) if not missing: - print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.") - return 0 + click.echo(f"[check_test_coverage] All {len(files)} changed file(s) have tests.") + return - print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr) + click.echo("[check_test_coverage] FAILED: missing tests for changed files:\n", err=True) for f, reason in missing.items(): - print(f" {f}", file=sys.stderr) - print(f" -> {reason}", file=sys.stderr) + click.echo(f" {f}", err=True) + click.echo(f" -> {reason}", err=True) - print( - "\n[check_test_coverage] Fix: add the missing test file(s) before committing.", - file=sys.stderr, + click.echo( + _("\n[check_test_coverage] Fix: add the missing test file(s) before committing."), + err=True, ) - if args.warn_only: - return 0 - return 1 + if not warn_only: + raise click.ClickException(_("Missing tests for changed files.")) if __name__ == "__main__": # pragma: no cover - sys.exit(main()) + cli() # pragma: no cover diff --git a/src/devx/tools/clean_images.py b/src/devx/tools/clean_images.py index b25d49d..e09d542 100644 --- a/src/devx/tools/clean_images.py +++ b/src/devx/tools/clean_images.py @@ -40,7 +40,7 @@ from typing import Any import click import requests -from devx.config import GITEA_API_URL +from devx.config import GITEA_API_URL, REPO_OWNER from devx.i18n import _ @@ -151,8 +151,8 @@ def select_for_deletion( @click.command() @click.option( "--owner", - required=True, - help="Package owner (user or org).", + default=None, + help="Package owner (user or org, default: from [tool.devx] repo_owner).", ) @click.option( "--name", @@ -180,7 +180,7 @@ def select_for_deletion( help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).", ) def main( - owner: str, + owner: str | None, names: tuple[str, ...], keep: int, dry_run: bool, @@ -190,6 +190,10 @@ def main( token = os.environ.get("CI_GITEA_TOKEN", "") if not token: raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) + if not owner: + owner = REPO_OWNER + if not owner: + raise click.ClickException(_("Package owner not specified. Use --owner or set [tool.devx] repo_owner.")) base_url = api_url or GITEA_API_URL total_deleted = 0 diff --git a/src/devx/tools/configure_repo.py b/src/devx/tools/configure_repo.py index 1db27c2..a36f248 100644 --- a/src/devx/tools/configure_repo.py +++ b/src/devx/tools/configure_repo.py @@ -19,7 +19,7 @@ from typing import Any, cast import click from devx.api_clients import GiteaClient -from devx.config import GITEA_API_URL, REPO_OWNER +from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.exceptions import APIError from devx.i18n import _ @@ -151,7 +151,7 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) token = os.environ.get("CI_GITEA_TOKEN", "") if repo is None: - repo = os.environ.get("DEVX_REPO_NAME", "") + repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME if not repo: raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.")) diff --git a/src/devx/tools/create_pr.py b/src/devx/tools/create_pr.py index 743cfd2..a1d95b4 100644 --- a/src/devx/tools/create_pr.py +++ b/src/devx/tools/create_pr.py @@ -76,24 +76,16 @@ def get_vikunja_task_title(task_id: str) -> str: if not token: raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) client = VikunjaClient(VIKUNJA_API_URL, token) - page = 1 - while True: - tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE) - if not tasks: - break - matches = [t for t in tasks if t.get("identifier") == task_id] - if matches: - return str(matches[0].get("title", "")) - if len(tasks) < DEFAULT_PER_PAGE: - break - page += 1 - raise click.ClickException( - _( - "Could not find Vikunja task {task_id} in project {project_id}.", - task_id=task_id, - project_id=VIKUNJA_PROJECT_ID, - ), - ) + task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) + if not task: + raise click.ClickException( + _( + "Could not find Vikunja task {task_id} in project {project_id}.", + task_id=task_id, + project_id=VIKUNJA_PROJECT_ID, + ), + ) + return str(task.get("title", "")) def find_existing_pr(client: GiteaClient, branch: str) -> dict | None: diff --git a/src/devx/tools/generate_cliff_config.py b/src/devx/tools/generate_cliff_config.py index 70cd37b..3e77843 100644 --- a/src/devx/tools/generate_cliff_config.py +++ b/src/devx/tools/generate_cliff_config.py @@ -109,7 +109,7 @@ def _generate(prefix: str) -> str: @click.option( "--prefix", default=TASK_PREFIX, - help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').", + help="Task ID prefix for commit preprocessor (default: from [tool.devx] task_prefix in pyproject.toml).", ) @click.option( "--output", diff --git a/src/devx/tools/install_checkmake.py b/src/devx/tools/install_checkmake.py index 743cdc1..0c0ae6d 100644 --- a/src/devx/tools/install_checkmake.py +++ b/src/devx/tools/install_checkmake.py @@ -56,7 +56,8 @@ def _download_binary() -> None: TARGET_PATH.chmod(0o755) -def main() -> None: +@click.command() +def cli() -> None: """Install checkmake if not already present.""" if shutil.which("checkmake") is not None: return @@ -66,4 +67,4 @@ def main() -> None: if __name__ == "__main__": # pragma: no cover - main() # pragma: no cover + cli() # pragma: no cover diff --git a/src/devx/tools/pre_push_check.py b/src/devx/tools/pre_push_check.py index 9a328eb..952b8b1 100644 --- a/src/devx/tools/pre_push_check.py +++ b/src/devx/tools/pre_push_check.py @@ -59,17 +59,7 @@ def task_exists(task_id: str) -> bool: if not token: return False client = VikunjaClient(VIKUNJA_API_URL, token) - page = 1 - while True: - tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE) - if not tasks: - break - if any(t.get("identifier") == task_id for t in tasks): - return True - if len(tasks) < DEFAULT_PER_PAGE: - break - page += 1 - return False + return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None def validate(branch: str) -> None: diff --git a/src/devx/translations.json b/src/devx/translations.json index 54de0a0..87f84c9 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -463,14 +463,6 @@ "ru": "Bumping version: {current} -> v{new_version}", "zh": "Bumping version: {current} -> v{new_version}" }, - "Check that changed files have corresponding tests": { - "bg": "Check that changed files have corresponding tests", - "de": "Check that changed files have corresponding tests", - "en": "Check that changed files have corresponding tests", - "pl": "Check that changed files have corresponding tests", - "ru": "Check that changed files have corresponding tests", - "zh": "Check that changed files have corresponding tests" - }, "Checking CLI command documentation...": { "bg": "Checking CLI command documentation...", "de": "Checking CLI command documentation...", @@ -2190,5 +2182,37 @@ "pl": "Waiting for CI checks to complete (timeout: {timeout}s)...", "ru": "Waiting for CI checks to complete (timeout: {timeout}s)...", "zh": "Waiting for CI checks to complete (timeout: {timeout}s)..." + }, + "\n[check_test_coverage] Fix: add the missing test file(s) before committing.": { + "en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.", + "bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.", + "de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.", + "pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.", + "ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.", + "zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing." + }, + "Package owner not specified. Use --owner or set [tool.devx] repo_owner.": { + "en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.", + "bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.", + "de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.", + "pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.", + "ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.", + "zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner." + }, + "Configuration validation failed.": { + "en": "Configuration validation failed.", + "bg": "Configuration validation failed.", + "de": "Configuration validation failed.", + "pl": "Configuration validation failed.", + "ru": "Configuration validation failed.", + "zh": "Configuration validation failed." + }, + "Missing tests for changed files.": { + "en": "Missing tests for changed files.", + "bg": "Missing tests for changed files.", + "de": "Missing tests for changed files.", + "pl": "Missing tests for changed files.", + "ru": "Missing tests for changed files.", + "zh": "Missing tests for changed files." } } diff --git a/tests/unit/test_api_clients.py b/tests/unit/test_api_clients.py index 6adb7f3..ee658f7 100644 --- a/tests/unit/test_api_clients.py +++ b/tests/unit/test_api_clients.py @@ -781,6 +781,42 @@ class TestVikunjaClient: call_kwargs = client._session.request.call_args.kwargs assert call_kwargs["json"]["description"] == "" + def test_find_task_by_identifier_found(self) -> None: + client = VikunjaClient("https://work.example.com", "tok") + client._session.request = MagicMock( + return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-42", "title": "Found"}]) + ) + result = client.find_task_by_identifier(6, "DEVX-42", per_page=50) + assert result is not None + assert result["title"] == "Found" + + def test_find_task_by_identifier_not_found(self) -> None: + client = VikunjaClient("https://work.example.com", "tok") + client._session.request = MagicMock( + return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-2"}]) + ) + result = client.find_task_by_identifier(6, "DEVX-99", per_page=50) + assert result is None + + def test_find_task_by_identifier_empty_project(self) -> None: + client = VikunjaClient("https://work.example.com", "tok") + client._session.request = MagicMock(return_value=_mock_response([])) + result = client.find_task_by_identifier(6, "DEVX-1", per_page=50) + assert result is None + + def test_find_task_by_identifier_paginates(self) -> None: + client = VikunjaClient("https://work.example.com", "tok") + full_page = [{"identifier": f"DEVX-{i}"} for i in range(50)] + client._session.request = MagicMock( + side_effect=[ + _mock_response(full_page), + _mock_response([{"identifier": "DEVX-50", "title": "Found on page 2"}]), + ] + ) + result = client.find_task_by_identifier(6, "DEVX-50", per_page=50) + assert result is not None + assert result["title"] == "Found on page 2" + class TestIsRetryable: def test_connection_error_is_retryable(self) -> None: diff --git a/tests/unit/test_build_image.py b/tests/unit/test_build_image.py index 1fd40cc..20ac496 100644 --- a/tests/unit/test_build_image.py +++ b/tests/unit/test_build_image.py @@ -705,4 +705,33 @@ class TestCLICleanImages: ) assert result.exit_code != 0 assert "FAILED" in result.output - assert "failed" in result.output.lower() + + @patch("devx.tools.clean_images.REPO_OWNER", "oblachno-oss") + def test_owner_from_config(self) -> None: + from devx.tools.clean_images import main as clean_main + + runner = CliRunner() + mock_resp = MagicMock() + mock_resp.json.return_value = [] + mock_resp.raise_for_status = MagicMock() + with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): + with patch("devx.tools.clean_images.requests.get", return_value=mock_resp): + result = runner.invoke( + clean_main, + ["--name", "ci-base", "--dry-run"], + ) + assert result.exit_code == 0 + assert "oblachno-oss/ci-base" in result.output + + @patch("devx.tools.clean_images.REPO_OWNER", "") + def test_no_owner_raises(self) -> None: + from devx.tools.clean_images import main as clean_main + + runner = CliRunner() + with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): + result = runner.invoke( + clean_main, + ["--name", "ci-base"], + ) + assert result.exit_code != 0 + assert "owner" in result.output.lower() diff --git a/tests/unit/test_check_test_coverage.py b/tests/unit/test_check_test_coverage.py index e0cb60a..e5ecbba 100644 --- a/tests/unit/test_check_test_coverage.py +++ b/tests/unit/test_check_test_coverage.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import MagicMock, patch +from click.testing import CliRunner + from devx.tools.check_test_coverage import ( BUILTIN_RULES, DEFAULT_SKIP_EXTENSIONS, @@ -13,7 +15,7 @@ from devx.tools.check_test_coverage import ( _load_rules, _resolve_test_path, _should_skip_file, - main, + cli, ) @@ -201,7 +203,10 @@ class TestMain: ), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), ): - assert main([]) == 0 + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "No changed files" in result.output def test_all_have_tests(self, tmp_path: Path) -> None: (tmp_path / "scripts" / "tests").mkdir(parents=True) @@ -214,7 +219,10 @@ class TestMain: ), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), ): - assert main([]) == 0 + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "have tests" in result.output def test_missing_test_returns_1(self, tmp_path: Path) -> None: with ( @@ -225,7 +233,9 @@ class TestMain: ), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), ): - assert main([]) == 1 + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code != 0 def test_warn_only_returns_0(self, tmp_path: Path) -> None: with ( @@ -236,4 +246,6 @@ class TestMain: ), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), ): - assert main(["--warn-only"]) == 0 + runner = CliRunner() + result = runner.invoke(cli, ["--warn-only"]) + assert result.exit_code == 0 diff --git a/tests/unit/test_configure_repo.py b/tests/unit/test_configure_repo.py index 24d8e50..3d8f559 100644 --- a/tests/unit/test_configure_repo.py +++ b/tests/unit/test_configure_repo.py @@ -145,6 +145,7 @@ class TestMain: assert "CI_GITEA_TOKEN" in result.output @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) + @patch("devx.tools.configure_repo.REPO_NAME", "") def test_main_no_repo(self) -> None: runner = CliRunner() result = runner.invoke(main, []) @@ -197,3 +198,19 @@ class TestMain: call_args = mock_client_cls.call_args assert call_args[0][2] == "oblachno" # owner assert call_args[0][3] == "infra" # repo + + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) + @patch("devx.tools.configure_repo.REPO_NAME", "devx") + @patch("devx.tools.configure_repo.REPO_OWNER", "oblachno-oss") + @patch("devx.tools.configure_repo.GiteaClient") + def test_main_repo_from_pyproject(self, mock_client_cls: MagicMock) -> None: + """When no env var is set, repo name should come from pyproject.toml.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + call_args = mock_client_cls.call_args + assert call_args[0][2] == "oblachno-oss" # owner + assert call_args[0][3] == "devx" # repo diff --git a/tests/unit/test_create_pr.py b/tests/unit/test_create_pr.py index a29c0c0..5c08df0 100644 --- a/tests/unit/test_create_pr.py +++ b/tests/unit/test_create_pr.py @@ -56,7 +56,7 @@ class TestGetVikunjaTaskTitle: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_found(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() - mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}] + mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42", "title": "Add feature"} mock_client_cls.return_value = mock_client assert get_vikunja_task_title("DEVX-42") == "Add feature" @@ -69,20 +69,7 @@ class TestGetVikunjaTaskTitle: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_not_found(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() - mock_client.list_project_tasks.return_value = [] - mock_client_cls.return_value = mock_client - with pytest.raises(click.ClickException, match="Could not find"): - get_vikunja_task_title("DEVX-42") - - @patch("devx.tools.create_pr.VikunjaClient") - @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None: - from devx.config import DEFAULT_PER_PAGE - - mock_client = MagicMock() - page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)] - page2 = [{"identifier": "OTHER-99"}] - mock_client.list_project_tasks.side_effect = [page1, page2] + mock_client.find_task_by_identifier.return_value = None mock_client_cls.return_value = mock_client with pytest.raises(click.ClickException, match="Could not find"): get_vikunja_task_title("DEVX-42") diff --git a/tests/unit/test_install_checkmake.py b/tests/unit/test_install_checkmake.py index 73e6360..50b351f 100644 --- a/tests/unit/test_install_checkmake.py +++ b/tests/unit/test_install_checkmake.py @@ -63,13 +63,19 @@ class TestDownloadBinary: class TestMain: def test_already_installed(self) -> None: + from click.testing import CliRunner + with patch("shutil.which", return_value="/usr/bin/checkmake"): - install_checkmake.main() + runner = CliRunner() + runner.invoke(install_checkmake.cli, []) def test_install_with_go(self) -> None: + from click.testing import CliRunner + with patch("shutil.which", side_effect=[None, "/usr/bin/go"]): with patch("subprocess.run") as mock_run: - install_checkmake.main() + runner = CliRunner() + runner.invoke(install_checkmake.cli, []) mock_run.assert_called_once_with( [ "/usr/bin/go", @@ -80,6 +86,8 @@ class TestMain: ) def test_download_when_no_go(self, tmp_path: Path) -> None: + from click.testing import CliRunner + target = tmp_path / "checkmake" def _write_file(url: str, path: str) -> tuple[str, None]: @@ -90,5 +98,6 @@ class TestMain: with patch("shutil.which", side_effect=[None, None]): with patch.object(platform, "machine", return_value="x86_64"): with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve: - install_checkmake.main() + runner = CliRunner() + runner.invoke(install_checkmake.cli, []) mock_retrieve.assert_called_once() diff --git a/tests/unit/test_pre_push_check.py b/tests/unit/test_pre_push_check.py index 4ad3bb2..4d48deb 100644 --- a/tests/unit/test_pre_push_check.py +++ b/tests/unit/test_pre_push_check.py @@ -43,7 +43,7 @@ class TestTaskExists: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_found(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() - mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42"}] + mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42"} mock_client_cls.return_value = mock_client assert task_exists("DEVX-42") is True @@ -51,7 +51,7 @@ class TestTaskExists: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_not_found(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() - mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}] + mock_client.find_task_by_identifier.return_value = None mock_client_cls.return_value = mock_client assert task_exists("DEVX-42") is False @@ -59,37 +59,6 @@ class TestTaskExists: def test_no_token(self) -> None: assert task_exists("DEVX-42") is False - @patch("devx.tools.pre_push_check.VikunjaClient") - @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - def test_pagination(self, mock_client_cls: MagicMock) -> None: - mock_client = MagicMock() - # First page: full page (50 items, none matching), second page: match - page1 = [{"identifier": f"OTHER-{i}"} for i in range(50)] - page2 = [{"identifier": "DEVX-42"}] - mock_client.list_project_tasks.side_effect = [page1, page2] - mock_client_cls.return_value = mock_client - assert task_exists("DEVX-42") is True - - @patch("devx.tools.pre_push_check.VikunjaClient") - @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - def test_empty_project(self, mock_client_cls: MagicMock) -> None: - mock_client = MagicMock() - mock_client.list_project_tasks.return_value = [] - mock_client_cls.return_value = mock_client - assert task_exists("DEVX-42") is False - - @patch("devx.tools.pre_push_check.VikunjaClient") - @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None: - from devx.config import DEFAULT_PER_PAGE - - mock_client = MagicMock() - page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)] - page2 = [{"identifier": "OTHER-99"}] - mock_client.list_project_tasks.side_effect = [page1, page2] - mock_client_cls.return_value = mock_client - assert task_exists("DEVX-42") is False - class TestValidate: def test_master_branch_skips(self) -> None: