Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f40223d2 | ||
|
|
06e80516d4 | ||
|
|
f1adf22c3e | ||
|
|
91216da1a4 | ||
|
|
f9836208df | ||
|
|
0f0f0b683a | ||
|
|
54f687f1bf | ||
|
|
44c906a5e6 | ||
|
|
a3d528f802 | ||
|
|
e3a7afc0b0 | ||
|
|
700d3b55c6 | ||
|
|
701363d935 | ||
|
|
ddb2d43b4e | ||
|
|
fe6373b682 | ||
|
|
33434d5750 | ||
|
|
dfcd33c35b | ||
|
|
0aefe1f028 | ||
|
|
891b0b5dba | ||
|
|
8f15e5402b | ||
|
|
9060cd7b1e | ||
|
|
f687ab5aa3 | ||
|
|
4738b594b2 | ||
|
|
f6e9f2013b | ||
|
|
8450f33e88 | ||
|
|
904812dfae | ||
|
|
95384c26e1 | ||
|
|
c10b759f6b | ||
|
|
4c818b32ce | ||
|
|
bbf09c07df | ||
|
|
faff67aa6a | ||
|
|
3e4dfcadb7 | ||
|
|
2385747bed | ||
|
|
3625bf2872 | ||
|
|
8affccfa35 | ||
|
|
7ae85b6955 | ||
|
|
f702286779 | ||
|
|
d4b58fa86f | ||
|
|
44c6c42ede | ||
|
|
14c585971d | ||
|
|
3e2342c347 | ||
|
|
f3d5b0ff45 |
@@ -82,6 +82,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
- name: Configure git
|
||||
run: |
|
||||
@@ -106,13 +108,8 @@ jobs:
|
||||
echo "No tag found — skipping publish"
|
||||
exit 0
|
||||
fi
|
||||
HEAD_MSG=$(git log -1 --format=%s)
|
||||
if echo "$HEAD_MSG" | grep -q "^release: ${TAG}"; then
|
||||
echo "Publishing release $TAG..."
|
||||
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
||||
else
|
||||
echo "HEAD is not a release commit for $TAG — skipping publish"
|
||||
fi
|
||||
echo "Publishing release $TAG (idempotent — skips if already published)..."
|
||||
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
|
||||
@@ -162,7 +162,7 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
|
||||
2. **Check** that at least one substantive APPROVE review exists
|
||||
3. Wait for all CI checks to pass (including the `pr-review` job)
|
||||
4. Squash-merge with title: `DEVX-N <conventional commit message>` (space-separated, no colon after DEVX-N)
|
||||
4. Squash-merge with title: `DEVX-N: <conventional commit message>`
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes
|
||||
|
||||
@@ -259,7 +259,7 @@ by `python -m devx.tools.install_tools` and configured by
|
||||
|
||||
### git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `DEVX-N <conventional commit>`. The
|
||||
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
||||
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
|
||||
prefix before parsing. This ensures all merged work appears in the changelog.
|
||||
|
||||
@@ -282,7 +282,7 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
||||
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add release script` |
|
||||
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
|
||||
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
|
||||
| Merge commit | `DEVX-N: <conventional commit>` | `DEVX-12: feat: add release script` |
|
||||
|
||||
### Task ID Resolution
|
||||
|
||||
|
||||
@@ -2,6 +2,62 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.16.0] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- Single-source-of-truth config via [tool.devx] in pyproject.toml
|
||||
|
||||
## [0.15.0] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- Add create-task, create-pr, pre-push-check tools and devx.mak fragment
|
||||
|
||||
## [0.14.2] - 2026-06-26
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY
|
||||
|
||||
## [0.14.1] - 2026-06-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Handle 'already a release' error idempotently in publish
|
||||
|
||||
## [0.14.0] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- Add FORCE_DEPLOY env var, --git flag, --from-tag flag
|
||||
|
||||
## [0.13.0] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- Add --force flag to classify_changes, fix api_clients coverage
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Squash-merge format uses space not colon after task ID
|
||||
- Revert squash-merge format to use colon after task ID
|
||||
|
||||
## [0.1.0] - 2026-06-25
|
||||
|
||||
## [0.12.5] - 2026-06-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make PyPI publish failures non-fatal
|
||||
|
||||
## [0.12.4] - 2026-06-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Pass REPO_TOKEN to setup-release so tea login is configured
|
||||
- Guarantee Gitea release for every tag
|
||||
|
||||
## [0.12.3] - 2026-06-25
|
||||
|
||||
### Refactor
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
|
||||
+6
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
+8
-1
@@ -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"]
|
||||
@@ -96,6 +96,13 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s
|
||||
# Rule priority (first match wins):
|
||||
# 1. user_facing_overrides (safety — highest priority)
|
||||
# 2. infrastructure_overrides (explicit per-file)
|
||||
# Project-specific devx configuration (read by devx.config)
|
||||
[tool.devx]
|
||||
task_prefix = "DEVX"
|
||||
vikunja_project_id = 8
|
||||
repo_owner = "oblachno-oss"
|
||||
repo_name = "devx"
|
||||
|
||||
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||
# 4. Default: user-facing (safe)
|
||||
[tool.devx.classify]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.12.3"
|
||||
__version__ = "0.16.0"
|
||||
|
||||
@@ -192,6 +192,32 @@ 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.
|
||||
|
||||
Args:
|
||||
state: ``open``, ``closed``, ``all`` (default).
|
||||
**params: Additional query params (e.g. ``q="keyword"`` for title search).
|
||||
"""
|
||||
params.setdefault("state", state)
|
||||
r = self._request("GET", "/pulls", params=params)
|
||||
return r.json()
|
||||
|
||||
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the list of files changed in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||
@@ -342,8 +368,46 @@ 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})
|
||||
|
||||
def list_comments(self, task_id: int) -> list[dict[str, Any]]:
|
||||
"""List all comments on a task."""
|
||||
r = self._request("GET", f"/tasks/{task_id}/comments")
|
||||
return r.json()
|
||||
|
||||
def update_task(self, task_id: int, **fields: Any) -> None:
|
||||
"""Update task fields via POST (full replacement semantics).
|
||||
|
||||
Warning: Vikunja's POST /tasks/{id} replaces the entire task body.
|
||||
Unspecified fields are reset to their type defaults. Use
|
||||
``update_task_safe`` to preserve existing fields.
|
||||
"""
|
||||
self._request("POST", f"/tasks/{task_id}", json=fields)
|
||||
|
||||
def update_task_safe(self, task_id: int, **fields: Any) -> dict[str, Any]:
|
||||
"""Safely update task fields using read-merge-write pattern.
|
||||
|
||||
Fetches the full task body, merges the provided fields on top,
|
||||
and POSTs the complete body back. This prevents accidental
|
||||
resets of done status, title, etc.
|
||||
"""
|
||||
task = self.get_task(task_id)
|
||||
task.update(fields)
|
||||
r = self._request("POST", f"/tasks/{task_id}", json=task)
|
||||
return r.json()
|
||||
|
||||
@@ -615,11 +615,29 @@ def _write_github_output(key: str, value: str) -> None:
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
|
||||
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
|
||||
)
|
||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Force user-facing-changed=true regardless of actual changes. "
|
||||
"Used by workflow_dispatch with force-deploy input.",
|
||||
)
|
||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool, force: bool) -> None:
|
||||
"""Classify git changes and output results."""
|
||||
classifier = _get_classifier()
|
||||
available_tags = list(classifier.config.tags.keys())
|
||||
|
||||
# --force can also be activated via FORCE_DEPLOY env var (for workflow_dispatch)
|
||||
if os.environ.get("FORCE_DEPLOY", "").lower() == "true":
|
||||
force = True
|
||||
|
||||
if force and github_output:
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true")
|
||||
click.echo("Forced user-facing-changed=true via --force flag.")
|
||||
return
|
||||
|
||||
if base is None:
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect whether the latest git commit is a release commit.
|
||||
|
||||
Release commits have the format ``release: vX.Y.Z [skip ci]``.
|
||||
Release commits have the format ``release: vX.Y.Z``.
|
||||
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||
|
||||
|
||||
+97
-19
@@ -135,13 +135,21 @@ def publish_to_gitea_registry(registry_url: str, token: str) -> None:
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
# Twine writes errors to stdout (not stderr), so check both.
|
||||
combined = f"{result.stdout}\n{result.stderr}".strip()
|
||||
# 409 Conflict means the package version is already published —
|
||||
# this is not an error, just a sign we're re-running publish.
|
||||
if "409" in combined or "Conflict" in combined:
|
||||
click.echo(_("Gitea PyPI registry: {tag} already published — continuing.", tag=""))
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
||||
stderr=combined,
|
||||
)
|
||||
)
|
||||
)
|
||||
click.echo(_("Published to Gitea PyPI registry."))
|
||||
else:
|
||||
click.echo(_("Published to Gitea PyPI registry."))
|
||||
|
||||
|
||||
def _default_gitea_registry_url() -> str:
|
||||
@@ -161,9 +169,37 @@ def _default_gitea_registry_url() -> str:
|
||||
return f"{base}/api/packages/{owner}/pypi"
|
||||
|
||||
|
||||
def get_latest_tag() -> str | None:
|
||||
"""Get the latest git tag, or None if no tags exist."""
|
||||
try:
|
||||
result = subprocess.run( # nosec
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def is_release_commit(tag: str) -> bool:
|
||||
"""Check if HEAD commit message starts with 'release: <tag>'."""
|
||||
try:
|
||||
result = subprocess.run( # nosec
|
||||
["git", "log", "-1", "--format=%s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip().startswith(f"release: {tag}")
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("tag")
|
||||
@click.argument("repo")
|
||||
@click.argument("tag", required=False)
|
||||
@click.argument("repo", required=False)
|
||||
@click.option(
|
||||
"--registry-url",
|
||||
default=None,
|
||||
@@ -178,7 +214,37 @@ def _default_gitea_registry_url() -> str:
|
||||
help="Skip package build and PyPI publish (for non-Python repos that only "
|
||||
"need a Gitea release with git-cliff notes).",
|
||||
)
|
||||
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
|
||||
@click.option(
|
||||
"--from-tag",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Auto-detect latest tag and check if HEAD is a release commit. "
|
||||
"Skips publish if no tag or HEAD is not a release commit for that tag.",
|
||||
)
|
||||
def main(
|
||||
tag: str | None,
|
||||
repo: str | None,
|
||||
registry_url: str | None,
|
||||
skip_build: bool,
|
||||
from_tag: bool,
|
||||
) -> None:
|
||||
if repo is None:
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if not repo:
|
||||
raise click.ClickException(_("REPO argument is required (or set GITHUB_REPOSITORY env var)."))
|
||||
if from_tag:
|
||||
detected_tag = get_latest_tag()
|
||||
if not detected_tag:
|
||||
click.echo(_("No tag found — skipping publish."))
|
||||
return
|
||||
if not is_release_commit(detected_tag):
|
||||
click.echo(_("HEAD is not a release commit for {tag} — skipping publish.", tag=detected_tag))
|
||||
return
|
||||
tag = detected_tag
|
||||
click.echo(_("Publishing release {tag}...", tag=tag))
|
||||
|
||||
if not tag:
|
||||
raise click.ClickException(_("Tag is required (or use --from-tag)."))
|
||||
gitea_token = os.environ.get("REPO_TOKEN", "")
|
||||
if not gitea_token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
@@ -194,18 +260,27 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
|
||||
if not skip_build:
|
||||
build_package()
|
||||
|
||||
if pypi_token:
|
||||
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
|
||||
publish_to_pypi(pypi_token)
|
||||
elif registry_url:
|
||||
# Gitea PyPI registry flow
|
||||
publish_to_gitea_registry(registry_url, gitea_token)
|
||||
else:
|
||||
try:
|
||||
if pypi_token:
|
||||
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
|
||||
publish_to_pypi(pypi_token)
|
||||
elif registry_url:
|
||||
# Gitea PyPI registry flow
|
||||
publish_to_gitea_registry(registry_url, gitea_token)
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"PYPI_TOKEN not set and no registry URL configured — "
|
||||
"skipping PyPI publish. No worries, we'll just create the Gitea release."
|
||||
)
|
||||
)
|
||||
except click.ClickException as e:
|
||||
click.echo(
|
||||
_(
|
||||
"PYPI_TOKEN not set and no registry URL configured — "
|
||||
"skipping PyPI publish. No worries, we'll just create the Gitea release."
|
||||
)
|
||||
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
|
||||
error=str(e),
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
else:
|
||||
click.echo(_("--skip-build: skipping package build and PyPI publish."))
|
||||
@@ -227,6 +302,9 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
|
||||
try:
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
if "already" in str(e).lower() and "release" in str(e).lower():
|
||||
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
||||
return
|
||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||
|
||||
click.echo(
|
||||
|
||||
@@ -279,7 +279,7 @@ def commit_release_changes(new_version: str) -> bool:
|
||||
if status.returncode == 0:
|
||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||
return False
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ task ID format for each project.
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
@@ -23,6 +24,17 @@ from devx.i18n import _
|
||||
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
|
||||
|
||||
|
||||
def get_latest_commit_msg() -> str:
|
||||
"""Get the latest commit message from git."""
|
||||
result = subprocess.run( # nosec
|
||||
["git", "log", "-1", "--format=%B"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def first_line(text: str) -> str:
|
||||
return text.split("\n")[0]
|
||||
|
||||
@@ -41,11 +53,26 @@ def get_branch() -> str:
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("commit_msg_file")
|
||||
@click.argument("commit_msg_file", required=False)
|
||||
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
|
||||
def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
with open(commit_msg_file) as f:
|
||||
msg = f.read().strip()
|
||||
@click.option(
|
||||
"--git",
|
||||
"from_git",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Read commit message from git log instead of a file.",
|
||||
)
|
||||
def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None:
|
||||
if from_git:
|
||||
msg = get_latest_commit_msg()
|
||||
elif commit_msg_file:
|
||||
if commit_msg_file == "-":
|
||||
msg = sys.stdin.read().strip()
|
||||
else:
|
||||
with open(commit_msg_file) as f:
|
||||
msg = f.read().strip()
|
||||
else:
|
||||
raise click.ClickException(_("Provide a commit message file or use --git."))
|
||||
|
||||
if branch is None:
|
||||
branch = get_branch()
|
||||
|
||||
+65
-7
@@ -1,28 +1,86 @@
|
||||
"""Shared configuration constants for devx scripts and API clients.
|
||||
|
||||
All defaults can be overridden via environment variables with the ``DEVX_``
|
||||
prefix. Projects consuming devx can set these in their ``.env`` files.
|
||||
Configuration is read from two sources, in priority order:
|
||||
|
||||
1. **Environment variables** (``DEVX_`` prefix) — highest priority, used for
|
||||
CI secrets and per-run overrides.
|
||||
2. **``[tool.devx]`` section in ``pyproject.toml``** — project defaults,
|
||||
read from the current working directory.
|
||||
|
||||
If neither source provides a value, built-in defaults are used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_pyproject_devx() -> dict[str, object]:
|
||||
"""Load the ``[tool.devx]`` section from pyproject.toml in the CWD.
|
||||
|
||||
Returns an empty dict if the file or section is missing.
|
||||
"""
|
||||
path = Path("pyproject.toml")
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path, "rb") as f: # noqa: PTH123
|
||||
data: dict[str, object] = tomllib.load(f)
|
||||
except (tomllib.TOMLDecodeError, OSError):
|
||||
return {}
|
||||
tool_raw: object = data.get("tool", {})
|
||||
if not isinstance(tool_raw, dict):
|
||||
return {}
|
||||
tool: dict[str, object] = tool_raw # type: ignore[assignment]
|
||||
devx_raw: object = tool.get("devx", {})
|
||||
if not isinstance(devx_raw, dict):
|
||||
return {}
|
||||
devx: dict[str, object] = devx_raw # type: ignore[assignment]
|
||||
return devx
|
||||
|
||||
|
||||
_PYPROJECT = _load_pyproject_devx()
|
||||
|
||||
|
||||
def _get(key: str, env_var: str, default: str) -> str:
|
||||
"""Get a config value: env var > pyproject.toml > default."""
|
||||
env_val = os.getenv(env_var)
|
||||
if env_val is not None:
|
||||
return env_val
|
||||
pyproject_val = _PYPROJECT.get(key)
|
||||
if isinstance(pyproject_val, str):
|
||||
return pyproject_val
|
||||
return default
|
||||
|
||||
|
||||
def _get_int(key: str, env_var: str, default: int) -> int:
|
||||
"""Get an int config value: env var > pyproject.toml > default."""
|
||||
env_val = os.getenv(env_var)
|
||||
if env_val is not None:
|
||||
return int(env_val)
|
||||
pyproject_val = _PYPROJECT.get(key)
|
||||
if isinstance(pyproject_val, int):
|
||||
return pyproject_val
|
||||
return default
|
||||
|
||||
|
||||
# API endpoints — override via env vars for different Gitea/Vikunja instances
|
||||
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
GITEA_API_URL = _get("gitea_api_url", "DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "")
|
||||
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
|
||||
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
||||
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
|
||||
|
||||
# Vikunja project ID — each project uses a different Vikunja project
|
||||
VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6"))
|
||||
VIKUNJA_PROJECT_ID = _get_int("vikunja_project_id", "DEVX_VIKUNJA_PROJECT_ID", 6)
|
||||
|
||||
# HTTP client defaults
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
@@ -82,12 +82,15 @@ class TeaCLI:
|
||||
cmd = [self._tea, *args]
|
||||
if json_output:
|
||||
cmd.extend(["--output", "json"])
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
|
||||
if result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
#
|
||||
# Project config (task prefix, Vikunja project ID, repo owner, repo name)
|
||||
# is read from [tool.devx] in pyproject.toml by devx.config — no
|
||||
# Makefile variables needed.
|
||||
#
|
||||
# Usage in your Makefile:
|
||||
#
|
||||
# # Set DEVX_PYTHON if you need a specific interpreter
|
||||
# DEVX_PYTHON := $(BIN)/python
|
||||
#
|
||||
# # 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)
|
||||
#
|
||||
# If devx is not installed, the -include silently skips and the targets
|
||||
# are simply unavailable (run 'make setup' first).
|
||||
#
|
||||
# Variables:
|
||||
# DEVX_PYTHON — Python executable (default: python3)
|
||||
# DEVX_PR_BASE — PR base branch (default: master)
|
||||
|
||||
DEVX_PYTHON ?= python3
|
||||
DEVX_PR_BASE ?= master
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
|
||||
# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml)
|
||||
devx-create-task:
|
||||
@$(DEVX_PYTHON) -m devx.tools.create_task
|
||||
|
||||
# Create a PR with title auto-derived from the Vikunja task
|
||||
# (owner/repo read from [tool.devx] in pyproject.toml)
|
||||
devx-create-pr:
|
||||
@$(DEVX_PYTHON) -m devx.tools.create_pr --base $(DEVX_PR_BASE)
|
||||
|
||||
# Push current branch to origin
|
||||
devx-push:
|
||||
@git push -u origin HEAD
|
||||
|
||||
# Validate devx configuration in pyproject.toml
|
||||
devx-check-config:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_config
|
||||
|
||||
# Push and create PR in one step
|
||||
devx-push-with-pr: devx-push devx-create-pr
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate devx configuration consistency in pyproject.toml.
|
||||
|
||||
Checks:
|
||||
1. [tool.devx] section exists with required keys (task_prefix, vikunja_project_id, repo_owner, repo_name)
|
||||
2. devx version is consistent across all extras that mention it
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_config
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
@click.command()
|
||||
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)
|
||||
|
||||
with open(path, "rb") as f: # noqa: PTH123
|
||||
data = tomllib.load(f)
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
# Check [tool.devx] section
|
||||
devx_cfg = data.get("tool", {}).get("devx", {})
|
||||
required_keys = {"task_prefix", "vikunja_project_id", "repo_owner", "repo_name"}
|
||||
missing = required_keys - set(devx_cfg.keys())
|
||||
if missing:
|
||||
errors.append(
|
||||
_("[tool.devx] missing required keys: {keys}", keys=", ".join(sorted(missing))),
|
||||
)
|
||||
|
||||
# Check devx version consistency across extras
|
||||
optional_deps = data.get("project", {}).get("optional-dependencies", {})
|
||||
devx_versions: dict[str, str] = {}
|
||||
for extra_name, deps in optional_deps.items():
|
||||
for dep in deps:
|
||||
# Match "devx>=X.Y.Z", "devx==X.Y.Z", "devx>X.Y.Z", etc.
|
||||
m = re.search(r"\bdevx\s*(>=|==|>|<=|<|~=)\s*([\d.]+)", dep)
|
||||
if m:
|
||||
devx_versions[extra_name] = m.group(2)
|
||||
|
||||
if devx_versions:
|
||||
unique_versions = set(devx_versions.values())
|
||||
if len(unique_versions) > 1:
|
||||
detail = ", ".join(f"{extra}={v}" for extra, v in sorted(devx_versions.items()))
|
||||
errors.append(
|
||||
_("devx version mismatch across extras: {detail}", detail=detail),
|
||||
)
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
click.echo(f"ERROR: {err}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -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: <vikunja task title>``.
|
||||
|
||||
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
|
||||
@@ -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 "<h2>Overview</h2><p>Implement automated...</p>"
|
||||
|
||||
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
|
||||
@@ -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
|
||||
+12
-2
@@ -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...")
|
||||
|
||||
@@ -439,6 +439,14 @@
|
||||
"ru": "Настройка параметров репозитория...",
|
||||
"zh": "正在配置仓库设置..."
|
||||
},
|
||||
"Configuration OK: [tool.devx] present, devx versions consistent.": {
|
||||
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
|
||||
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
|
||||
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
|
||||
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
|
||||
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
|
||||
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||
},
|
||||
"Could not extract conventional commit message from PR commits.": {
|
||||
"bg": "Could not extract conventional commit message from PR commits.",
|
||||
"de": "Could not extract conventional commit message from PR commits.",
|
||||
@@ -487,6 +495,14 @@
|
||||
"ru": "Created release commit.",
|
||||
"zh": "Created release commit."
|
||||
},
|
||||
"devx version mismatch across extras: {detail}": {
|
||||
"bg": "несъответствие на версията на devx между extras: {detail}",
|
||||
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
||||
"en": "devx version mismatch across extras: {detail}",
|
||||
"pl": "niezgodność wersji devx między extras: {detail}",
|
||||
"ru": "несоответствие версии devx между extras: {detail}",
|
||||
"zh": "devx 版本在 extras 之间不一致: {detail}"
|
||||
},
|
||||
"Docker daemon already running": {
|
||||
"bg": "Докер демонът вече работи",
|
||||
"de": "Docker-Daemon läuft bereits",
|
||||
@@ -599,6 +615,14 @@
|
||||
"ru": "Generated {file} with prefix '{prefix}'.",
|
||||
"zh": "Generated {file} with prefix '{prefix}'."
|
||||
},
|
||||
"Gitea PyPI registry: {tag} already published — continuing.": {
|
||||
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
|
||||
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
|
||||
"en": "Gitea PyPI registry: {tag} already published — continuing.",
|
||||
"pl": "Gitea PyPI registry: {tag} już opublikowano — kontynuacja.",
|
||||
"ru": "Gitea PyPI registry: {tag} уже опубликован — продолжаем.",
|
||||
"zh": "Gitea PyPI registry: {tag} 已发布 — 继续。"
|
||||
},
|
||||
"Gitea release {tag} already exists — skipping creation.": {
|
||||
"bg": "Gitea release {tag} вече съществува — прескачане на създаването.",
|
||||
"de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.",
|
||||
@@ -631,6 +655,14 @@
|
||||
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
|
||||
},
|
||||
"HEAD is not a release commit for {tag} — skipping publish.": {
|
||||
"bg": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||
"de": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||
"en": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||
"pl": "HEAD nie jest commitem wydania dla {tag} — pomijanie publikacji.",
|
||||
"ru": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||
"zh": "HEAD is not a release commit for {tag} — skipping publish."
|
||||
},
|
||||
"HTTP error: {status} — {message}": {
|
||||
"bg": "HTTP грешка: {status} — {message}",
|
||||
"de": "HTTP-Fehler: {status} — {message}",
|
||||
@@ -791,6 +823,14 @@
|
||||
"ru": "No staged changes — version and changelog already up to date.",
|
||||
"zh": "No staged changes — version and changelog already up to date."
|
||||
},
|
||||
"No tag found — skipping publish.": {
|
||||
"bg": "No tag found — skipping publish.",
|
||||
"de": "No tag found — skipping publish.",
|
||||
"en": "No tag found — skipping publish.",
|
||||
"pl": "Nie znaleziono tagu — pomijanie publikacji.",
|
||||
"ru": "No tag found — skipping publish.",
|
||||
"zh": "No tag found — skipping publish."
|
||||
},
|
||||
"No tags found — treating all changes as user-facing.": {
|
||||
"bg": "No tags found — treating all changes as user-facing.",
|
||||
"de": "No tags found — treating all changes as user-facing.",
|
||||
@@ -959,6 +999,14 @@
|
||||
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
|
||||
},
|
||||
"Provide a commit message file or use --git.": {
|
||||
"bg": "Provide a commit message file or use --git.",
|
||||
"de": "Provide a commit message file or use --git.",
|
||||
"en": "Provide a commit message file or use --git.",
|
||||
"pl": "Podaj plik komunikatu commitu lub użyj --git.",
|
||||
"ru": "Provide a commit message file or use --git.",
|
||||
"zh": "Provide a commit message file or use --git."
|
||||
},
|
||||
"Published to Gitea PyPI registry.": {
|
||||
"bg": "Публикувано в Gitea PyPI registry.",
|
||||
"de": "In der Gitea PyPI-Registry veröffentlicht.",
|
||||
@@ -975,6 +1023,14 @@
|
||||
"ru": "Опубликовано в PyPI.",
|
||||
"zh": "已发布到 PyPI。"
|
||||
},
|
||||
"Publishing release {tag}...": {
|
||||
"bg": "Publishing release {tag}...",
|
||||
"de": "Publishing release {tag}...",
|
||||
"en": "Publishing release {tag}...",
|
||||
"pl": "Publikowanie wydania {tag}...",
|
||||
"ru": "Publishing release {tag}...",
|
||||
"zh": "Publishing release {tag}..."
|
||||
},
|
||||
"Pushed release commit to master.": {
|
||||
"bg": "Pushed release commit to master.",
|
||||
"de": "Pushed release commit to master.",
|
||||
@@ -983,6 +1039,14 @@
|
||||
"ru": "Pushed release commit to master.",
|
||||
"zh": "Pushed release commit to master."
|
||||
},
|
||||
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": {
|
||||
"bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}",
|
||||
"de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}",
|
||||
"en": "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
|
||||
"pl": "Publikacja PyPI nie powiodła się (niekrytyczne — kontynuacja Gitea release):\n{error}",
|
||||
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
|
||||
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
|
||||
},
|
||||
"Release creation failed: {error}": {
|
||||
"bg": "Release creation failed: {error}",
|
||||
"de": "Release creation failed: {error}",
|
||||
@@ -1079,6 +1143,22 @@
|
||||
"ru": "Tag consistency check failed.",
|
||||
"zh": "Tag consistency check failed."
|
||||
},
|
||||
"Tag is required (or use --from-tag).": {
|
||||
"bg": "Tag is required (or use --from-tag).",
|
||||
"de": "Tag is required (or use --from-tag).",
|
||||
"en": "Tag is required (or use --from-tag).",
|
||||
"pl": "Tag jest wymagany (lub użyj --from-tag).",
|
||||
"ru": "Tag is required (or use --from-tag).",
|
||||
"zh": "Tag is required (or use --from-tag)."
|
||||
},
|
||||
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
|
||||
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
|
||||
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||
},
|
||||
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
||||
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
@@ -1239,6 +1319,14 @@
|
||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
},
|
||||
"[tool.devx] missing required keys: {keys}": {
|
||||
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
|
||||
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
|
||||
"en": "[tool.devx] missing required keys: {keys}",
|
||||
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
|
||||
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
|
||||
"zh": "[tool.devx] 缺少必需的键: {keys}"
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version}": {
|
||||
"bg": "[dry-run] Would commit: release: v{version}",
|
||||
"de": "[dry-run] Would commit: release: v{version}",
|
||||
@@ -1391,6 +1479,14 @@
|
||||
"ru": "ожидает",
|
||||
"zh": "待处理"
|
||||
},
|
||||
"pyproject.toml not found in current directory.": {
|
||||
"bg": "pyproject.toml не е намерен в текущата директория.",
|
||||
"de": "pyproject.toml im aktuellen Verzeichnis nicht gefunden.",
|
||||
"en": "pyproject.toml not found in current directory.",
|
||||
"pl": "nie znaleziono pyproject.toml w bieżącym katalogu.",
|
||||
"ru": "pyproject.toml не найден в текущей директории.",
|
||||
"zh": "在当前目录中未找到 pyproject.toml。"
|
||||
},
|
||||
"unknown": {
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
@@ -1406,5 +1502,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 中设置以启用完整验证。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -278,6 +334,35 @@ class TestGiteaClient:
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_list_prs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"number": 1, "title": "feat: add"}, {"number": 2, "title": "fix: bug"}])
|
||||
)
|
||||
|
||||
result = client.list_prs()
|
||||
assert len(result) == 2
|
||||
assert result[0]["number"] == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls",
|
||||
params={"state": "all"},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_list_prs_with_params(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"number": 3, "title": "docs: update"}]))
|
||||
|
||||
result = client.list_prs(state="closed", q="docs")
|
||||
assert len(result) == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/pulls",
|
||||
params={"state": "closed", "q": "docs"},
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_pr_reviews(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
|
||||
@@ -566,6 +651,46 @@ class TestVikunjaClient:
|
||||
json={"done": True},
|
||||
)
|
||||
|
||||
def test_list_comments(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"id": 1, "comment": "first"}, {"id": 2, "comment": "second"}])
|
||||
)
|
||||
|
||||
result = client.list_comments(42)
|
||||
assert len(result) == 2
|
||||
assert result[0]["comment"] == "first"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://work.example.com/tasks/42/comments",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_update_task_safe(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
side_effect=[
|
||||
_mock_response({"id": 42, "title": "My task", "done": False}),
|
||||
_mock_response({"id": 42, "title": "My task", "done": True}),
|
||||
]
|
||||
)
|
||||
|
||||
result = client.update_task_safe(42, done=True)
|
||||
assert result["done"] is True
|
||||
assert result["title"] == "My task"
|
||||
assert client._session.request.call_count == 2
|
||||
client._session.request.assert_any_call(
|
||||
"GET",
|
||||
"https://work.example.com/tasks/42",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
client._session.request.assert_any_call(
|
||||
"POST",
|
||||
"https://work.example.com/tasks/42",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"id": 42, "title": "My task", "done": True},
|
||||
)
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -632,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", "<p>desc</p>")
|
||||
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": "<p>desc</p>"},
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
@@ -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) --
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Unit tests for devx.tools.check_config."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_config import cli
|
||||
|
||||
|
||||
class TestCheckConfig:
|
||||
def test_valid_config(self, tmp_path: Path) -> None:
|
||||
"""A valid [tool.devx] section with consistent versions passes."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text(
|
||||
'[project]\nname = "test"\n'
|
||||
'[project.optional-dependencies]\nci = ["devx>=0.15.0"]\ndev = ["devx>=0.15.0"]\n'
|
||||
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||
)
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 0
|
||||
assert "Configuration OK" in result.output
|
||||
|
||||
def test_missing_tool_devx_section(self, tmp_path: Path) -> None:
|
||||
"""Missing [tool.devx] section fails with error."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n')
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 1
|
||||
assert "missing required keys" in result.output
|
||||
|
||||
def test_partial_tool_devx_section(self, tmp_path: Path) -> None:
|
||||
"""Partial [tool.devx] section fails with missing keys."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\ntask_prefix = "TEST"\n')
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 1
|
||||
assert "missing required keys" in result.output
|
||||
assert "vikunja_project_id" in result.output
|
||||
assert "repo_owner" in result.output
|
||||
assert "repo_name" in result.output
|
||||
|
||||
def test_version_mismatch(self, tmp_path: Path) -> None:
|
||||
"""Version mismatch across extras fails."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text(
|
||||
'[project]\nname = "test"\n'
|
||||
"[project.optional-dependencies]\n"
|
||||
'ci = ["devx>=0.15.0"]\n'
|
||||
'dev = ["devx>=0.14.2"]\n'
|
||||
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||
)
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 1
|
||||
assert "version mismatch" in result.output
|
||||
|
||||
def test_no_pyproject_file(self, tmp_path: Path) -> None:
|
||||
"""Missing pyproject.toml fails."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 1
|
||||
assert "not found" in result.output
|
||||
|
||||
def test_no_extras_passes(self, tmp_path: Path) -> None:
|
||||
"""No optional-dependencies with devx is fine (no versions to compare)."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text(
|
||||
'[project]\nname = "test"\n'
|
||||
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||
)
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 0
|
||||
assert "Configuration OK" in result.output
|
||||
|
||||
def test_single_extra_passes(self, tmp_path: Path) -> None:
|
||||
"""Single extra with devx version is fine (no mismatch possible)."""
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||
Path(fs, "pyproject.toml").write_text(
|
||||
'[project]\nname = "test"\n'
|
||||
'[project.optional-dependencies]\nci = ["devx>=0.15.0", "pytest"]\n'
|
||||
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||
)
|
||||
result = runner.invoke(cli)
|
||||
assert result.exit_code == 0
|
||||
@@ -317,9 +317,9 @@ class TestCollectKeys:
|
||||
assert "completed" in keys
|
||||
assert "pending" in keys
|
||||
|
||||
def test_default_dir_includes_dynamic_keys(self) -> None:
|
||||
"""The default source dir should include DYNAMIC_KEYS."""
|
||||
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR)
|
||||
def test_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
|
||||
"""collect_keys includes DYNAMIC_KEYS even with an empty source dir."""
|
||||
keys = check_translations.collect_keys(tmp_path)
|
||||
assert "completed" in keys
|
||||
assert "pending" in keys
|
||||
assert "in_progress" in keys
|
||||
|
||||
@@ -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()
|
||||
@@ -742,3 +772,95 @@ class TestGithubOutput:
|
||||
assert "user-facing-changed=true" in content
|
||||
# No tag outputs since no tags are configured
|
||||
assert "ansible-changed" not in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_outputs_true(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--force with --github-output writes user-facing-changed=true and all tags true."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output", "--force"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
assert "ansible-changed=true" in content
|
||||
assert "Forced user-facing-changed=true" in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_without_github_output_does_nothing(
|
||||
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""--force without --github-output falls through to normal classification."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output.txt"))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--force", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == "false"
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--force with --github-output and no tags writes only user-facing-changed=true."""
|
||||
mock_clf.return_value = ChangeClassifier(
|
||||
ClassifierConfig(
|
||||
infrastructure=[".gitea/**"],
|
||||
tags={},
|
||||
)
|
||||
)
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output", "--force"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
assert "ansible-changed" not in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""FORCE_DEPLOY=true env var activates force mode without --force flag."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
monkeypatch.setenv("FORCE_DEPLOY", "true")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
assert "ansible-changed=true" in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_deploy_env_var_false(
|
||||
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""FORCE_DEPLOY=false does not activate force mode."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
monkeypatch.setenv("FORCE_DEPLOY", "false")
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
@patch("devx.ci.classify_changes._get_classifier")
|
||||
def test_force_flag_overrides_env_var(
|
||||
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""--force flag works even when FORCE_DEPLOY=false."""
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
monkeypatch.setenv("FORCE_DEPLOY", "false")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output", "--force"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "user-facing-changed=true" in content
|
||||
|
||||
+106
-25
@@ -1,12 +1,14 @@
|
||||
"""Unit tests for config module constants."""
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
from devx.config import (
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
GITEA_API_URL,
|
||||
MAX_RETRIES,
|
||||
REPO_OWNER,
|
||||
RETRY_BACKOFF_BASE,
|
||||
RETRY_STATUS_CODES,
|
||||
TASK_ID_RE,
|
||||
@@ -20,25 +22,10 @@ class TestConfigConstants:
|
||||
assert "api/v1" in GITEA_API_URL
|
||||
assert "api/v1" in VIKUNJA_API_URL
|
||||
|
||||
def test_project_ids(self, monkeypatch: object) -> None:
|
||||
"""VIKUNJA_PROJECT_ID defaults to 6 when DEVX_VIKUNJA_PROJECT_ID is not set."""
|
||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||
import importlib
|
||||
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||
# Restore module state
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_timeouts(self) -> None:
|
||||
assert DEFAULT_TIMEOUT == 30
|
||||
assert DEFAULT_PER_PAGE == 50
|
||||
|
||||
def test_owner(self) -> None:
|
||||
assert REPO_OWNER == ""
|
||||
|
||||
def test_task_prefix(self) -> None:
|
||||
assert TASK_PREFIX == "DEVX"
|
||||
|
||||
@@ -64,28 +51,122 @@ class TestConfigConstants:
|
||||
assert 503 in RETRY_STATUS_CODES
|
||||
assert 504 in RETRY_STATUS_CODES
|
||||
|
||||
def test_env_var_override(self, monkeypatch: object) -> None:
|
||||
"""Test that env vars override defaults at import time."""
|
||||
# We can't easily re-import the module, but we can verify
|
||||
# the constants respect env vars by checking the module source.
|
||||
|
||||
class TestPyprojectReading:
|
||||
"""Test that config.py reads [tool.devx] from pyproject.toml."""
|
||||
|
||||
def test_pyproject_provides_values(self) -> None:
|
||||
"""When pyproject.toml has [tool.devx], values are read from it."""
|
||||
import devx.config as cfg
|
||||
|
||||
assert cfg.GITEA_API_URL # always non-empty
|
||||
assert cfg.VIKUNJA_API_URL # always non-empty
|
||||
# devx's own pyproject.toml has task_prefix=DEVX, vikunja_project_id=8
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 8
|
||||
assert cfg.REPO_OWNER == "oblachno-oss"
|
||||
|
||||
def test_env_overrides_pyproject(self, monkeypatch: object) -> None:
|
||||
"""Env vars take priority over pyproject.toml."""
|
||||
monkeypatch.setenv("DEVX_TASK_PREFIX", "CUSTOM")
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "CUSTOM"
|
||||
assert cfg.TASK_ID_RE.search("CUSTOM-42")
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_no_pyproject_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When no pyproject.toml exists, defaults are used."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||
monkeypatch.delenv("DEVX_REPO_OWNER", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||
assert cfg.REPO_OWNER == ""
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_invalid_toml_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When pyproject.toml is invalid TOML, defaults are used."""
|
||||
(tmp_path / "pyproject.toml").write_text("invalid toml {{{")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_no_devx_section_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When pyproject.toml has no [tool.devx], defaults are used."""
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_pyproject_int_value_used(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When pyproject.toml has an int value, it is used (covers _get_int return)."""
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 42
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_env_int_override(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""Env var override for int config takes priority over pyproject.toml."""
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("DEVX_VIKUNJA_PROJECT_ID", "99")
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.VIKUNJA_PROJECT_ID == 99
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When [tool] is not a dict, defaults are used."""
|
||||
(tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
importlib.reload(cfg)
|
||||
|
||||
def test_devx_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||
"""When [tool.devx] is not a dict, defaults are used."""
|
||||
(tmp_path / "pyproject.toml").write_text('[tool]\ndevx = "not a dict"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "DEVX"
|
||||
importlib.reload(cfg)
|
||||
|
||||
|
||||
class TestTaskPrefixOverride:
|
||||
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
|
||||
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
|
||||
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
|
||||
import importlib
|
||||
|
||||
import devx.config as cfg
|
||||
|
||||
importlib.reload(cfg)
|
||||
assert cfg.TASK_PREFIX == "INFRA"
|
||||
assert cfg.TASK_ID_RE.search("INFRA-42")
|
||||
assert not cfg.TASK_ID_RE.search("DEVX-42")
|
||||
# Restore
|
||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||
importlib.reload(cfg)
|
||||
|
||||
@@ -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
|
||||
@@ -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", "<p>desc</p>"],
|
||||
)
|
||||
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] == "<p>desc</p>"
|
||||
|
||||
@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="<p>stdin desc</p>",
|
||||
)
|
||||
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] == "<p>stdin desc</p>"
|
||||
|
||||
@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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -77,6 +77,12 @@ class TestTeaCLIRun:
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
|
||||
def test_run_tea_not_found_raises_tea_error(self) -> None:
|
||||
cli = TeaCLI(tea_bin="tea")
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
|
||||
with pytest.raises(TeaCLIError, match="tea binary not found"):
|
||||
cli._run(["labels", "list"])
|
||||
|
||||
def test_run_includes_json_flag(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
||||
|
||||
@@ -116,7 +116,7 @@ class TestCli:
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
@@ -163,7 +163,7 @@ class TestCli:
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
@@ -208,7 +208,7 @@ class TestCli:
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
|
||||
@@ -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:
|
||||
@@ -297,7 +302,7 @@ class TestCli:
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
@@ -333,7 +338,7 @@ class TestCli:
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
):
|
||||
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
||||
proc = MagicMock()
|
||||
@@ -380,7 +385,7 @@ class TestCli:
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
mock_killpg.side_effect = ProcessLookupError("no such process")
|
||||
@@ -427,7 +432,7 @@ class TestCli:
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
+184
-6
@@ -11,6 +11,8 @@ from devx.ci.publish import (
|
||||
_default_gitea_registry_url,
|
||||
build_package,
|
||||
generate_release_notes,
|
||||
get_latest_tag,
|
||||
is_release_commit,
|
||||
main,
|
||||
publish_to_gitea_registry,
|
||||
publish_to_pypi,
|
||||
@@ -122,11 +124,18 @@ class TestPublishToGiteaRegistry:
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="registry upload failed")
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="registry upload failed")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
||||
assert "Gitea PyPI registry" in str(exc.value)
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_409_conflict_is_non_fatal(self, mock_run: MagicMock) -> None:
|
||||
"""409 Conflict (already published) should not raise — just continue."""
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="ERROR 409 Conflict from url", stderr="")
|
||||
# Should not raise
|
||||
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
||||
|
||||
|
||||
class TestDefaultGiteaRegistryUrl:
|
||||
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
|
||||
@@ -147,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"})
|
||||
@@ -294,14 +310,21 @@ class TestMain:
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_publish_failure_raises_click(
|
||||
def test_publish_failure_continues_to_gitea_release(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
"""PyPI publish failure is non-fatal — Gitea release is still created."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
mock_publish.side_effect = click.ClickException("publish failed")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "publish" in result.output
|
||||
assert result.exit_code == 0
|
||||
assert "non-fatal" in result.output
|
||||
mock_tea.create_release.assert_called_once_with(
|
||||
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
||||
)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@@ -371,5 +394,160 @@ class TestMain:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Gitea release v1.0.0 created" in result.output
|
||||
mock_tea.create_release.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_create_release_already_exists_is_idempotent(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_gitea_pub: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
"""If create_release fails with 'already exists', treat as success."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "already exists" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_create_release_other_error_raises(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_gitea_pub: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
"""If create_release fails with a non-'already exists' error, raise."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||
mock_tea.create_release.side_effect = TeaCLIError("network error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code != 0
|
||||
assert "Release creation failed" in result.output
|
||||
|
||||
|
||||
class TestFromTag:
|
||||
def test_get_latest_tag_success(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="v1.2.3\n")
|
||||
result = get_latest_tag()
|
||||
assert result == "v1.2.3"
|
||||
|
||||
def test_get_latest_tag_no_tags(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, [])
|
||||
result = get_latest_tag()
|
||||
assert result is None
|
||||
|
||||
def test_is_release_commit_match(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="release: v1.2.3 [skip ci]\n"
|
||||
)
|
||||
result = is_release_commit("v1.2.3")
|
||||
assert result is True
|
||||
|
||||
def test_is_release_commit_no_match(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n")
|
||||
result = is_release_commit("v1.2.3")
|
||||
assert result is False
|
||||
|
||||
def test_is_release_commit_git_error(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, [])
|
||||
result = is_release_commit("v1.2.3")
|
||||
assert result is False
|
||||
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||
def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "No tag found" in result.output
|
||||
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}):
|
||||
result = runner.invoke(main, ["--from-tag", "--skip-build"])
|
||||
assert result.exit_code == 0
|
||||
assert "No tag found" in result.output
|
||||
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = runner.invoke(main, ["--from-tag", "--skip-build"])
|
||||
assert result.exit_code != 0
|
||||
assert "REPO argument is required" in result.output
|
||||
|
||||
@patch("devx.ci.publish.is_release_commit", return_value=False)
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
|
||||
def test_from_tag_not_release_commit_skips(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "not a release commit" in result.output
|
||||
|
||||
@patch("devx.ci.publish.is_release_commit", return_value=True)
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
|
||||
def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Publishing release v1.0.0" in result.output
|
||||
|
||||
@patch("devx.ci.publish.is_release_commit", return_value=True)
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
|
||||
def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
|
||||
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-tag", "--skip-build"])
|
||||
assert result.exit_code == 0
|
||||
assert "Publishing release v1.0.0" in result.output
|
||||
|
||||
def test_no_tag_no_from_tag_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
|
||||
assert result.exit_code != 0
|
||||
assert "Tag is required" in result.output
|
||||
|
||||
@@ -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")
|
||||
@@ -766,7 +800,7 @@ class TestCommitReleaseChanges:
|
||||
assert result is True
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.validate_commit_msg import first_line, get_branch, main
|
||||
from devx.ci.validate_commit_msg import first_line, get_branch, get_latest_commit_msg, main
|
||||
from devx.config import CONVENTIONAL_RE, TASK_ID_RE
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class TestMain:
|
||||
def test_usage_message_without_args(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 2
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_branch_override_accepts_master_commit(self) -> None:
|
||||
"""--branch master overrides branch detection (for CI use)."""
|
||||
@@ -257,3 +257,45 @@ def test_main_module_block() -> None:
|
||||
namespace["main"]([msg_path], standalone_mode=False)
|
||||
|
||||
os.unlink(msg_path)
|
||||
|
||||
|
||||
class TestGitMode:
|
||||
def test_git_flag_reads_from_git(self, tmp_path) -> None:
|
||||
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value="feat: add feature"):
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_git_flag_master_valid(self) -> None:
|
||||
msg = "DEVX-24: fix: resolve timeout"
|
||||
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git", "--branch", "master"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_git_flag_master_invalid(self) -> None:
|
||||
msg = "fix: resolve timeout"
|
||||
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git", "--branch", "master"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_no_file_no_git_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--branch", "master"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_get_latest_commit_msg_success(self) -> None:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: test\n\nBody")
|
||||
result = get_latest_commit_msg()
|
||||
assert result == "feat: test\n\nBody"
|
||||
|
||||
def test_stdin_input(self) -> None:
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, input="feat: add feature\n", args=["-", "--branch", "feature-branch"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
Reference in New Issue
Block a user