From 44c906a5e687465662011090176055fafabfef80 Mon Sep 17 00:00:00 2001 From: emil Date: Fri, 26 Jun 2026 14:29:47 +0000 Subject: [PATCH] DEVX-60: feat: add create-task, create-pr, pre-push-check tools and devx.mak fragment --- pyproject.toml | 2 +- src/devx/__init__.py | 2 +- src/devx/api_clients.py | 30 +++ src/devx/make/devx.mak | 56 ++++++ src/devx/tools/create_pr.py | 191 ++++++++++++++++++ src/devx/tools/create_task.py | 81 ++++++++ src/devx/tools/pre_push_check.py | 133 +++++++++++++ src/devx/tools/setup.py | 14 +- src/devx/translations.json | 128 ++++++++++++ tests/unit/test_api_clients.py | 80 ++++++++ tests/unit/test_auto_merge.py | 16 ++ tests/unit/test_classify_changes.py | 30 +++ tests/unit/test_create_pr.py | 193 +++++++++++++++++++ tests/unit/test_create_task.py | 83 ++++++++ tests/unit/test_discover_runners.py | 12 ++ tests/unit/test_doc_coverage.py | 15 ++ tests/unit/test_generate_badges.py | 17 ++ tests/unit/test_molecule_ci_guard.py | 5 + tests/unit/test_molecule_discover_runners.py | 11 ++ tests/unit/test_pr_review.py | 61 ++++++ tests/unit/test_pre_push_check.py | 137 +++++++++++++ tests/unit/test_publish.py | 7 + tests/unit/test_release.py | 34 ++++ tests/unit/test_setup.py | 28 +++ tests/unit/test_start_docker.py | 16 ++ 25 files changed, 1378 insertions(+), 4 deletions(-) create mode 100644 src/devx/make/devx.mak create mode 100644 src/devx/tools/create_pr.py create mode 100644 src/devx/tools/create_task.py create mode 100644 src/devx/tools/pre_push_check.py create mode 100644 tests/unit/test_create_pr.py create mode 100644 tests/unit/test_create_task.py create mode 100644 tests/unit/test_pre_push_check.py diff --git a/pyproject.toml b/pyproject.toml index 40a08ec..a984cfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dev = [ where = ["src"] [tool.setuptools.package-data] -devx = ["translations.json"] +devx = ["translations.json", "make/*.mak"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/devx/__init__.py b/src/devx/__init__.py index 4ec48d8..6e0e00f 100644 --- a/src/devx/__init__.py +++ b/src/devx/__init__.py @@ -1,3 +1,3 @@ """devx — reusable development and CI/CD tools for oblachno-oss projects.""" -__version__ = "0.14.2" +__version__ = "0.15.0" diff --git a/src/devx/api_clients.py b/src/devx/api_clients.py index 0ec06b1..64388db 100644 --- a/src/devx/api_clients.py +++ b/src/devx/api_clients.py @@ -192,6 +192,21 @@ class GiteaClient: r = self._request("GET", f"/pulls/{pr_number}") return r.json() + def create_pr(self, title: str, head: str, base: str = "master", body: str = "") -> dict[str, Any]: + """Create a pull request and return the PR dict. + + Args: + title: PR title. + head: Head branch name. + base: Base branch name (default: master). + body: PR description (markdown). + """ + payload: dict[str, Any] = {"title": title, "head": head, "base": base} + if body: + payload["body"] = body + r = self._request("POST", "/pulls", json=payload) + return r.json() + def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]: """List pull requests, optionally filtered by state. @@ -353,6 +368,21 @@ class VikunjaClient: r = self._request("GET", f"/projects/{project_id}/tasks", params=params) return r.json() + 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. + + Args: + project_id: Target Vikunja project ID. + title: Task title (required, non-empty). + description: Task description (HTML supported, optional). + """ + r = self._request( + "PUT", + f"/projects/{project_id}/tasks", + json={"title": title, "description": description}, + ) + return r.json() + def post_comment(self, task_id: int, comment: str) -> None: self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment}) diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak new file mode 100644 index 0000000..35f3f49 --- /dev/null +++ b/src/devx/make/devx.mak @@ -0,0 +1,56 @@ +# devx.mak — Shared Makefile fragment for devx-integrated projects. +# +# This fragment provides common targets for Vikunja task management, +# PR creation, and pushing. It is designed to be included from a +# project's Makefile after project-specific variables are set. +# +# Usage in your Makefile: +# +# # Set project-specific variables +# DEVX_VIKUNJA_PROJECT_ID := 3 +# DEVX_REPO_OWNER := oblachno +# DEVX_REPO_NAME := infra +# DEVX_PYTHON := python3 # or $(BIN)/python, etc. +# +# # Include the devx fragment (silent if devx not installed yet) +# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \ +# "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \ +# 2>/dev/null) +# -include $(DEVX_MAK) +# +# The fragment uses ?= for all variables so projects can override them +# before the include. If devx is not installed, the -include silently +# skips and the targets are simply unavailable (run 'make setup' first). +# +# Variables: +# DEVX_VIKUNJA_PROJECT_ID — Vikunja project ID (default: 1) +# DEVX_REPO_OWNER — Gitea repository owner (default: empty) +# DEVX_REPO_NAME — Gitea repository name (default: empty) +# DEVX_PYTHON — Python executable (default: python3) +# DEVX_PR_BASE — PR base branch (default: master) + +DEVX_VIKUNJA_PROJECT_ID ?= 1 +DEVX_REPO_OWNER ?= +DEVX_REPO_NAME ?= +DEVX_PYTHON ?= python3 +DEVX_PR_BASE ?= master + +.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr + +# Create a Vikunja task in the configured project +devx-create-task: + @$(DEVX_PYTHON) -m devx.tools.create_task --project-id $(DEVX_VIKUNJA_PROJECT_ID) + +# Create a PR with title auto-derived from the Vikunja task +devx-create-pr: + @$(DEVX_PYTHON) -m devx.tools.create_pr \ + --owner $(DEVX_REPO_OWNER) \ + --repo $(DEVX_REPO_NAME) \ + --base $(DEVX_PR_BASE) + +# Push current branch to origin +devx-push: + @git push -u origin HEAD + +# Push and create PR in one step +devx-push-with-pr: devx-push devx-create-pr diff --git a/src/devx/tools/create_pr.py b/src/devx/tools/create_pr.py new file mode 100644 index 0000000..497abab --- /dev/null +++ b/src/devx/tools/create_pr.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Create a pull request with the correct title from the Vikunja task. + +This tool is run **after** pushing a feature branch. It: + +1. Extracts the task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``). +2. Fetches the Vikunja task title for that task ID. +3. Creates a PR with title ``{TASK_PREFIX}-N: ``. + +This eliminates manual PR title entry and ensures the title always +matches the Vikunja task — which is what the auto-merge workflow +validates. + +If a PR already exists for the branch, the tool prints its URL and +exits successfully (idempotent). + +Usage:: + + python -m devx.tools.create_pr --branch DEVX-31-fix-foo + +The repository is auto-detected from ``DEVX_REPO_OWNER`` / +``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables. +""" + +from __future__ import annotations + +import os +import subprocess # nosec B404 + +import click +from dotenv import load_dotenv + +from devx.api_clients import GiteaClient, VikunjaClient +from devx.config import ( + DEFAULT_PER_PAGE, + GITEA_API_URL, + REPO_OWNER, + TASK_ID_RE, + TASK_PREFIX, + VIKUNJA_API_URL, + VIKUNJA_PROJECT_ID, +) +from devx.i18n import _ + +load_dotenv() + + +def get_repo_name() -> str: + """Auto-detect repository name from env vars or git remote.""" + name = os.environ.get("DEVX_REPO_NAME", "") + if name: + return name + github_repo = os.environ.get("GITHUB_REPOSITORY", "") + if github_repo and "/" in github_repo: + return github_repo.split("/", 1)[1] + raise click.ClickException( + _("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."), + ) + + +def extract_task_id(branch: str) -> str: + """Extract the task ID (e.g. ``DEVX-31``) from a branch name.""" + match = TASK_ID_RE.search(branch) + return match.group(0) if match else "" + + +def get_vikunja_task_title(task_id: str) -> str: + """Fetch the Vikunja task title for the given task identifier. + + Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found. + """ + token = os.environ.get("VIKUNJA_TOKEN", "") + 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, + ), + ) + + +def find_existing_pr(client: GiteaClient, branch: str) -> dict | None: + """Return an existing open PR for the branch, or None.""" + prs = client.list_prs(state="open") + for pr in prs: + if pr.get("head", {}).get("ref") == branch: + return pr + return None + + +def create_pr( + branch: str, + base: str, + body: str, + repo_owner: str, + repo_name: str, +) -> dict: + """Create a PR with the title derived from the Vikunja task. + + Returns the PR dict from the Gitea API. + """ + task_id = extract_task_id(branch) + if not task_id: + raise click.ClickException( + _( + "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description", + branch=branch, + prefix=TASK_PREFIX, + ), + ) + + token = os.environ.get("REPO_TOKEN", "") + if not token: + raise click.ClickException(_("REPO_TOKEN is not set. Required to create a PR.")) + + vikunja_title = get_vikunja_task_title(task_id) + pr_title = f"{task_id}: {vikunja_title}" + + client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name) + + existing = find_existing_pr(client, branch) + if existing: + click.echo( + _( + "PR already exists: #{index} — {url}", + index=existing.get("number", "?"), + url=existing.get("html_url", ""), + ), + ) + return existing + + pr = client.create_pr(title=pr_title, head=branch, base=base, body=body) + click.echo( + _( + "Created PR #{index}: {title}\n {url}", + index=pr.get("number", "?"), + title=pr_title, + url=pr.get("html_url", ""), + ), + ) + return pr + + +@click.command() +@click.option("--branch", default=None, help="Head branch (default: auto-detect from git).") +@click.option("--base", default="master", show_default=True, help="Base branch.") +@click.option("--body", default="", help="PR body (markdown). Read from stdin if '-' is passed.") +@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).") +@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).") +def cli(branch: str | None, base: str, body: str, owner: str | None, repo: str | None) -> None: + """Create a PR with the correct title from the Vikunja task.""" + if branch is None: + result = subprocess.run( # nosec + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise click.ClickException( + _("Could not detect current branch: {error}", error=result.stderr.strip()), + ) + branch = result.stdout.strip() + + if body == "-": + body = click.get_text_stream("stdin").read().strip() + + repo_owner = owner or REPO_OWNER + if not repo_owner: + raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.")) + repo_name = repo or get_repo_name() + + create_pr(branch, base, body, repo_owner, repo_name) + + +if __name__ == "__main__": # pragma: no cover + cli() # pragma: no cover diff --git a/src/devx/tools/create_task.py b/src/devx/tools/create_task.py new file mode 100644 index 0000000..3b0b131 --- /dev/null +++ b/src/devx/tools/create_task.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Create a Vikunja task with a detailed HTML description. + +This tool is used during the planning phase of the development workflow +to create a well-described task before any code is written. The task +identifier (e.g. ``DEVX-N``, ``GRM-N``, ``OBL-INFRA-N``) is then used +to name the feature branch and the pull request. + +Usage:: + + python -m devx.tools.create_task --title "Add release automation" \\ + --description "

Overview

Implement automated...

" + +The project ID and task prefix are read from ``DEVX_VIKUNJA_PROJECT_ID`` +and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``). +""" + +from __future__ import annotations + +import os + +import click +from dotenv import load_dotenv + +from devx.api_clients import VikunjaClient +from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID +from devx.i18n import _ + +load_dotenv() + + +@click.command() +@click.option("--title", required=True, help="Task title (becomes the Vikunja task title).") +@click.option( + "--description", + default="", + help="Task description (HTML supported). Read from stdin if '-' is passed.", +) +@click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).") +def cli(title: str, description: str, project_id: int | None) -> None: + """Create a Vikunja task and print its identifier.""" + token = os.environ.get("VIKUNJA_TOKEN", "") + if not token: + raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment.")) + + pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID + + if description == "-": + description = click.get_text_stream("stdin").read().strip() + + client = VikunjaClient(VIKUNJA_API_URL, token) + task = client.create_task(pid, title, description) + + identifier = task.get("identifier", "") + task_id = task.get("id", "") + click.echo( + _( + "Created Vikunja task: {identifier} (id={task_id})", + identifier=identifier, + task_id=task_id, + ) + ) + if identifier: + click.echo( + _( + "Next steps:\n" + " 1. git checkout master && git pull\n" + " 2. git checkout -b {prefix}-{num}-short-description\n" + " 3. Implement changes, commit with conventional commit format\n" + " 4. git push -u origin HEAD\n" + " 5. make create-pr (creates PR with title: {identifier}: {title})", + prefix=TASK_PREFIX, + num=identifier.split("-")[-1] if "-" in identifier else "N", + identifier=identifier, + title=title, + ) + ) + + +if __name__ == "__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 new file mode 100644 index 0000000..9a328eb --- /dev/null +++ b/src/devx/tools/pre_push_check.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Pre-push validation: ensure a Vikunja task exists for the branch. + +This tool is designed to run as a git pre-push hook. It extracts the +task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``) +and verifies that a corresponding Vikunja task exists. + +If the task does not exist, the hook **fails with guidance** — it does +not auto-create the task. This prevents accidental pushes of branches +without a planning task. + +Usage:: + + python -m devx.tools.pre_push_check --branch DEVX-31-fix-foo + +Exit codes: + 0 — all checks passed, safe to push + 1 — validation failed (missing task, missing token, etc.) +""" + +from __future__ import annotations + +import os +import subprocess # nosec B404 + +import click +from dotenv import load_dotenv + +from devx.api_clients import VikunjaClient +from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID +from devx.i18n import _ + +load_dotenv() + + +def get_current_branch() -> str: + """Return the current git branch name, or empty string on error.""" + result = subprocess.run( # nosec + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + return result.stdout.strip() + + +def extract_task_id(branch: str) -> str: + """Extract the task ID (e.g. ``DEVX-31``) from a branch name.""" + match = TASK_ID_RE.search(branch) + return match.group(0) if match else "" + + +def task_exists(task_id: str) -> bool: + """Check if a Vikunja task with the given identifier exists. + + Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode). + """ + token = os.environ.get("VIKUNJA_TOKEN", "") + 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 + + +def validate(branch: str) -> None: + """Run all pre-push validations for the given branch. + + Raises ``click.ClickException`` on failure. + """ + if not branch or branch in ("master", "main"): + return + + task_id = extract_task_id(branch) + if not task_id: + raise click.ClickException( + _( + "Branch '{branch}' does not contain a task ID.\n" + " Expected format: {prefix}-N-short-description\n" + " Example: {prefix}-42-add-feature\n" + " Fix: rename the branch or create a Vikunja task first:\n" + ' python -m devx.tools.create_task --title "Task title"', + branch=branch, + prefix=TASK_PREFIX, + ) + ) + + token = os.environ.get("VIKUNJA_TOKEN", "") + if not token: + click.echo( + _( + "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. " + "Set it in .env to enable full validation.", + ), + err=True, + ) + return + + if not task_exists(task_id): + raise click.ClickException( + _( + "Vikunja task {task_id} not found in project {project_id}.\n" + " Create it first:\n" + ' python -m devx.tools.create_task --title "Task title"\n' + " Or check that the task ID in the branch name is correct.", + task_id=task_id, + project_id=VIKUNJA_PROJECT_ID, + ) + ) + + click.echo(_("Pre-push check passed: task {task_id} exists.", task_id=task_id)) + + +@click.command() +@click.option("--branch", default=None, help="Branch name (default: auto-detect from git).") +def cli(branch: str | None) -> None: + """Validate pre-push preconditions for the current branch.""" + if branch is None: + branch = get_current_branch() + validate(branch) + + +if __name__ == "__main__": # pragma: no cover + cli() # pragma: no cover diff --git a/src/devx/tools/setup.py b/src/devx/tools/setup.py index 18092a8..be6aafb 100644 --- a/src/devx/tools/setup.py +++ b/src/devx/tools/setup.py @@ -150,19 +150,29 @@ def _verify(bin_dir: str) -> None: default=False, help="Skip Ansible Galaxy collection installation.", ) +@click.option( + "--skip-install", + is_flag=True, + default=False, + help="Skip pip install (use when deps already installed, e.g. devx came via ci extra).", +) def main( bin_dir: str, extras: str, no_pre_commit: bool, no_tea_login: bool, no_ansible_collections: bool, + skip_install: bool, ) -> None: """Install Python deps, pre-commit hooks, and configure tea CLI.""" if not Path(bin_dir).exists(): raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.") - click.echo(f"Installing Python dependencies (extras: {extras})...") - _install_python_deps(bin_dir, extras) + if not skip_install: + click.echo(f"Installing Python dependencies (extras: {extras})...") + _install_python_deps(bin_dir, extras) + else: + click.echo("Skipping pip install (--skip-install).") if not no_ansible_collections: click.echo("Installing Ansible Galaxy collections...") diff --git a/src/devx/translations.json b/src/devx/translations.json index 26cbcf4..b138298 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -1470,5 +1470,133 @@ "pl": "{file} już istnieje. Użyj --force, aby nadpisać.", "ru": "{file} already exists. Use --force to overwrite.", "zh": "{file} already exists. Use --force to overwrite." + }, + "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": { + "bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание", + "de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung", + "en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description", + "pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis", + "ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание", + "zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述" + }, + "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": { + "bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"", + "de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"", + "en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"", + "pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"", + "ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"", + "zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\"" + }, + "Could not find Vikunja task {task_id} in project {project_id}.": { + "bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.", + "de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.", + "en": "Could not find Vikunja task {task_id} in project {project_id}.", + "pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.", + "ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.", + "zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。" + }, + "Could not detect current branch: {error}": { + "bg": "Не може да се определи текущия клон: {error}", + "de": "Aktueller Branch konnte nicht erkannt werden: {error}", + "en": "Could not detect current branch: {error}", + "pl": "Nie można wykryć bieżącej gałęzi: {error}", + "ru": "Не удалось определить текущую ветку: {error}", + "zh": "无法检测当前分支: {error}" + }, + "Created PR #{index}: {title}\n {url}": { + "bg": "Създаден PR #{index}: {title}\n {url}", + "de": "PR erstellt #{index}: {title}\n {url}", + "en": "Created PR #{index}: {title}\n {url}", + "pl": "Utworzono PR #{index}: {title}\n {url}", + "ru": "Создан PR #{index}: {title}\n {url}", + "zh": "已创建 PR #{index}: {title}\n {url}" + }, + "Created Vikunja task: {identifier} (id={task_id})": { + "bg": "Създадена Vikunja задача: {identifier} (id={task_id})", + "de": "Vikunja-Task erstellt: {identifier} (id={task_id})", + "en": "Created Vikunja task: {identifier} (id={task_id})", + "pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})", + "ru": "Создана задача Vikunja: {identifier} (id={task_id})", + "zh": "已创建 Vikunja 任务: {identifier} (id={task_id})" + }, + "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": { + "bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})", + "de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})", + "en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})", + "pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})", + "ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})", + "zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})" + }, + "PR already exists: #{index} — {url}": { + "bg": "PR вече съществува: #{index} — {url}", + "de": "PR existiert bereits: #{index} — {url}", + "en": "PR already exists: #{index} — {url}", + "pl": "PR już istnieje: #{index} — {url}", + "ru": "PR уже существует: #{index} — {url}", + "zh": "PR 已存在: #{index} — {url}" + }, + "Pre-push check passed: task {task_id} exists.": { + "bg": "Pre-push проверката премина: задача {task_id} съществува.", + "de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.", + "en": "Pre-push check passed: task {task_id} exists.", + "pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.", + "ru": "Pre-push проверка пройдена: задача {task_id} существует.", + "zh": "Pre-push 检查通过: 任务 {task_id} 存在。" + }, + "REPO_TOKEN is not set. Required to create a PR.": { + "bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.", + "de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.", + "en": "REPO_TOKEN is not set. Required to create a PR.", + "pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.", + "ru": "REPO_TOKEN не установлен. Требуется для создания PR.", + "zh": "REPO_TOKEN 未设置。创建 PR 所需。" + }, + "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": { + "bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.", + "de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.", + "en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.", + "pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.", + "ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.", + "zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。" + }, + "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": { + "bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.", + "de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.", + "en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.", + "pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.", + "ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.", + "zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。" + }, + "VIKUNJA_TOKEN is not set. Required to derive PR title.": { + "bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.", + "de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.", + "en": "VIKUNJA_TOKEN is not set. Required to derive PR title.", + "pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.", + "ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.", + "zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。" + }, + "VIKUNJA_TOKEN is not set. Set it in .env or environment.": { + "bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.", + "de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.", + "en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.", + "pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.", + "ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.", + "zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。" + }, + "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": { + "bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.", + "de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.", + "en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.", + "pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.", + "ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.", + "zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。" + }, + "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": { + "bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.", + "de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.", + "en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.", + "pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.", + "ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.", + "zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。" } } diff --git a/tests/unit/test_api_clients.py b/tests/unit/test_api_clients.py index d4ddc86..8b024a9 100644 --- a/tests/unit/test_api_clients.py +++ b/tests/unit/test_api_clients.py @@ -137,6 +137,19 @@ class TestGiteaClient: assert result is None client.create_label.assert_not_called() + def test_ensure_label_creates_when_others_exist(self) -> None: + """When labels exist but none match the target name, create a new one.""" + client = GiteaClient("https://git.example.com", "tok", "owner", "repo") + client.list_labels = MagicMock( + return_value=[{"name": "bug", "color": "ff0000"}, {"name": "docs", "color": "007ec6"}] + ) + client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"}) + + result = client.ensure_label("ready-to-merge", "2ecc71", "desc") + assert result is not None + assert result["name"] == "ready-to-merge" + client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc") + def test_list_branch_protections(self) -> None: client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client._session.request = MagicMock( @@ -196,6 +209,18 @@ class TestGiteaClient: expected_update = {k: v for k, v in TEST_BP_CONFIG.items() if k != "branch_name"} client.update_branch_protection.assert_called_once_with("master", expected_update) + def test_ensure_branch_protection_creates_when_none_match(self) -> None: + """When existing protections exist but none match the target branch, create a new one.""" + client = GiteaClient("https://git.example.com", "tok", "owner", "repo") + client.list_branch_protections = MagicMock( + return_value=[{"branch_name": "develop"}, {"branch_name": "staging"}] + ) + client.create_branch_protection = MagicMock(return_value={"id": 5, "branch_name": "master"}) + + result = client.ensure_branch_protection("master", TEST_BP_CONFIG) + assert result["id"] == 5 + client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG) + def test_merge_pr(self) -> None: client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client._session.request = MagicMock(return_value=_mock_response()) @@ -248,6 +273,37 @@ class TestGiteaClient: timeout=DEFAULT_TIMEOUT, ) + def test_create_pr(self) -> None: + client = GiteaClient("https://git.example.com", "tok", "owner", "repo") + client._session.request = MagicMock( + return_value=_mock_response({"number": 15, "html_url": "https://git.example.com/pr/15"}) + ) + result = client.create_pr(title="DEVX-42: Add feature", head="DEVX-42-fix", body="desc") + assert result["number"] == 15 + client._session.request.assert_called_once_with( + "POST", + "https://git.example.com/repos/owner/repo/pulls", + timeout=DEFAULT_TIMEOUT, + json={"title": "DEVX-42: Add feature", "head": "DEVX-42-fix", "base": "master", "body": "desc"}, + ) + + def test_create_pr_no_body(self) -> None: + client = GiteaClient("https://git.example.com", "tok", "owner", "repo") + client._session.request = MagicMock( + return_value=_mock_response({"number": 16, "html_url": "https://git.example.com/pr/16"}) + ) + result = client.create_pr(title="DEVX-43: Fix bug", head="DEVX-43-fix") + assert result["number"] == 16 + call_kwargs = client._session.request.call_args.kwargs + assert "body" not in call_kwargs["json"] + + def test_create_pr_custom_base(self) -> None: + client = GiteaClient("https://git.example.com", "tok", "owner", "repo") + client._session.request = MagicMock(return_value=_mock_response({"number": 17})) + client.create_pr(title="Test", head="branch", base="develop") + call_kwargs = client._session.request.call_args.kwargs + assert call_kwargs["json"]["base"] == "develop" + def test_get_pr_files(self) -> None: client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client._session.request = MagicMock( @@ -701,6 +757,30 @@ class TestVikunjaClient: assert exc_info.value.status == 0 assert client._session.request.call_count == 3 # MAX_RETRIES + def test_vikunja_create_task(self) -> None: + client = VikunjaClient("https://work.example.com", "tok") + client._session.request = MagicMock( + return_value=_mock_response({"id": 1, "identifier": "DEVX-1", "title": "Test"}) + ) + result = client.create_task(6, "Test", "

desc

") + assert result["identifier"] == "DEVX-1" + client._session.request.assert_called_once_with( + "PUT", + "https://work.example.com/projects/6/tasks", + timeout=DEFAULT_TIMEOUT, + json={"title": "Test", "description": "

desc

"}, + ) + + def test_vikunja_create_task_no_description(self) -> None: + client = VikunjaClient("https://work.example.com", "tok") + client._session.request = MagicMock( + return_value=_mock_response({"id": 2, "identifier": "DEVX-2", "title": "No desc"}) + ) + result = client.create_task(6, "No desc") + assert result["id"] == 2 + call_kwargs = client._session.request.call_args.kwargs + assert call_kwargs["json"]["description"] == "" + class TestIsRetryable: def test_connection_error_is_retryable(self) -> None: diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index 0e7a71d..e129b83 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -47,6 +47,22 @@ class TestReadTaskid: captured = capsys.readouterr() assert "WARNING" not in captured.out + def test_no_warning_when_taskid_file_matches_branch(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def] + """No warning when .taskid file content matches the branch task ID.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".taskid").write_text("DEVX-19\n") + assert read_taskid("DEVX-19-fix-bug") == "DEVX-19" + captured = capsys.readouterr() + assert "WARNING" not in captured.out + + def test_no_warning_when_taskid_file_empty(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def] + """No warning when .taskid file exists but is empty.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".taskid").write_text("\n") + assert read_taskid("DEVX-19-fix-bug") == "DEVX-19" + captured = capsys.readouterr() + assert "WARNING" not in captured.out + # -- extract_task_id (legacy fallback) -- diff --git a/tests/unit/test_classify_changes.py b/tests/unit/test_classify_changes.py index a5ebdde..c32f4d9 100644 --- a/tests/unit/test_classify_changes.py +++ b/tests/unit/test_classify_changes.py @@ -162,6 +162,15 @@ class TestClassifierConfig: assert config.user_facing_overrides == [] assert config.tags == {} + def test_from_pyproject_dedupes_existing_default(self, tmp_path: Path) -> None: + """Project infrastructure patterns already in defaults are not duplicated.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**", "scripts/**"]\n') + config = ClassifierConfig.from_pyproject(str(pyproject)) + # .gitea/** should appear only once (deduplicated with defaults) + assert config.infrastructure.count(".gitea/**") == 1 + assert "scripts/**" in config.infrastructure + def test_defaults_are_empty_for_bare_constructor(self) -> None: """ClassifierConfig() without from_pyproject has empty lists.""" config = ClassifierConfig() @@ -515,6 +524,27 @@ class TestMain: assert "Ansible files" in result.output assert "ansible/tasks/main.yml" in result.output + @patch("devx.ci.classify_changes._get_classifier") + @patch("devx.ci.classify_changes.get_changed_files") + @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") + def test_default_mode_skips_empty_tag( + self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock + ) -> None: + """Tags with no matching files are skipped in default mode output.""" + mock_changes.return_value = ["ansible/tasks/main.yml"] + mock_clf.return_value = ChangeClassifier( + ClassifierConfig( + infrastructure=[".gitea/**"], + tags={"ansible": ["ansible/**"], "docs": ["docs/**"]}, + ) + ) + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "Ansible files" in result.output + # docs tag has no matching files — should not appear + assert "Docs files" not in result.output + @patch("devx.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None: runner = CliRunner() diff --git a/tests/unit/test_create_pr.py b/tests/unit/test_create_pr.py new file mode 100644 index 0000000..e5dd5b2 --- /dev/null +++ b/tests/unit/test_create_pr.py @@ -0,0 +1,193 @@ +"""Unit tests for devx.tools.create_pr.""" + +from unittest.mock import MagicMock, patch + +import click +import pytest +from click.testing import CliRunner + +from devx.tools.create_pr import ( + cli, + create_pr, + extract_task_id, + find_existing_pr, + get_repo_name, + get_vikunja_task_title, +) + + +class TestExtractTaskId: + def test_valid(self) -> None: + assert extract_task_id("DEVX-42-fix") == "DEVX-42" + + def test_invalid(self) -> None: + assert extract_task_id("feature") == "" + + +class TestGetRepoName: + @patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"}) + def test_from_env(self) -> None: + assert get_repo_name() == "infra" + + @patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True) + def test_from_github(self) -> None: + assert get_repo_name() == "infra" + + @patch.dict("os.environ", {}, clear=True) + def test_missing_raises(self) -> None: + with pytest.raises(click.ClickException, match="Repository name"): + get_repo_name() + + +class TestGetVikunjaTaskTitle: + @patch("devx.tools.create_pr.VikunjaClient") + @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_cls.return_value = mock_client + assert get_vikunja_task_title("DEVX-42") == "Add feature" + + @patch.dict("os.environ", {}, clear=True) + def test_no_token(self) -> None: + with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN"): + get_vikunja_task_title("DEVX-42") + + @patch("devx.tools.create_pr.VikunjaClient") + @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_cls.return_value = mock_client + with pytest.raises(click.ClickException, match="Could not find"): + get_vikunja_task_title("DEVX-42") + + +class TestFindExistingPr: + def test_found(self) -> None: + client = MagicMock() + client.list_prs.return_value = [{"head": {"ref": "DEVX-42-fix"}, "number": 10}] + result = find_existing_pr(client, "DEVX-42-fix") + assert result is not None + assert result["number"] == 10 + + def test_not_found(self) -> None: + client = MagicMock() + client.list_prs.return_value = [{"head": {"ref": "other"}, "number": 10}] + result = find_existing_pr(client, "DEVX-42-fix") + assert result is None + + +class TestCreatePr: + @patch("devx.tools.create_pr.GiteaClient") + @patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature") + @patch("devx.tools.create_pr.find_existing_pr", return_value=None) + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None: + mock_client = MagicMock() + mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"} + mock_gitea.return_value = mock_client + result = create_pr("DEVX-42-fix", "master", "body", "owner", "repo") + assert result["number"] == 15 + mock_client.create_pr.assert_called_once_with( + title="DEVX-42: Add feature", + head="DEVX-42-fix", + base="master", + body="body", + ) + + @patch("devx.tools.create_pr.GiteaClient") + @patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature") + @patch("devx.tools.create_pr.find_existing_pr") + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None: + mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"} + mock_client = MagicMock() + mock_gitea.return_value = mock_client + result = create_pr("DEVX-42-fix", "master", "", "owner", "repo") + assert result["number"] == 10 + mock_client.create_pr.assert_not_called() + + @patch.dict("os.environ", {}, clear=True) + def test_no_repo_token(self) -> None: + with pytest.raises(click.ClickException, match="REPO_TOKEN"): + create_pr("DEVX-42-fix", "master", "", "owner", "repo") + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + def test_no_task_id_in_branch(self) -> None: + with pytest.raises(click.ClickException, match="does not contain a task ID"): + create_pr("feature-branch", "master", "", "owner", "repo") + + +class TestCli: + @patch("devx.tools.create_pr.create_pr") + @patch("devx.tools.create_pr.subprocess.run") + @patch("devx.tools.create_pr.REPO_OWNER", "owner") + @patch("devx.tools.create_pr.get_repo_name", return_value="repo") + def test_auto_detect_branch(self, mock_repo: MagicMock, mock_run: MagicMock, mock_create: MagicMock) -> None: + mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0) + mock_create.return_value = {"number": 1} + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 + mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo") + + @patch("devx.tools.create_pr.create_pr") + @patch("devx.tools.create_pr.REPO_OWNER", "owner") + @patch("devx.tools.create_pr.get_repo_name", return_value="repo") + def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock) -> None: + mock_create.return_value = {"number": 1} + runner = CliRunner() + result = runner.invoke(cli, ["--branch", "DEVX-42-fix"]) + assert result.exit_code == 0 + + @patch("devx.tools.create_pr.create_pr") + @patch("devx.tools.create_pr.REPO_OWNER", "owner") + @patch("devx.tools.create_pr.get_repo_name", return_value="repo") + def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock) -> None: + mock_create.return_value = {"number": 1} + runner = CliRunner() + result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text") + assert result.exit_code == 0 + mock_create.assert_called_once() + assert mock_create.call_args.args[2] == "PR body text" + + @patch("devx.tools.create_pr.REPO_OWNER", "") + @patch("devx.tools.create_pr.get_repo_name", return_value="repo") + def test_missing_owner(self, mock_repo: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["--branch", "DEVX-42-fix"]) + assert result.exit_code != 0 + assert "owner" in result.output.lower() + + @patch("devx.tools.create_pr.create_pr") + @patch("devx.tools.create_pr.get_repo_name", return_value="repo") + def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock) -> None: + mock_create.return_value = {"number": 1} + runner = CliRunner() + result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "custom", "repo") + + @patch("devx.tools.create_pr.subprocess.run") + @patch("devx.tools.create_pr.REPO_OWNER", "owner") + @patch("devx.tools.create_pr.get_repo_name", return_value="repo") + def test_git_detect_failure(self, mock_repo: MagicMock, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(stdout="", stderr="fatal: not a git repository", returncode=128) + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code != 0 + assert "Could not detect" in result.output diff --git a/tests/unit/test_create_task.py b/tests/unit/test_create_task.py new file mode 100644 index 0000000..4181cc6 --- /dev/null +++ b/tests/unit/test_create_task.py @@ -0,0 +1,83 @@ +"""Unit tests for devx.tools.create_task.""" + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from devx.tools.create_task import cli + + +class TestCreateTaskCli: + @patch("devx.tools.create_task.VikunjaClient") + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_success(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.create_task.return_value = {"identifier": "DEVX-60", "id": 60} + mock_client_cls.return_value = mock_client + runner = CliRunner() + result = runner.invoke(cli, ["--title", "Add feature X"]) + assert result.exit_code == 0 + assert "DEVX-60" in result.output + mock_client.create_task.assert_called_once() + + @patch.dict("os.environ", {}, clear=True) + def test_missing_token(self) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["--title", "Add feature X"]) + assert result.exit_code != 0 + assert "VIKUNJA_TOKEN" in result.output + + @patch("devx.tools.create_task.VikunjaClient") + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_with_description(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.create_task.return_value = {"identifier": "DEVX-61", "id": 61} + mock_client_cls.return_value = mock_client + runner = CliRunner() + result = runner.invoke( + cli, + ["--title", "Add feature Y", "--description", "

desc

"], + ) + assert result.exit_code == 0 + call_args = mock_client.create_task.call_args + assert call_args.args[1] == "Add feature Y" + assert call_args.args[2] == "

desc

" + + @patch("devx.tools.create_task.VikunjaClient") + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_description_from_stdin(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.create_task.return_value = {"identifier": "DEVX-62", "id": 62} + mock_client_cls.return_value = mock_client + runner = CliRunner() + result = runner.invoke( + cli, + ["--title", "Add feature Z", "--description", "-"], + input="

stdin desc

", + ) + assert result.exit_code == 0 + mock_client.create_task.assert_called_once() + call_args = mock_client.create_task.call_args + assert call_args.args[2] == "

stdin desc

" + + @patch("devx.tools.create_task.VikunjaClient") + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_custom_project_id(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.create_task.return_value = {"identifier": "GRM-10", "id": 10} + mock_client_cls.return_value = mock_client + runner = CliRunner() + result = runner.invoke(cli, ["--title", "Task", "--project-id", "3"]) + assert result.exit_code == 0 + mock_client.create_task.assert_called_once_with(3, "Task", "") + + @patch("devx.tools.create_task.VikunjaClient") + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_no_identifier_in_response(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.create_task.return_value = {"id": 99} + mock_client_cls.return_value = mock_client + runner = CliRunner() + result = runner.invoke(cli, ["--title", "Task"]) + assert result.exit_code == 0 + assert "id=99" in result.output diff --git a/tests/unit/test_discover_runners.py b/tests/unit/test_discover_runners.py index 5f22324..81113a6 100644 --- a/tests/unit/test_discover_runners.py +++ b/tests/unit/test_discover_runners.py @@ -237,3 +237,15 @@ class TestMain: runner = CliRunner() result = runner.invoke(main, ["--github-output"]) assert result.exit_code != 0 + + @patch("devx.ci.discover_runners.get_runner_count", return_value=2) + def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None: + """When --owner and --repo are provided, env vars are not used.""" + runner = CliRunner() + result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"]) + assert result.exit_code == 0 + mock_count.assert_called_once() + # Verify owner/repo passed through + args, kwargs = mock_count.call_args + assert "myorg" in args + assert "myrepo" in args diff --git a/tests/unit/test_doc_coverage.py b/tests/unit/test_doc_coverage.py index f510a3f..fac55b7 100644 --- a/tests/unit/test_doc_coverage.py +++ b/tests/unit/test_doc_coverage.py @@ -46,6 +46,21 @@ class TestExtractCliCommands: commands = extract_cli_commands() assert "my_command" in commands + def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When a command decorator has no name and no following def, it is skipped.""" + from devx.ci import doc_coverage + + fake_cli = tmp_path / "cli.py" + # The last @cli.command() has no explicit name and no def statement after it + fake_cli.write_text( + "@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n" + ) + monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli) + commands = extract_cli_commands() + # real_cmd should be found via def fallback; the bare @cli.command() is skipped + assert "real_cmd" in commands + assert "pass" not in commands + class TestCheckCommandDocumented: def test_finds_command_in_heading(self) -> None: diff --git a/tests/unit/test_generate_badges.py b/tests/unit/test_generate_badges.py index add5a45..e7e533c 100644 --- a/tests/unit/test_generate_badges.py +++ b/tests/unit/test_generate_badges.py @@ -97,6 +97,15 @@ class TestDetectCoverageTarget: def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] assert detect_coverage_target(tmp_path) is None + def test_pyproject_without_cov_falls_back_to_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + """When pyproject exists but has no --cov=, falls back to package name.""" + src = tmp_path / "src" + pkg = src / "mypkg" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text('__version__ = "1.0"\n') + (tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\naddopts = "-ra"\n') + assert detect_coverage_target(tmp_path) == "src/mypkg" + class TestDetectTestpaths: def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] @@ -112,6 +121,14 @@ class TestDetectTestpaths: (tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n') assert detect_testpaths(tmp_path) == ["tests"] + def test_all_paths_nonexistent_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + """When all testpaths are non-existent, falls back to tests/ directory.""" + (tmp_path / "tests").mkdir() + (tmp_path / "pyproject.toml").write_text( + '[tool.pytest.ini_options]\ntestpaths = ["nonexistent1", "nonexistent2"]\n' + ) + assert detect_testpaths(tmp_path) == ["tests"] + def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] (tmp_path / "tests").mkdir() assert detect_testpaths(tmp_path) == ["tests"] diff --git a/tests/unit/test_molecule_ci_guard.py b/tests/unit/test_molecule_ci_guard.py index 2735651..7a54b26 100644 --- a/tests/unit/test_molecule_ci_guard.py +++ b/tests/unit/test_molecule_ci_guard.py @@ -97,6 +97,11 @@ class TestBuildEnvForPair: env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"}) assert "MOLECULE_PLATFORM_COMMAND" not in env + def test_preserves_existing_molecule_home(self) -> None: + """When MOLECULE_HOME is already set, it is not overridden.""" + env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"}) + assert env["MOLECULE_HOME"] == "/custom/home" + class TestPollForOtherFailures: def test_sets_failed_event_when_other_runner_fails(self) -> None: diff --git a/tests/unit/test_molecule_discover_runners.py b/tests/unit/test_molecule_discover_runners.py index 94f415b..b1c5b4d 100644 --- a/tests/unit/test_molecule_discover_runners.py +++ b/tests/unit/test_molecule_discover_runners.py @@ -208,3 +208,14 @@ class TestMain: runner = CliRunner() result = runner.invoke(main, ["--github-output"]) assert result.exit_code != 0 + + @patch("devx.molecule.discover_runners.get_runner_count", return_value=2) + def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None: + """When --owner and --repo are provided, env vars are not used.""" + runner = CliRunner() + result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"]) + assert result.exit_code == 0 + mock_count.assert_called_once() + args, kwargs = mock_count.call_args + assert "myorg" in args + assert "myrepo" in args diff --git a/tests/unit/test_pr_review.py b/tests/unit/test_pr_review.py index 8d72816..257b16e 100644 --- a/tests/unit/test_pr_review.py +++ b/tests/unit/test_pr_review.py @@ -129,6 +129,18 @@ class TestCheckArchitectureCompliance: assert result.has_issues assert "os.system" in result.issues[0]["body"] + def test_malformed_hunk_header_no_line_number(self) -> None: + """A @@ header without a +N line number is handled gracefully.""" + result = ReviewResult() + files = [ + { + "filename": "src/devx/cli.py", + "patch": "@@ -1,2 @@\n+ subprocess.run(['ls'])\n", + } + ] + check_architecture_compliance(files, result) + assert result.has_issues + class TestCheckBestPractices: def test_print_triggers_warning(self) -> None: @@ -190,6 +202,19 @@ class TestCheckBestPractices: check_best_practices(files, result) assert not result.has_issues + def test_malformed_hunk_header_no_line_number(self) -> None: + """A @@ header without a +N line number is handled gracefully.""" + result = ReviewResult() + files = [ + { + "filename": "src/devx/cli.py", + "patch": "@@ -1,2 @@\n+ print('hello')\n", + } + ] + check_best_practices(files, result) + assert result.has_issues + assert "print()" in result.issues[0]["body"] + class TestCheckSecurity: def test_hardcoded_secret_triggers_error(self) -> None: @@ -239,6 +264,19 @@ class TestCheckSecurity: check_security(files, result) assert not result.has_issues + def test_malformed_hunk_header_no_line_number(self) -> None: + """A @@ header without a +N line number is handled gracefully.""" + result = ReviewResult() + files = [ + { + "filename": "src/devx/config.py", + "patch": "@@ -1,2 @@\n+ token = 'abc123secrettoken456'\n", + } + ] + check_security(files, result) + assert result.has_issues + assert "secret" in result.issues[0]["body"].lower() + class TestCheckI18n: def test_raw_string_in_echo_triggers_warning(self) -> None: @@ -295,6 +333,14 @@ class TestCheckI18n: check_i18n(files, result) assert any("i18n: OK" in s for s in result.summary) + def test_malformed_hunk_header_no_line_number(self) -> None: + """A @@ header without a +N line number is handled gracefully.""" + result = ReviewResult() + files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+click.echo("Hello world")\n'}] + check_i18n(files, result) + assert result.has_issues + assert any("i18n" in i["body"] for i in result.issues) + class TestCheckResourceManagement: def test_open_without_with_triggers_warning(self) -> None: @@ -366,6 +412,14 @@ class TestCheckResourceManagement: check_resource_management(files, result) assert any("Resource management: OK" in s for s in result.summary) + def test_malformed_hunk_header_no_line_number(self) -> None: + """A @@ header without a +N line number is handled gracefully.""" + result = ReviewResult() + files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+f = open("file.txt")\n'}] + check_resource_management(files, result) + assert result.has_issues + assert any("resource" in i["body"].lower() for i in result.issues) + class TestCheckFunctionLength: def test_long_function_triggers_warning(self) -> None: @@ -429,6 +483,13 @@ class TestCheckFunctionLength: assert result.has_issues assert "foo" in result.issues[0]["body"] + def test_malformed_hunk_header_no_line_number(self) -> None: + """A @@ header without a +N line number is handled gracefully.""" + result = ReviewResult() + files = [{"filename": "src/devx/cli.py", "patch": "@@ -1,2 @@\n+def foo():\n+ pass\n"}] + check_function_length(files, result) + assert not result.has_issues + class TestCheckDocumentation: def test_src_changes_without_docs_warns(self) -> None: diff --git a/tests/unit/test_pre_push_check.py b/tests/unit/test_pre_push_check.py new file mode 100644 index 0000000..4ad3bb2 --- /dev/null +++ b/tests/unit/test_pre_push_check.py @@ -0,0 +1,137 @@ +"""Unit tests for devx.tools.pre_push_check.""" + +from unittest.mock import MagicMock, patch + +import click +import pytest +from click.testing import CliRunner + +from devx.tools.pre_push_check import ( + cli, + extract_task_id, + get_current_branch, + task_exists, + validate, +) + + +class TestExtractTaskId: + def test_valid_branch(self) -> None: + assert extract_task_id("DEVX-42-fix-bug") == "DEVX-42" + + def test_no_task_id(self) -> None: + assert extract_task_id("feature-branch") == "" + + def test_empty_branch(self) -> None: + assert extract_task_id("") == "" + + +class TestGetCurrentBranch: + @patch("devx.tools.pre_push_check.subprocess.run") + def test_success(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0) + assert get_current_branch() == "DEVX-42-fix" + + @patch("devx.tools.pre_push_check.subprocess.run") + def test_failure(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(stdout="", returncode=1) + assert get_current_branch() == "" + + +class TestTaskExists: + @patch("devx.tools.pre_push_check.VikunjaClient") + @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_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_not_found(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}] + mock_client_cls.return_value = mock_client + assert task_exists("DEVX-42") is False + + @patch.dict("os.environ", {}, clear=True) + 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: + validate("master") + + def test_main_branch_skips(self) -> None: + validate("main") + + def test_empty_branch_skips(self) -> None: + validate("") + + def test_no_task_id_raises(self) -> None: + with pytest.raises(click.ClickException, match="does not contain a task ID"): + validate("feature-branch") + + @patch.dict("os.environ", {}, clear=True) + def test_no_token_warns(self) -> None: + validate("DEVX-42-fix-bug") + + @patch("devx.tools.pre_push_check.task_exists", return_value=True) + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_task_exists_passes(self, mock_exists: MagicMock) -> None: + validate("DEVX-42-fix-bug") + + @patch("devx.tools.pre_push_check.task_exists", return_value=False) + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_task_not_found_raises(self, mock_exists: MagicMock) -> None: + with pytest.raises(click.ClickException, match="not found"): + validate("DEVX-42-fix-bug") + + +class TestCli: + @patch("devx.tools.pre_push_check.get_current_branch", return_value="master") + def test_auto_detect_master(self, mock_branch: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 + + @patch("devx.tools.pre_push_check.task_exists", return_value=True) + @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) + def test_explicit_branch(self, mock_exists: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["--branch", "DEVX-42-fix"]) + assert result.exit_code == 0 + assert "passed" in result.output diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index 2169d25..65633cf 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -156,6 +156,13 @@ class TestDefaultGiteaRegistryUrl: url = _default_gitea_registry_url() assert "oblachno-oss" in url + @patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True) + @patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/") + def test_no_api_suffix(self) -> None: + """URL without /api/v1 or /api suffix is used as-is.""" + url = _default_gitea_registry_url() + assert url == "https://git.example.com/api/packages/myorg/pypi" + class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py index fc1efd6..4948541 100644 --- a/tests/unit/test_release.py +++ b/tests/unit/test_release.py @@ -508,6 +508,30 @@ class TestVerifyAlignment: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") assert verify_alignment() == 1 + @patch("devx.ci.release.run_cmd") + @patch("devx.ci.release.get_changelog_versions") + @patch("devx.ci.release.get_init_version") + @patch("devx.ci.release.verify_tag_consistency") + @patch("devx.ci.release.get_all_tags") + @patch("devx.ci.release.get_latest_tag") + def test_no_latest_tag_skips_changelog_tag_check( + self, + mock_lt: MagicMock, + mock_tags: MagicMock, + mock_vtc: MagicMock, + mock_iv: MagicMock, + mock_cv: MagicMock, + mock_run_cmd: MagicMock, + ) -> None: + """When there is no latest tag, the CHANGELOG/tag match check is skipped.""" + mock_lt.return_value = None # no tags + mock_tags.return_value = [] + mock_vtc.return_value = [] + mock_iv.return_value = "0.4.4" + mock_cv.return_value = ["0.4.4"] # changelog has versions but no tag to compare + mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") + assert verify_alignment() == 0 + @patch("devx.ci.release.run_cmd") @patch("devx.ci.release.get_changelog_versions") @patch("devx.ci.release.get_init_version") @@ -756,6 +780,16 @@ class TestUpdateChangelog: assert "# Changelog" not in content assert "## [0.2.0]" in content + def test_no_version_section_in_changelog(self, tmp_path, monkeypatch) -> None: + """Changelog input without any ## [ version section is inserted as-is.""" + changelog_file = tmp_path / "CHANGELOG.md" + changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n") + monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file)) + # No ## [ section in the cliff output — should not be stripped + update_changelog("Some raw text without version header") + content = changelog_file.read_text() + assert "Some raw text without version header" in content + class TestCommitReleaseChanges: @patch("devx.ci.release.run_cmd") diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py index ea8ae46..87df4cb 100644 --- a/tests/unit/test_setup.py +++ b/tests/unit/test_setup.py @@ -186,6 +186,12 @@ class TestVerify: mock_run.side_effect = subprocess.TimeoutExpired(cmd="devx", timeout=10) _verify(".venv/bin") # Should not raise + @patch("devx.tools.setup.subprocess.run") + def test_verify_handles_nonzero_returncode(self, mock_run: MagicMock) -> None: + """When a tool returns non-zero, it is skipped without raising.""" + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error") + _verify(".venv/bin") # Should not raise + class TestMain: @patch("devx.tools.setup._configure_tea_login") @@ -304,6 +310,28 @@ class TestMain: assert result.exit_code != 0 assert "Bin directory not found" in result.output + @patch("devx.tools.setup._verify") + @patch("devx.tools.setup._configure_tea_login") + @patch("devx.tools.setup._install_pre_commit_hooks") + @patch("devx.tools.setup._install_ansible_collections") + @patch("devx.tools.setup._install_python_deps") + def test_main_skip_install( + self, + mock_install_deps: MagicMock, + mock_install_ansible: MagicMock, + mock_install_hooks: MagicMock, + mock_verify: MagicMock, + mock_tea: MagicMock, + tmp_path: Path, + ) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + runner = CliRunner() + result = runner.invoke(main, ["--bin", str(bin_dir), "--skip-install"]) + assert result.exit_code == 0 + mock_install_deps.assert_not_called() + assert "Skipping pip install" in result.output + def test_main_module_block(tmp_path: Path) -> None: """Test the __main__ block execution.""" diff --git a/tests/unit/test_start_docker.py b/tests/unit/test_start_docker.py index 89aee7b..29493d5 100644 --- a/tests/unit/test_start_docker.py +++ b/tests/unit/test_start_docker.py @@ -69,6 +69,22 @@ class TestDiagnoseSocket: _diagnose_socket() mock_exists.assert_called_with(DOCKER_SOCK) + @patch("devx.molecule.start_docker.os.path.exists", return_value=True) + @patch("devx.molecule.start_docker.os.stat") + @patch("devx.molecule.start_docker.subprocess.run") + def test_docker_info_no_matching_lines( + self, mock_run: MagicMock, mock_stat: MagicMock, mock_exists: MagicMock + ) -> None: + """docker info succeeds but stdout has no Server Version/Storage Driver/Root Dir lines.""" + mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0) + mock_run.side_effect = [ + MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""), + MagicMock(stdout="default\n", returncode=0, text=""), + MagicMock(stdout="Containers: 0\nImages: 0\nKernel: 6.1\n", returncode=0, text=""), + ] + _diagnose_socket() + mock_exists.assert_called_with(DOCKER_SOCK) + class TestStartDockerDaemon: @patch("devx.molecule.start_docker._diagnose_socket")