Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ff73bb1e | ||
|
|
368c87aabf | ||
|
|
4f982dc3ba | ||
|
|
a7a8637244 | ||
|
|
cdf3408a35 | ||
|
|
8fcac10286 | ||
|
|
c62c560c85 | ||
|
|
08b781f978 | ||
|
|
ea7566fe6b | ||
|
|
d8ceb6c8a1 | ||
|
|
748baf17eb | ||
|
|
f339df3562 | ||
|
|
db38453a54 | ||
|
|
5d78377152 | ||
|
|
b8b21cccd5 | ||
|
|
326eccfd2f | ||
|
|
076b470344 | ||
|
|
53b49ec91c | ||
|
|
2cfc0aca10 | ||
|
|
83ea4496e5 | ||
|
|
adb94bf96f | ||
|
|
32308f2ad8 | ||
|
|
5468a6f4af | ||
|
|
79830b52e7 | ||
|
|
ddfbdec956 | ||
|
|
68f0872134 | ||
|
|
888cc4e3b2 | ||
|
|
f08ff0e7a3 | ||
|
|
772e1b1c6d |
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Check unit test speed
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 8 --max-single-seconds 0.5
|
||||
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||
env:
|
||||
DEVX_DOC_COVERAGE_STRICT: "1"
|
||||
|
||||
@@ -71,7 +71,7 @@ src/devx/
|
||||
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
|
||||
├── ci/ # CI/CD automation modules (run by workflows)
|
||||
│ ├── release.py # Automated versioning, tagging, changelog
|
||||
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
|
||||
│ ├── publish.py # Build, publish to Gitea PyPI registry, create Gitea release (with retry)
|
||||
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
|
||||
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
|
||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
||||
@@ -310,6 +310,20 @@ by `python -m devx.tools.install_tools` and configured by
|
||||
- `create_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
|
||||
- `create_release()` / `list_releases()` — Release management
|
||||
|
||||
**`devx.gitea_cli.configure_tea_login()`** — Configures tea login in
|
||||
containerized CI environments where `make setup` was not called. Used by
|
||||
`publish.py` (`--auto-login`) and `notify_failure.py` (`--auto-login`).
|
||||
Raises `TeaCLIError` if login configuration fails — this prevents cryptic
|
||||
"no available login" errors from subsequent tea commands.
|
||||
|
||||
**Error handling**: `TeaCLI._run()` includes both stdout and stderr in
|
||||
`TeaCLIError` messages, because `tea` writes some errors (for example,
|
||||
"no available login") to stdout, not stderr.
|
||||
|
||||
**Release creation retry**: `publish.py` retries Gitea release creation
|
||||
up to 3 times with exponential backoff (2s, 4s) on transient failures.
|
||||
"Already exists" errors are treated as success (idempotent).
|
||||
|
||||
### git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
||||
|
||||
@@ -2,6 +2,60 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.47.1] - 2026-07-16
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Tea CLI login failure handling, error messages, release retry
|
||||
|
||||
## [0.47.0] - 2026-07-14
|
||||
|
||||
### Features
|
||||
|
||||
- Add promtool to install_tools for alert rule validation
|
||||
|
||||
## [0.46.0] - 2026-07-14
|
||||
|
||||
### Features
|
||||
|
||||
- Make check_test_isolation configurable via pyproject.toml
|
||||
|
||||
## [0.45.1] - 2026-07-14
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- URL-encode package names and versions in clean_images API calls
|
||||
|
||||
## [0.45.0] - 2026-07-14
|
||||
|
||||
### Features
|
||||
|
||||
- Add IO_INTERNAL_CALLS to check_test_isolation
|
||||
|
||||
## [0.44.2] - 2026-07-14
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use legacy Docker builder to avoid Gitea registry 403
|
||||
|
||||
## [0.44.1] - 2026-07-14
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Disable Docker buildx provenance attestation
|
||||
|
||||
## [0.44.0] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- Add fix_pr_title module and update_pr API method
|
||||
|
||||
## [0.43.0] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- Add get_customer_vm_ip and get_observability_vm_ip to I/O check
|
||||
|
||||
## [0.42.0] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.42.0",
|
||||
"devx>=0.47.1",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
@@ -101,8 +101,8 @@ pip install -e .
|
||||
```
|
||||
|
||||
> **Note:** If your project requires a specific devx version, pin it in
|
||||
> `dependencies` (for example, `"devx==0.42.0"`) or use a version constraint
|
||||
> (for example, `"devx>=0.42.0,<0.43"`).
|
||||
> `dependencies` (for example, `"devx==0.47.1"`) or use a version constraint
|
||||
> (for example, `"devx>=0.47.1,<0.48"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -40,20 +40,30 @@ invocation in any repo with devx installed automatically runs the
|
||||
static analysis. No extra Makefile target or CI step needed.
|
||||
|
||||
The plugin (`devx.tools.check_test_isolation`) statically analyzes
|
||||
test files during `pytest_collection_finish` and emits
|
||||
`UserWarning` for violations:
|
||||
test files during `pytest_collection_finish` and **fails the test run**
|
||||
on any hard violation:
|
||||
|
||||
- **unpatched-subprocess**: `subprocess.run/call/Popen/check_call/check_output`
|
||||
called in a test function without `@patch`
|
||||
called in a test function without `@patch` or `with patch(...)`
|
||||
- **unpatched-sleep**: `time.sleep` called without `@patch`
|
||||
- **unpatched-helper**: known subprocess-spawning helpers
|
||||
(`update_doc_versions`, `run_cmd`, `run_tests`) called without
|
||||
`@patch` (and without patching their internal dependencies)
|
||||
- **excessive-iterations**: `for _ in range(N)` where N > 100
|
||||
- **heavy-module-import**: `httpx`, `ansible`, etc. imported at module
|
||||
level in test files, slowing collection for all tests
|
||||
- **reload-without-cleanup**: `importlib.reload()` called an odd number
|
||||
of times, leaving module state modified
|
||||
|
||||
The plugin recognizes transitive safety: if `run_cmd` is patched,
|
||||
`run_tests` (which calls `run_cmd`) is safe. This is tracked via
|
||||
`HELPER_INTERNAL_CALLS`.
|
||||
Transitive-subprocess findings (via call-graph analysis) are reported
|
||||
as **advisories** — the static analysis can't predict early exits or
|
||||
runtime branch conditions, so the runtime audit is authoritative.
|
||||
|
||||
The plugin also wraps `subprocess.run` at runtime to catch real
|
||||
subprocess calls that leak through transitive call paths (for example
|
||||
`CliRunner.invoke(main)` → `main()` → `update_doc_versions()` →
|
||||
`subprocess.run()`). If a test spawns a real subprocess without
|
||||
`@patch`, the test fails.
|
||||
|
||||
A standalone CLI (`python -m devx.tools.check_test_isolation`) is also
|
||||
provided for CI gates and pre-commit hooks where pytest isn't run.
|
||||
@@ -85,16 +95,20 @@ is even created.
|
||||
|
||||
- **Automatic enforcement**: The pytest plugin runs on every `pytest`
|
||||
invocation across devx, grm, and infra — no per-repo configuration
|
||||
needed. New tests with unpatched subprocess calls emit warnings
|
||||
immediately.
|
||||
needed. New tests with unpatched subprocess calls fail immediately.
|
||||
- **Shift-left**: Translation gaps and test isolation violations are
|
||||
caught locally (pre-commit / `make lint`) instead of in CI.
|
||||
- **Fast feedback**: Static analysis adds <0.1s to test runs — no
|
||||
runtime overhead.
|
||||
- **No false positives**: The transitive dependency tracking
|
||||
(`HELPER_INTERNAL_CALLS`) correctly recognizes that patching
|
||||
`run_cmd` makes `run_tests` safe, and patching `subprocess.run`
|
||||
makes all helpers safe.
|
||||
- **Fast feedback**: Static analysis adds <0.1s to test runs; runtime
|
||||
subprocess audit adds negligible overhead (wrapper checks a
|
||||
thread-local flag).
|
||||
- **Transitive detection**: The call-graph BFS traces
|
||||
`CliRunner.invoke(main)` → `main()` → `update_doc_versions()` →
|
||||
`subprocess.run()`, catching indirect subprocess leaks that direct
|
||||
analysis misses. The runtime audit provides authoritative enforcement.
|
||||
- **No false positives**: The call graph correctly recognizes that
|
||||
patching `run_cmd` makes `run_tests` (which calls `run_cmd`) safe,
|
||||
and class methods are excluded to avoid false positives when classes
|
||||
like `TeaCLI` are patched.
|
||||
|
||||
### Negative
|
||||
|
||||
@@ -103,10 +117,12 @@ is even created.
|
||||
definitions) appears uncovered. Mitigated by `-p no:devx_test_isolation`
|
||||
in devx's own `pyproject.toml` `addopts` and `# pragma: no cover` on
|
||||
plugin hook functions.
|
||||
- **Static analysis limitations**: The plugin only sees direct calls
|
||||
in test function bodies, not indirect calls through `main()` or
|
||||
other wrappers. This is acceptable — the `check_test_speed` tool
|
||||
catches the symptom (slow tests) for indirect cases.
|
||||
- **Static analysis limitations**: The call-graph BFS can't predict
|
||||
runtime branch conditions or early exits — a test that patches
|
||||
`shutil.which` to return `None` may skip the subprocess path
|
||||
entirely, but the static analysis still reports it. Transitive
|
||||
findings are advisories (exit 0) for this reason; the runtime audit
|
||||
is authoritative.
|
||||
- **Translation burden**: Every new `_()` call in source requires
|
||||
adding 6 language translations. This is by design (all supported
|
||||
languages must be complete) but adds friction for quick prototypes.
|
||||
@@ -122,21 +138,36 @@ in consumer repos.
|
||||
|
||||
### Disabling the Plugin
|
||||
|
||||
- `--no-test-isolation` flag: disables analysis for a single run
|
||||
- `--no-test-isolation` flag: disables static analysis and runtime
|
||||
subprocess audit for a single run
|
||||
- `-p no:devx_test_isolation` in `addopts`: disables for a repo
|
||||
(used in devx's own `pyproject.toml` for coverage reasons)
|
||||
|
||||
### Strict Mode
|
||||
### Call-Graph Analysis
|
||||
|
||||
- `--strict-test-isolation` flag: promotes warnings to errors and
|
||||
prints a summary to stderr
|
||||
- `filterwarnings = ["error:Test isolation:UserWarning"]` in
|
||||
`pyproject.toml`: same effect via pytest's warning filter system
|
||||
The `CallGraph` class parses all `.py` files under `src/` and builds
|
||||
a map of function → called functions. When a test calls
|
||||
`CliRunner.invoke(target)`, a BFS traces the call graph from `target`
|
||||
to find all reachable functions. Class methods are excluded from the
|
||||
call graph to avoid false positives when classes are patched (for example
|
||||
`@patch("...TeaCLI")` mocks all methods). The BFS respects `@patch`
|
||||
decorators — if a function is patched, traversal stops at that node.
|
||||
|
||||
### Runtime Subprocess Audit
|
||||
|
||||
The `_SubprocessAudit` singleton wraps `subprocess.run`, `call`,
|
||||
`check_call`, `check_output`, and `Popen` with thread-local
|
||||
recording wrappers. During each non-integration test, the wrapper
|
||||
records calls; if any are recorded (that is the test didn't `@patch`
|
||||
subprocess), the test fails. The wrappers check a thread-local flag,
|
||||
so inactive audits have zero overhead beyond the flag check.
|
||||
|
||||
### Known Subprocess Helpers
|
||||
|
||||
The `KNOWN_SUBPROCESS_HELPERS` dict maps function names to
|
||||
descriptions. `HELPER_INTERNAL_CALLS` maps each helper to the
|
||||
function names it internally calls, enabling transitive safety
|
||||
checks. Both are defined in `check_test_isolation.py` and can be
|
||||
extended as new subprocess-spawning helpers are added to devx.
|
||||
checks for direct calls in test functions. The call-graph BFS
|
||||
handles transitive detection for `CliRunner.invoke` targets. Both
|
||||
are defined in `check_test_isolation.py` and can be extended as
|
||||
new subprocess-spawning helpers are added to devx.
|
||||
|
||||
+8
-8
@@ -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
|
||||
|
||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.42.0",
|
||||
"devx>=0.47.1",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Pin a specific version if needed: `"devx==0.42.0"` or `"devx>=0.42.0,<0.43"`.
|
||||
Pin a specific version if needed: `"devx==0.47.1"` or `"devx>=0.47.1,<0.48"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -339,28 +339,37 @@ devx tools check-test-speed --max-seconds 4 --max-single-seconds 0.5
|
||||
Statically analyze test files for un-hermetic patterns that cause slow
|
||||
or flaky tests. Also available as a **pytest plugin** (auto-discovered
|
||||
via the `pytest11` entry point when devx is installed — runs
|
||||
automatically on every `pytest` invocation).
|
||||
automatically on every `pytest` invocation and **fails on violations**).
|
||||
|
||||
Detected patterns:
|
||||
Detected patterns (hard errors — exit non-zero):
|
||||
|
||||
- **unpatched-subprocess**: `subprocess.run/call/Popen/check_call/check_output`
|
||||
called in a test function without `@patch`
|
||||
called in a test function without `@patch` or `with patch(...)`
|
||||
- **unpatched-sleep**: `time.sleep` called without `@patch`
|
||||
- **unpatched-helper**: known subprocess-spawning helpers (`update_doc_versions`,
|
||||
`run_cmd`, `run_tests`) called without `@patch` or patching their internal deps
|
||||
- **excessive-iterations**: `for _ in range(N)` where N > 100
|
||||
- **heavy-module-import**: `httpx`, `ansible`, etc. imported at module level
|
||||
- **reload-without-cleanup**: `importlib.reload()` called an odd number of times
|
||||
|
||||
Advisory patterns (exit 0 — runtime audit is authoritative):
|
||||
|
||||
- **transitive-subprocess**: `CliRunner.invoke(target)` where `target`
|
||||
transitively calls `subprocess.run` without being patched. Detected via
|
||||
static call-graph analysis. The runtime subprocess audit catches actual
|
||||
leaks — if a real subprocess runs without `@patch`, the test fails.
|
||||
|
||||
```bash
|
||||
devx tools check-test-isolation
|
||||
devx tools check-test-isolation --test-path tests/ --strict
|
||||
devx tools check-test-isolation --categories unpatched-subprocess,unpatched-sleep
|
||||
devx tools check-test-isolation --test-path tests/
|
||||
devx tools check-test-isolation --categories unpatched-subprocess,transitive-subprocess
|
||||
devx tools check-test-isolation --max-loop-iterations 50
|
||||
devx tools check-test-isolation --src-dir src/
|
||||
```
|
||||
|
||||
Pytest plugin options (automatic when devx is installed):
|
||||
|
||||
- `--strict-test-isolation` — fail the test run on violations
|
||||
- `--no-test-isolation` — disable analysis for this run
|
||||
- `--no-test-isolation` — disable static analysis and runtime subprocess audit
|
||||
- `--test-isolation-max-loop N` — max iterations per loop (default: 100)
|
||||
|
||||
### `devx tools configure-repo`
|
||||
|
||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.42.0",
|
||||
"devx>=0.47.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx>=0.42.0",
|
||||
"devx>=0.47.1",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.42.0"
|
||||
__version__ = "0.47.1"
|
||||
|
||||
@@ -224,6 +224,16 @@ class GiteaClient:
|
||||
r = self._request("GET", f"/pulls/{pr_number}")
|
||||
return r.json()
|
||||
|
||||
def update_pr(self, pr_number: str | int, fields: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update a pull request (e.g. title, body, state).
|
||||
|
||||
Args:
|
||||
pr_number: PR number.
|
||||
fields: Dict of fields to update (e.g. {"title": "new title"}).
|
||||
"""
|
||||
r = self._request("PATCH", f"/pulls/{pr_number}", json=fields)
|
||||
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.
|
||||
|
||||
|
||||
@@ -285,6 +285,26 @@ def cli(
|
||||
click.echo("=" * 60, err=True)
|
||||
for e in errors:
|
||||
click.echo(f" - {e}", err=True)
|
||||
|
||||
# Remediation hints for the most common failure: PR title format
|
||||
title_errors = [
|
||||
e for e in errors if "PR title must follow format" in str(e) or "PR title task ID mismatch" in str(e)
|
||||
]
|
||||
if title_errors and pr_number is not None and repo is not None:
|
||||
click.echo("", err=True)
|
||||
click.echo("REMEDIATION:", err=True)
|
||||
click.echo(
|
||||
_(
|
||||
" Fix the PR title with:\n"
|
||||
" python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n"
|
||||
" Or manually set the PR title to: '{expected}'",
|
||||
repo=repo,
|
||||
pr=pr_number,
|
||||
expected=f"{task_id}: <Vikunja task title>",
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
|
||||
raise click.ClickException(_("Pre-merge validation failed."))
|
||||
|
||||
click.echo("[pre-merge-check] All auto-merge preconditions satisfied.")
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-fix PR title to follow the ``{PREFIX}-N: <title>`` convention.
|
||||
|
||||
Reads the task ID from the branch name, fetches the Vikunja task title,
|
||||
and updates the PR title via the Gitea API.
|
||||
|
||||
Exit codes:
|
||||
0 = PR title updated (or already correct)
|
||||
1 = Error (missing token, PR not found, etc.)
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.fix_pr_title --repo owner/repo --pr-number 123
|
||||
python3 -m devx.ci.fix_pr_title --repo owner/repo --branch DEVX-256-fix-foo --pr-number 123
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.ci.auto_merge import extract_task_id
|
||||
from devx.ci.check_auto_merge_ready import get_vikunja_title_optional
|
||||
from devx.config import (
|
||||
GITEA_API_URL,
|
||||
TASK_PREFIX,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--repo", required=True, help=_("Repository in owner/name format"))
|
||||
@click.option("--pr-number", type=int, required=True, help=_("PR number to fix"))
|
||||
@click.option("--branch", default=None, help=_("Branch name (auto-fetched from PR if not given)"))
|
||||
@click.option("--dry-run", is_flag=True, help=_("Show what would change without updating"))
|
||||
def cli(repo: str, pr_number: int, branch: str | None, dry_run: bool) -> None:
|
||||
"""Fix PR title to follow the ``{PREFIX}-N: <title>`` convention."""
|
||||
if "/" not in repo:
|
||||
raise click.ClickException(_("Repo must be in 'owner/name' format, got: {repo}", repo=repo))
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
|
||||
# 1. Get CI token
|
||||
try:
|
||||
token = get_ci_token()
|
||||
except click.ClickException as exc:
|
||||
raise click.ClickException(_("CI_GITEA_API_TOKEN not set: {error}", error=str(exc))) from exc
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
# 2. Fetch PR
|
||||
try:
|
||||
pr = client.get_pr(pr_number)
|
||||
except APIError as exc:
|
||||
raise click.ClickException(_("Failed to fetch PR #{pr}: {error}", pr=pr_number, error=str(exc))) from exc
|
||||
|
||||
current_title = str(pr.get("title", ""))
|
||||
if not branch:
|
||||
branch = str(pr.get("head", {}).get("ref", ""))
|
||||
if not branch:
|
||||
raise click.ClickException(_("Could not determine branch name from PR #{pr}", pr=pr_number))
|
||||
|
||||
click.echo(f"[fix-pr-title] Branch: {branch}")
|
||||
click.echo(f"[fix-pr-title] Current PR title: {current_title}")
|
||||
|
||||
# 3. Extract task ID from branch
|
||||
task_id = extract_task_id(branch)
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
|
||||
branch=branch,
|
||||
prefix=TASK_PREFIX,
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(f"[fix-pr-title] Task ID: {task_id}")
|
||||
|
||||
# 4. Get Vikunja task title
|
||||
vikunja_title = get_vikunja_title_optional(task_id)
|
||||
if vikunja_title is None:
|
||||
# Fallback: strip common prefixes from current title
|
||||
# (e.g. "fix: ...", "feat: ...", "refactor: ...")
|
||||
import re
|
||||
|
||||
stripped = re.sub(
|
||||
r"^(fix|feat|refactor|chore|docs|test|ci|build|perf|style|revert)(\(.+?\))?!?:\s*", "", current_title
|
||||
)
|
||||
# Also strip any leading task ID prefix
|
||||
stripped = re.sub(rf"^{TASK_PREFIX}-\d+:\s*", "", stripped)
|
||||
vikunja_title = stripped if stripped else current_title
|
||||
click.echo(f"[fix-pr-title] WARNING: Vikunja task not found — using stripped title: {vikunja_title}")
|
||||
else:
|
||||
click.echo(f"[fix-pr-title] Vikunja title: {vikunja_title}")
|
||||
|
||||
# 5. Build new title
|
||||
# Defensive: strip task ID prefix from Vikunja title if present
|
||||
if vikunja_title.startswith(f"{task_id}:"):
|
||||
vikunja_title = vikunja_title[len(f"{task_id}:") :].strip()
|
||||
|
||||
new_title = f"{task_id}: {vikunja_title}"
|
||||
|
||||
if current_title == new_title:
|
||||
click.echo(f"[fix-pr-title] PR title already correct: {new_title}")
|
||||
return
|
||||
|
||||
click.echo(f"[fix-pr-title] New PR title: {new_title}")
|
||||
|
||||
if dry_run:
|
||||
click.echo("[fix-pr-title] Dry run — not updating PR.")
|
||||
return
|
||||
|
||||
# 6. Update PR title
|
||||
try:
|
||||
client.update_pr(pr_number, {"title": new_title})
|
||||
except APIError as exc:
|
||||
raise click.ClickException(_("Failed to update PR #{pr}: {error}", pr=pr_number, error=str(exc))) from exc
|
||||
|
||||
click.echo(f"[fix-pr-title] PR #{pr_number} title updated to: {new_title}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
+37
-7
@@ -4,6 +4,10 @@
|
||||
Uses git-cliff to generate the release notes from conventional commits.
|
||||
Uses the ``tea`` Gitea CLI for release creation.
|
||||
|
||||
Gitea release creation is retried up to 3 times with exponential backoff
|
||||
(2s, 4s) to handle transient failures (network timeouts, 5xx errors).
|
||||
If the release already exists, it is treated as success (idempotent).
|
||||
|
||||
Publishing destinations (checked in order):
|
||||
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
|
||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||
@@ -27,6 +31,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||
@@ -312,13 +317,7 @@ def main(
|
||||
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
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
|
||||
_create_release_with_retry(tea, repo, tag, release_body)
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
@@ -328,5 +327,36 @@ def main(
|
||||
)
|
||||
|
||||
|
||||
def _create_release_with_retry(tea: TeaCLI, repo: str, tag: str, release_body: str) -> None:
|
||||
"""Create a Gitea release with retry for transient failures.
|
||||
|
||||
Retries up to 3 times with exponential backoff (2s, 4s) on TeaCLIError
|
||||
unless the error indicates the release already exists (which is treated
|
||||
as success). This handles transient issues like network timeouts, Gitea
|
||||
rate limiting, or temporary 5xx errors that caused CI run #2822 to fail.
|
||||
"""
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=10),
|
||||
retry=retry_if_exception_type(TeaCLIError),
|
||||
reraise=True,
|
||||
)
|
||||
def _attempt() -> None:
|
||||
try:
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
error_str = str(e).lower()
|
||||
if "already" in error_str and "release" in error_str:
|
||||
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
||||
return
|
||||
raise
|
||||
|
||||
try:
|
||||
_attempt()
|
||||
except TeaCLIError as e:
|
||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
|
||||
+78
-17
@@ -40,27 +40,46 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
|
||||
from devx.i18n import _
|
||||
from devx.tokens import get_ci_token
|
||||
|
||||
logger = logging.getLogger("gitea_cli")
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
"""Raised when a tea CLI command fails."""
|
||||
|
||||
|
||||
class _TransientTeaError(TeaCLIError):
|
||||
"""Tea CLI error caused by a transient HTTP status (502/503/504/429)."""
|
||||
|
||||
|
||||
def configure_tea_login(login_name: str = "devx") -> None:
|
||||
"""Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips silently if tea is not installed or no token is set.
|
||||
|
||||
Raises ``TeaCLIError`` if the login add or default command fails. This is
|
||||
critical because subsequent tea commands (e.g. ``releases create``) will
|
||||
fail with a cryptic "no available login" error if the login was not
|
||||
configured successfully.
|
||||
|
||||
Used by CI scripts (publish, notify_failure) that need tea login but
|
||||
run in containerized environments where ``make setup`` was not called.
|
||||
"""
|
||||
@@ -88,18 +107,31 @@ def configure_tea_login(login_name: str = "devx") -> None:
|
||||
return
|
||||
|
||||
click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url))
|
||||
subprocess.run( # nosec B603
|
||||
add_result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run( # nosec B603
|
||||
if add_result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea login add failed (rc={add_result.returncode})\n"
|
||||
f"stdout: {add_result.stdout.strip()}\n"
|
||||
f"stderr: {add_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
default_result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "default", login_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if default_result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea login default failed (rc={default_result.returncode})\n"
|
||||
f"stdout: {default_result.stdout.strip()}\n"
|
||||
f"stderr: {default_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
class TeaCLI:
|
||||
@@ -122,6 +154,10 @@ class TeaCLI:
|
||||
def _run(self, args: list[str], json_output: bool = True) -> str:
|
||||
"""Run a tea command and return stdout.
|
||||
|
||||
Retries up to ``MAX_RETRIES`` times on transient HTTP errors
|
||||
(502/503/504/429) detected in stderr/stdout, with exponential
|
||||
backoff. Non-transient errors fail immediately.
|
||||
|
||||
Args:
|
||||
args: Command arguments (without the leading ``tea``).
|
||||
json_output: If True, append ``--output json`` to the command.
|
||||
@@ -130,25 +166,50 @@ class TeaCLI:
|
||||
stdout as a string.
|
||||
|
||||
Raises:
|
||||
TeaCLIError: If the command fails.
|
||||
TeaCLIError: If the command fails after retries are exhausted.
|
||||
"""
|
||||
cmd = [self._tea, *args]
|
||||
if json_output:
|
||||
cmd.extend(["--output", "json"])
|
||||
|
||||
def _execute() -> str:
|
||||
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:
|
||||
parts = [
|
||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}",
|
||||
f"stdout: {result.stdout.strip()}" if result.stdout.strip() else "",
|
||||
f"stderr: {result.stderr.strip()}" if result.stderr.strip() else "",
|
||||
]
|
||||
msg = "\n".join(p for p in parts if p)
|
||||
combined = f"{result.stdout} {result.stderr}".lower()
|
||||
if any(str(code) in combined for code in RETRY_STATUS_CODES):
|
||||
raise _TransientTeaError(msg)
|
||||
raise TeaCLIError(msg)
|
||||
return result.stdout.strip()
|
||||
|
||||
retry_decorator = retry(
|
||||
stop=stop_after_attempt(MAX_RETRIES),
|
||||
wait=wait_exponential(
|
||||
multiplier=RETRY_BACKOFF_BASE,
|
||||
min=RETRY_BACKOFF_BASE,
|
||||
max=RETRY_BACKOFF_BASE**MAX_RETRIES,
|
||||
),
|
||||
retry=retry_if_exception_type(_TransientTeaError),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
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()}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
return retry_decorator(_execute)()
|
||||
except _TransientTeaError as e:
|
||||
raise TeaCLIError(str(e)) from e
|
||||
|
||||
def _run_raw(self, args: list[str]) -> str:
|
||||
"""Run a tea command without JSON output and return stdout."""
|
||||
|
||||
@@ -174,9 +174,12 @@ def build_image(
|
||||
return True
|
||||
|
||||
click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...")
|
||||
# Use legacy builder (DOCKER_BUILDKIT=0) to avoid OCI-format manifest
|
||||
# blobs (attestation, config) that the Gitea registry rejects with 403.
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
check=False,
|
||||
env={**os.environ, "DOCKER_BUILDKIT": "0"},
|
||||
)
|
||||
if result.returncode != 0:
|
||||
click.echo(_("Build failed for {name}", name=spec.name), err=True)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,13 @@ Queries the Gitea API for all versions of a package (container type) and
|
||||
deletes all but the most recent N versions. The ``latest`` tag is always
|
||||
preserved if present.
|
||||
|
||||
.. note::
|
||||
This tool only deletes package versions via the Gitea API. The underlying
|
||||
blob files on the Gitea server's filesystem are NOT removed by this tool
|
||||
(Gitea 1.26.x has no built-in garbage collection). The production VM's
|
||||
daily cleanup script (``cleanup_gitea.py``) handles filesystem blob GC
|
||||
by querying the database for referenced blobs and removing orphaned files.
|
||||
|
||||
Usage::
|
||||
|
||||
# Clean up ci-base images, keep last 2 versions
|
||||
@@ -57,7 +64,10 @@ def list_package_versions(
|
||||
Returns a list of version dicts, each containing at least ``version``
|
||||
and ``created_at`` fields.
|
||||
"""
|
||||
url = f"{api_url}/packages/{owner}?type=container&name={name}"
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_name = quote(name, safe="")
|
||||
url = f"{api_url}/packages/{owner}?type=container&name={encoded_name}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
all_versions: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
@@ -96,7 +106,11 @@ def delete_package_version(
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_name = quote(name, safe="")
|
||||
encoded_version = quote(version, safe="")
|
||||
url = f"{api_url}/packages/{owner}/{package_type}/{encoded_name}/{encoded_version}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,7 @@ Handles installation of:
|
||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||
- hadolint (Dockerfile linter)
|
||||
- vale (prose linter for documentation quality)
|
||||
- promtool (Prometheus rule validator)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
@@ -47,6 +48,8 @@ TOFU_VERSION = "1.12.3"
|
||||
|
||||
VALE_VERSION = "3.15.1"
|
||||
|
||||
PROMTOOL_VERSION = "3.5.5"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||
@@ -212,7 +215,26 @@ def install_vale() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale"]
|
||||
def install_promtool() -> bool:
|
||||
"""Install promtool (Prometheus rule validator) if not already present.
|
||||
|
||||
Downloads the official Prometheus release tarball from GitHub and
|
||||
extracts the ``promtool`` binary to ``~/.local/bin``.
|
||||
"""
|
||||
if _is_installed("promtool"):
|
||||
click.echo("promtool: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://github.com/prometheus/prometheus/releases/download/"
|
||||
f"v{PROMTOOL_VERSION}/prometheus-{PROMTOOL_VERSION}.linux-{arch}.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "promtool")
|
||||
click.echo(f"promtool: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale", "promtool"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
@@ -231,6 +253,8 @@ def _install_tool(name: str) -> bool:
|
||||
return install_tofu()
|
||||
if name == "vale":
|
||||
return install_vale()
|
||||
if name == "promtool":
|
||||
return install_promtool()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
|
||||
+160
-24
@@ -183,14 +183,6 @@
|
||||
"ru": "\nTag → Commit alignment:",
|
||||
"zh": "\nTag → Commit alignment:"
|
||||
},
|
||||
"\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\nFix: add @patch decorators for subprocess/time.sleep calls, or patch the calling function.\n": {
|
||||
"bg": "\nПроверката за изолация на тестове НЕ ПРЕМИНА: {count} нарушения в {files} файла.\nРешение: добавете @patch декоратори за subprocess/time.sleep извиквания или patch-нете извикващата функция.\n",
|
||||
"de": "\nTestisolationsprüfung FEHLGESCHLAGEN: {count} Verstoß/Verstöße in {files} Datei(en).\nBehebung: @patch-Dekoratoren für subprocess/time.sleep-Aufrufe hinzufügen oder die aufrufende Funktion patchen.\n",
|
||||
"en": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\nFix: add @patch decorators for subprocess/time.sleep calls, or patch the calling function.\n",
|
||||
"pl": "\nSprawdzenie izolacji testów NIE ZALICZONE: {count} naruszeń w {files} plikach.\nNaprawa: dodaj dekoratory @patch dla wywołań subprocess/time.sleep lub patchuj wywołującą funkcję.\n",
|
||||
"ru": "\nПроверка изоляции тестов НЕ ПРОЙДЕНА: {count} нарушений в {files} файлах.\nИсправление: добавьте декораторы @patch для вызовов subprocess/time.sleep или patch вызывающую функцию.\n",
|
||||
"zh": "\n测试隔离检查失败:在 {files} 个文件中有 {count} 个违规。\n修复:为 subprocess/time.sleep 调用添加 @patch 装饰器,或 patch 调用函数。\n"
|
||||
},
|
||||
"\nUntagged release commits:": {
|
||||
"bg": "\nUntagged release commits:",
|
||||
"de": "\nUntagged release commits:",
|
||||
@@ -1583,14 +1575,6 @@
|
||||
"ru": "Fetching origin/master...",
|
||||
"zh": "Fetching origin/master..."
|
||||
},
|
||||
"Fix: add @patch decorators for subprocess/time.sleep calls, or patch the calling function. Use property-based testing for statistical tests.": {
|
||||
"bg": "Решение: добавете @patch декоратори за subprocess/time.sleep извиквания или patch-нете извикващата функция. Използвайте property-based тестове за статистически тестове.",
|
||||
"de": "Behebung: @patch-Dekoratoren für subprocess/time.sleep-Aufrufe hinzufügen oder die aufrufende Funktion patchen. Property-based testing für statistische Tests verwenden.",
|
||||
"en": "Fix: add @patch decorators for subprocess/time.sleep calls, or patch the calling function. Use property-based testing for statistical tests.",
|
||||
"pl": "Naprawa: dodaj dekoratory @patch dla wywołań subprocess/time.sleep lub patchuj wywołującą funkcję. Użyj testów opartych na właściwościach dla testów statystycznych.",
|
||||
"ru": "Исправление: добавьте декораторы @patch для вызовов subprocess/time.sleep или patch вызывающую функцию. Используйте property-based тестирование для статистических тестов.",
|
||||
"zh": "修复:为 subprocess/time.sleep 调用添加 @patch 装饰器,或 patch 调用函数。对统计测试使用基于属性的测试。"
|
||||
},
|
||||
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
|
||||
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
|
||||
@@ -2903,14 +2887,6 @@
|
||||
"ru": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||
"zh": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls."
|
||||
},
|
||||
"Test isolation check FAILED: {count} violation(s) found in {files} test file(s).": {
|
||||
"bg": "Проверката за изолация на тестове НЕ ПРЕМИНА: открити са {count} нарушения в {files} тестови файла.",
|
||||
"de": "Testisolationsprüfung FEHLGESCHLAGEN: {count} Verstoß/Verstöße in {files} Testdatei(en) gefunden.",
|
||||
"en": "Test isolation check FAILED: {count} violation(s) found in {files} test file(s).",
|
||||
"pl": "Sprawdzenie izolacji testów NIE ZALICZONE: znaleziono {count} naruszeń w {files} plikach testowych.",
|
||||
"ru": "Проверка изоляции тестов НЕ ПРОЙДЕНА: найдено {count} нарушений в {files} тестовых файлах.",
|
||||
"zh": "测试隔离检查失败:在 {files} 个测试文件中发现 {count} 个违规。"
|
||||
},
|
||||
"Test isolation check passed: {count} test files analyzed, no violations found.": {
|
||||
"bg": "Проверката за изолация на тестове премина: анализирани са {count} тестови файла, няма нарушения.",
|
||||
"de": "Testisolationsprüfung bestanden: {count} Testdateien analysiert, keine Verstöße gefunden.",
|
||||
@@ -3686,5 +3662,165 @@
|
||||
"pl": "{separator}",
|
||||
"ru": "{separator}",
|
||||
"zh": "{separator}"
|
||||
},
|
||||
"\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n": {
|
||||
"bg": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
|
||||
"de": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
|
||||
"en": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
|
||||
"pl": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
|
||||
"ru": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
|
||||
"zh": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n"
|
||||
},
|
||||
" Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'": {
|
||||
"bg": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
|
||||
"de": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
|
||||
"en": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
|
||||
"pl": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
|
||||
"ru": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
|
||||
"zh": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'"
|
||||
},
|
||||
"Add @patch(\"subprocess.run\") or patch the calling function to fix this.": {
|
||||
"bg": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
|
||||
"de": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
|
||||
"en": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
|
||||
"pl": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
|
||||
"ru": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
|
||||
"zh": "Add @patch(\"subprocess.run\") or patch the calling function to fix this."
|
||||
},
|
||||
"Branch name (auto-fetched from PR if not given)": {
|
||||
"bg": "Branch name (auto-fetched from PR if not given)",
|
||||
"de": "Branch name (auto-fetched from PR if not given)",
|
||||
"en": "Branch name (auto-fetched from PR if not given)",
|
||||
"pl": "Branch name (auto-fetched from PR if not given)",
|
||||
"ru": "Branch name (auto-fetched from PR if not given)",
|
||||
"zh": "Branch name (auto-fetched from PR if not given)"
|
||||
},
|
||||
"CI_GITEA_API_TOKEN not set: {error}": {
|
||||
"bg": "CI_GITEA_API_TOKEN not set: {error}",
|
||||
"de": "CI_GITEA_API_TOKEN not set: {error}",
|
||||
"en": "CI_GITEA_API_TOKEN not set: {error}",
|
||||
"pl": "CI_GITEA_API_TOKEN not set: {error}",
|
||||
"ru": "CI_GITEA_API_TOKEN not set: {error}",
|
||||
"zh": "CI_GITEA_API_TOKEN not set: {error}"
|
||||
},
|
||||
"CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.": {
|
||||
"bg": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
|
||||
"de": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
|
||||
"en": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
|
||||
"pl": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
|
||||
"ru": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
|
||||
"zh": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function."
|
||||
},
|
||||
"Could not determine branch name from PR #{pr}": {
|
||||
"bg": "Could not determine branch name from PR #{pr}",
|
||||
"de": "Could not determine branch name from PR #{pr}",
|
||||
"en": "Could not determine branch name from PR #{pr}",
|
||||
"pl": "Could not determine branch name from PR #{pr}",
|
||||
"ru": "Could not determine branch name from PR #{pr}",
|
||||
"zh": "Could not determine branch name from PR #{pr}"
|
||||
},
|
||||
"Failed to fetch PR #{pr}: {error}": {
|
||||
"bg": "Failed to fetch PR #{pr}: {error}",
|
||||
"de": "Failed to fetch PR #{pr}: {error}",
|
||||
"en": "Failed to fetch PR #{pr}: {error}",
|
||||
"pl": "Failed to fetch PR #{pr}: {error}",
|
||||
"ru": "Failed to fetch PR #{pr}: {error}",
|
||||
"zh": "Failed to fetch PR #{pr}: {error}"
|
||||
},
|
||||
"Failed to update PR #{pr}: {error}": {
|
||||
"bg": "Failed to update PR #{pr}: {error}",
|
||||
"de": "Failed to update PR #{pr}: {error}",
|
||||
"en": "Failed to update PR #{pr}: {error}",
|
||||
"pl": "Failed to update PR #{pr}: {error}",
|
||||
"ru": "Failed to update PR #{pr}: {error}",
|
||||
"zh": "Failed to update PR #{pr}: {error}"
|
||||
},
|
||||
"Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.": {
|
||||
"bg": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
|
||||
"de": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
|
||||
"en": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
|
||||
"pl": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
|
||||
"ru": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
|
||||
"zh": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function."
|
||||
},
|
||||
"Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n": {
|
||||
"bg": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
|
||||
"de": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
|
||||
"en": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
|
||||
"pl": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
|
||||
"ru": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
|
||||
"zh": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n"
|
||||
},
|
||||
"Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.": {
|
||||
"bg": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
|
||||
"de": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
|
||||
"en": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
|
||||
"pl": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
|
||||
"ru": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
|
||||
"zh": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import."
|
||||
},
|
||||
"No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.": {
|
||||
"bg": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"de": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"en": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"pl": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"ru": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"zh": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description."
|
||||
},
|
||||
"PR number to fix": {
|
||||
"bg": "PR number to fix",
|
||||
"de": "PR number to fix",
|
||||
"en": "PR number to fix",
|
||||
"pl": "PR number to fix",
|
||||
"ru": "PR number to fix",
|
||||
"zh": "PR number to fix"
|
||||
},
|
||||
"Real subprocess call(s) detected in test '{test}' without @patch:": {
|
||||
"bg": "Real subprocess call(s) detected in test '{test}' without @patch:",
|
||||
"de": "Real subprocess call(s) detected in test '{test}' without @patch:",
|
||||
"en": "Real subprocess call(s) detected in test '{test}' without @patch:",
|
||||
"pl": "Real subprocess call(s) detected in test '{test}' without @patch:",
|
||||
"ru": "Real subprocess call(s) detected in test '{test}' without @patch:",
|
||||
"zh": "Real subprocess call(s) detected in test '{test}' without @patch:"
|
||||
},
|
||||
"Show what would change without updating": {
|
||||
"bg": "Show what would change without updating",
|
||||
"de": "Show what would change without updating",
|
||||
"en": "Show what would change without updating",
|
||||
"pl": "Show what would change without updating",
|
||||
"ru": "Show what would change without updating",
|
||||
"zh": "Show what would change without updating"
|
||||
},
|
||||
"Test isolation check FAILED: {count} violation(s) in {files} file(s).": {
|
||||
"bg": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
|
||||
"de": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
|
||||
"en": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
|
||||
"pl": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
|
||||
"ru": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
|
||||
"zh": "Test isolation check FAILED: {count} violation(s) in {files} file(s)."
|
||||
},
|
||||
"Test isolation check passed with {count} advisory warning(s) in {files} file(s).": {
|
||||
"bg": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
|
||||
"de": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
|
||||
"en": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
|
||||
"pl": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
|
||||
"ru": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
|
||||
"zh": "Test isolation check passed with {count} advisory warning(s) in {files} file(s)."
|
||||
},
|
||||
"Transitive-subprocess advisories (runtime audit is authoritative):": {
|
||||
"bg": "Transitive-subprocess advisories (runtime audit is authoritative):",
|
||||
"de": "Transitive-subprocess advisories (runtime audit is authoritative):",
|
||||
"en": "Transitive-subprocess advisories (runtime audit is authoritative):",
|
||||
"pl": "Transitive-subprocess advisories (runtime audit is authoritative):",
|
||||
"ru": "Transitive-subprocess advisories (runtime audit is authoritative):",
|
||||
"zh": "Transitive-subprocess advisories (runtime audit is authoritative):"
|
||||
},
|
||||
"importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.": {
|
||||
"bg": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"de": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"en": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"pl": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"ru": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"zh": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,18 @@ class TestGiteaClient:
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert call_kwargs["json"]["base"] == "develop"
|
||||
|
||||
def test_update_pr(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"number": 42, "title": "DEVX-99: New title"}))
|
||||
result = client.update_pr(42, {"title": "DEVX-99: New title"})
|
||||
assert result["number"] == 42
|
||||
client._session.request.assert_called_once_with(
|
||||
"PATCH",
|
||||
"https://git.example.com/repos/owner/repo/pulls/42",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"title": "DEVX-99: New title"},
|
||||
)
|
||||
|
||||
def test_get_pr_files(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
|
||||
@@ -508,7 +508,8 @@ class TestCLIBuildImage:
|
||||
|
||||
def test_missing_dockerfile_and_manifest(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(build_image.main, [])
|
||||
with patch("devx.tools.build_image.subprocess.run"):
|
||||
result = runner.invoke(build_image.main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower()
|
||||
|
||||
@@ -516,10 +517,11 @@ class TestCLIBuildImage:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"],
|
||||
)
|
||||
with patch("devx.tools.build_image.subprocess.run"):
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "registry" in result.output.lower()
|
||||
|
||||
@@ -527,7 +529,7 @@ class TestCLIBuildImage:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
with patch("devx.tools.build_image.subprocess.run"), patch.dict("os.environ", {}, clear=True):
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
|
||||
|
||||
@@ -152,7 +152,10 @@ class TestGetVikunjaTitleOptional:
|
||||
class TestCli:
|
||||
def test_fails_without_task_id(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX"}, clear=True):
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX"}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
):
|
||||
result = runner.invoke(cli, ["--branch", "no-task-id-here"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -242,6 +245,7 @@ class TestCli:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
|
||||
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
|
||||
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value=None),
|
||||
):
|
||||
result = runner.invoke(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -481,8 +481,9 @@ class TestRunGit:
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
||||
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
|
||||
def test_no_tags_outputs_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
@@ -555,8 +556,9 @@ class TestMain:
|
||||
# docs tag has no matching files — should not appear
|
||||
assert "Docs files" not in result.output
|
||||
|
||||
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
||||
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
||||
def test_no_tags_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
@@ -731,7 +733,10 @@ class TestGithubOutput:
|
||||
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
||||
with (
|
||||
patch.object(classify_changes_mod, "get_latest_tag", return_value=""),
|
||||
patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]),
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
@@ -774,7 +779,10 @@ class TestGithubOutput:
|
||||
)
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
||||
with (
|
||||
patch.object(classify_changes_mod, "get_latest_tag", return_value=""),
|
||||
patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]),
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
@@ -789,8 +797,9 @@ class TestGithubOutput:
|
||||
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"])
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
|
||||
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
|
||||
@@ -822,8 +831,9 @@ class TestGithubOutput:
|
||||
)
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output", "--force"])
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
|
||||
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
|
||||
@@ -836,8 +846,9 @@ class TestGithubOutput:
|
||||
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"])
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
|
||||
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
|
||||
@@ -869,8 +880,9 @@ class TestGithubOutput:
|
||||
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"])
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
|
||||
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
|
||||
|
||||
@@ -144,19 +144,21 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo")
|
||||
|
||||
@patch("devx.tools.create_pr.subprocess.run")
|
||||
@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:
|
||||
def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock, mock_subproc: 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.subprocess.run")
|
||||
@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:
|
||||
def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock, mock_subproc: MagicMock) -> None:
|
||||
mock_create.return_value = {"number": 1}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text")
|
||||
@@ -164,17 +166,19 @@ class TestCli:
|
||||
mock_create.assert_called_once()
|
||||
assert mock_create.call_args.args[2] == "PR body text"
|
||||
|
||||
@patch("devx.tools.create_pr.subprocess.run")
|
||||
@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:
|
||||
def test_missing_owner(self, mock_repo: MagicMock, mock_subproc: 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.subprocess.run")
|
||||
@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:
|
||||
def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock, mock_subproc: MagicMock) -> None:
|
||||
mock_create.return_value = {"number": 1}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"])
|
||||
|
||||
@@ -90,8 +90,9 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=False)
|
||||
|
||||
@patch("devx.tools.docker_login.docker_login")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_required_no_token_raises(self) -> None:
|
||||
def test_required_no_token_raises(self, mock_login: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
@@ -99,8 +100,9 @@ class TestCli:
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch("devx.tools.docker_login.docker_login")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_optional_no_token_skips(self) -> None:
|
||||
def test_optional_no_token_skips(self, mock_login: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tests for devx.ci.fix_pr_title."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.fix_pr_title import cli
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _obl_infra_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Use OBL-INFRA prefix to match infra repo conventions."""
|
||||
import re
|
||||
|
||||
monkeypatch.setattr("devx.ci.fix_pr_title.TASK_PREFIX", "OBL-INFRA")
|
||||
monkeypatch.setattr("devx.ci.auto_merge.TASK_PREFIX", "OBL-INFRA")
|
||||
monkeypatch.setattr("devx.ci.auto_merge.PR_TITLE_RE", re.compile(r"^OBL-INFRA-\d+:\s+.+"))
|
||||
monkeypatch.setattr("devx.ci.auto_merge._TASK_ID_PREFIX_RE", re.compile(r"^OBL-INFRA-\d+:\s*"))
|
||||
monkeypatch.setattr("devx.ci._shared.TASK_ID_RE", re.compile(r"OBL-INFRA-\d+"))
|
||||
|
||||
|
||||
class TestFixPrTitle:
|
||||
@patch("devx.ci.fix_pr_title.get_ci_token")
|
||||
@patch("devx.ci.fix_pr_title.GiteaClient")
|
||||
@patch("devx.ci.fix_pr_title.get_vikunja_title_optional")
|
||||
def test_fixes_title_with_vikunja(
|
||||
self,
|
||||
mock_vikunja: MagicMock,
|
||||
mock_gitea_cls: MagicMock,
|
||||
mock_ci_token: MagicMock,
|
||||
) -> None:
|
||||
"""PR title is updated to match task ID + Vikunja title."""
|
||||
mock_client = MagicMock()
|
||||
mock_gitea_cls.return_value = mock_client
|
||||
mock_client.get_pr.return_value = {
|
||||
"number": 42,
|
||||
"title": "Fix blackbox exporter",
|
||||
"head": {"ref": "OBL-INFRA-458-blackbox-ipv4"},
|
||||
}
|
||||
mock_vikunja.return_value = "Fix blackbox exporter IPv4 config"
|
||||
mock_ci_token.return_value = "token"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--pr-number", "42"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.update_pr.assert_called_once_with(42, {"title": "OBL-INFRA-458: Fix blackbox exporter IPv4 config"})
|
||||
|
||||
@patch("devx.ci.fix_pr_title.get_ci_token")
|
||||
@patch("devx.ci.fix_pr_title.GiteaClient")
|
||||
@patch("devx.ci.fix_pr_title.get_vikunja_title_optional")
|
||||
def test_strips_conventional_commit_prefix_when_no_vikunja(
|
||||
self,
|
||||
mock_vikunja: MagicMock,
|
||||
mock_gitea_cls: MagicMock,
|
||||
mock_ci_token: MagicMock,
|
||||
) -> None:
|
||||
"""When Vikunja task not found, strips conventional-commit prefix from current title."""
|
||||
mock_client = MagicMock()
|
||||
mock_gitea_cls.return_value = mock_client
|
||||
mock_client.get_pr.return_value = {
|
||||
"number": 10,
|
||||
"title": "fix: platform self-monitoring and fixes",
|
||||
"head": {"ref": "OBL-INFRA-456-platform-fixes"},
|
||||
}
|
||||
mock_vikunja.return_value = None
|
||||
mock_ci_token.return_value = "token"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--pr-number", "10"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.update_pr.assert_called_once_with(
|
||||
10, {"title": "OBL-INFRA-456: platform self-monitoring and fixes"}
|
||||
)
|
||||
|
||||
@patch("devx.ci.fix_pr_title.get_ci_token")
|
||||
@patch("devx.ci.fix_pr_title.GiteaClient")
|
||||
@patch("devx.ci.fix_pr_title.get_vikunja_title_optional")
|
||||
def test_already_correct_title_no_update(
|
||||
self,
|
||||
mock_vikunja: MagicMock,
|
||||
mock_gitea_cls: MagicMock,
|
||||
mock_ci_token: MagicMock,
|
||||
) -> None:
|
||||
"""When PR title is already correct, no update is made."""
|
||||
mock_client = MagicMock()
|
||||
mock_gitea_cls.return_value = mock_client
|
||||
mock_client.get_pr.return_value = {
|
||||
"number": 5,
|
||||
"title": "OBL-INFRA-100: Fix bug",
|
||||
"head": {"ref": "OBL-INFRA-100-fix-bug"},
|
||||
}
|
||||
mock_vikunja.return_value = "Fix bug"
|
||||
mock_ci_token.return_value = "token"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--pr-number", "5"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.update_pr.assert_not_called()
|
||||
|
||||
@patch("devx.ci.fix_pr_title.get_ci_token")
|
||||
@patch("devx.ci.fix_pr_title.GiteaClient")
|
||||
@patch("devx.ci.fix_pr_title.get_vikunja_title_optional")
|
||||
def test_dry_run_no_update(
|
||||
self,
|
||||
mock_vikunja: MagicMock,
|
||||
mock_gitea_cls: MagicMock,
|
||||
mock_ci_token: MagicMock,
|
||||
) -> None:
|
||||
"""Dry run shows what would change without updating."""
|
||||
mock_client = MagicMock()
|
||||
mock_gitea_cls.return_value = mock_client
|
||||
mock_client.get_pr.return_value = {
|
||||
"number": 7,
|
||||
"title": "Fix thing",
|
||||
"head": {"ref": "OBL-INFRA-7-fix-thing"},
|
||||
}
|
||||
mock_vikunja.return_value = "Fix thing"
|
||||
mock_ci_token.return_value = "token"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--pr-number", "7", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.update_pr.assert_not_called()
|
||||
|
||||
@patch("devx.ci.fix_pr_title.get_ci_token")
|
||||
@patch("devx.ci.fix_pr_title.GiteaClient")
|
||||
def test_no_task_id_in_branch_exits_error(
|
||||
self,
|
||||
mock_gitea_cls: MagicMock,
|
||||
mock_ci_token: MagicMock,
|
||||
) -> None:
|
||||
"""When branch has no task ID, exits with error."""
|
||||
mock_client = MagicMock()
|
||||
mock_gitea_cls.return_value = mock_client
|
||||
mock_client.get_pr.return_value = {
|
||||
"number": 1,
|
||||
"title": "Some title",
|
||||
"head": {"ref": "just-a-branch"},
|
||||
}
|
||||
mock_ci_token.return_value = "token"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--pr-number", "1"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
mock_client.update_pr.assert_not_called()
|
||||
|
||||
@patch("devx.ci.fix_pr_title.get_ci_token")
|
||||
def test_no_token_exits_error(self, mock_ci_token: MagicMock) -> None:
|
||||
"""When CI token is not set, exits with error."""
|
||||
mock_ci_token.side_effect = Exception("no token")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--pr-number", "1"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch("devx.ci.fix_pr_title.get_ci_token")
|
||||
@patch("devx.ci.fix_pr_title.GiteaClient")
|
||||
@patch("devx.ci.fix_pr_title.get_vikunja_title_optional")
|
||||
def test_strips_task_id_prefix_from_vikunja_title(
|
||||
self,
|
||||
mock_vikunja: MagicMock,
|
||||
mock_gitea_cls: MagicMock,
|
||||
mock_ci_token: MagicMock,
|
||||
) -> None:
|
||||
"""When Vikunja title already has task ID prefix, it's stripped to avoid double prefix."""
|
||||
mock_client = MagicMock()
|
||||
mock_gitea_cls.return_value = mock_client
|
||||
mock_client.get_pr.return_value = {
|
||||
"number": 99,
|
||||
"title": "Fix thing",
|
||||
"head": {"ref": "OBL-INFRA-99-fix-thing"},
|
||||
}
|
||||
mock_vikunja.return_value = "OBL-INFRA-99: Fix thing"
|
||||
mock_ci_token.return_value = "token"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--pr-number", "99"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.update_pr.assert_called_once_with(99, {"title": "OBL-INFRA-99: Fix thing"})
|
||||
|
||||
def test_invalid_repo_format_exits_error(self) -> None:
|
||||
"""When repo is not in owner/name format, exits with error."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--repo", "invalid", "--pr-number", "1"])
|
||||
assert result.exit_code != 0
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/gitea_cli.py."""
|
||||
"""Unit tests for devx/gitea_cli.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +7,13 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login
|
||||
from devx.gitea_cli import (
|
||||
TeaCLI,
|
||||
TeaCLIError,
|
||||
_extract_issue_number,
|
||||
_extract_pr_number,
|
||||
configure_tea_login,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractIssueNumber:
|
||||
@@ -77,6 +83,25 @@ class TestTeaCLIRun:
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
|
||||
def test_run_failure_includes_stdout(self) -> None:
|
||||
"""tea writes some errors to stdout (e.g. 'no available login')."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=1, stdout="no available login", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(TeaCLIError, match="no available login"):
|
||||
cli._run(["releases", "create"])
|
||||
|
||||
def test_run_failure_includes_both_stdout_and_stderr(self) -> None:
|
||||
"""When both stdout and stderr have content, both are included."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=1, stdout="partial error", stderr="auth error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(TeaCLIError, match="partial error"):
|
||||
cli._run(["labels", "list"])
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
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")):
|
||||
@@ -100,6 +125,46 @@ class TestTeaCLIRun:
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--output" not in cmd
|
||||
|
||||
def test_run_retries_on_502(self) -> None:
|
||||
"""Transient 502 errors should be retried, then succeed."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="502 Bad Gateway")
|
||||
success_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
|
||||
with patch("subprocess.run", side_effect=[fail_result, success_result]) as mock_run:
|
||||
with patch("tenacity.nap.time.sleep"):
|
||||
output = cli._run(["labels", "list"])
|
||||
assert output == '[{"id": 1}]'
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_run_retries_on_503_then_fails(self) -> None:
|
||||
"""If all retries are exhausted on 503, raise TeaCLIError."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="503 Service Unavailable")
|
||||
with patch("subprocess.run", return_value=fail_result):
|
||||
with patch("tenacity.nap.time.sleep"):
|
||||
with pytest.raises(TeaCLIError, match="503"):
|
||||
cli._run(["issues", "create"])
|
||||
# MAX_RETRIES=3, so 3 attempts total
|
||||
|
||||
def test_run_no_retry_on_non_transient_error(self) -> None:
|
||||
"""Non-transient errors (e.g. auth) should fail immediately without retry."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="auth error")
|
||||
with patch("subprocess.run", return_value=fail_result) as mock_run:
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_run_retries_on_429_in_stdout(self) -> None:
|
||||
"""429 rate limit in stdout should trigger retry."""
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
fail_result = MagicMock(returncode=1, stdout="429 Too Many Requests", stderr="")
|
||||
success_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", side_effect=[fail_result, success_result]):
|
||||
with patch("tenacity.nap.time.sleep"):
|
||||
output = cli._run(["releases", "create"])
|
||||
assert output == "ok"
|
||||
|
||||
|
||||
class TestRepoArg:
|
||||
def test_with_repo_arg(self) -> None:
|
||||
@@ -380,9 +445,11 @@ class TestConfigureTeaLogin:
|
||||
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login adds login when not already configured."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_subprocess.return_value = mock_list
|
||||
mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="")
|
||||
mock_default = MagicMock(returncode=0, stdout="", stderr="")
|
||||
mock_subprocess.side_effect = [mock_list, mock_add, mock_default]
|
||||
configure_tea_login()
|
||||
assert mock_subprocess.call_count >= 2 # login list + login add + login default
|
||||
assert mock_subprocess.call_count == 3 # login list + login add + login default
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@@ -393,3 +460,37 @@ class TestConfigureTeaLogin:
|
||||
mock_subprocess.return_value = mock_list
|
||||
configure_tea_login()
|
||||
assert mock_subprocess.call_count == 1 # only login list, no add
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
def test_raises_on_login_add_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login raises TeaCLIError if tea login add fails."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_add = MagicMock(returncode=1, stdout="", stderr="invalid token")
|
||||
mock_subprocess.side_effect = [mock_list, mock_add]
|
||||
with pytest.raises(TeaCLIError, match="login add failed"):
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
def test_raises_on_login_default_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login raises TeaCLIError if tea login default fails."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="")
|
||||
mock_default = MagicMock(returncode=1, stdout="", stderr="login not found")
|
||||
mock_subprocess.side_effect = [mock_list, mock_add, mock_default]
|
||||
with pytest.raises(TeaCLIError, match="login default failed"):
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
def test_login_add_failure_includes_stdout(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""Error message includes stdout when tea writes errors there."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_add = MagicMock(returncode=1, stdout="Error: invalid username", stderr="")
|
||||
mock_subprocess.side_effect = [mock_list, mock_add]
|
||||
with pytest.raises(TeaCLIError, match="invalid username"):
|
||||
configure_tea_login()
|
||||
|
||||
@@ -66,7 +66,10 @@ class TestMain:
|
||||
def test_already_installed(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/checkmake"):
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/checkmake"),
|
||||
patch("devx.tools.install_checkmake._install_with_go"),
|
||||
):
|
||||
runner = CliRunner()
|
||||
runner.invoke(install_checkmake.cli, [])
|
||||
|
||||
@@ -98,7 +101,10 @@ class TestMain:
|
||||
with patch.object(install_checkmake, "TARGET_PATH", target):
|
||||
with patch("shutil.which", side_effect=[None, None]):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
with (
|
||||
patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve,
|
||||
patch("devx.tools.install_checkmake._install_with_go", return_value=False),
|
||||
):
|
||||
runner = CliRunner()
|
||||
runner.invoke(install_checkmake.cli, [])
|
||||
mock_retrieve.assert_called_once()
|
||||
|
||||
@@ -297,6 +297,47 @@ class TestInstallVale:
|
||||
assert (tmp_path / "vale").exists()
|
||||
|
||||
|
||||
class TestInstallPromtool:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_promtool() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake promtool"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
info = tarfile.TarInfo(name="promtool")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(install_tools, "_arch", return_value="amd64"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_promtool() is True
|
||||
assert (tmp_path / "promtool").exists()
|
||||
|
||||
def test_url_contains_version(self, tmp_path: Path) -> None:
|
||||
"""Verify the download URL includes the correct promtool version."""
|
||||
captured_url = []
|
||||
|
||||
def fake_extract(url: str, binary_name: str) -> Path:
|
||||
captured_url.append(url)
|
||||
return tmp_path / binary_name
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "_download_and_extract_tarball", side_effect=fake_extract):
|
||||
install_tools.install_promtool()
|
||||
assert any(f"v{install_tools.PROMTOOL_VERSION}" in url for url in captured_url)
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
@@ -341,6 +382,11 @@ class TestInstallTool:
|
||||
assert install_tools._install_tool("vale") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_promtool(self) -> None:
|
||||
with patch.object(install_tools, "install_promtool", return_value=True) as mock:
|
||||
assert install_tools._install_tool("promtool") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
@@ -359,7 +405,7 @@ class TestMain:
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 7
|
||||
assert mock_install.call_count == 8
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
@@ -87,14 +87,16 @@ class TestRunPlatform:
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_molecule_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.molecule.molecule_all._run_molecule")
|
||||
def test_molecule_not_found(self, mock_run: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(molecule_all.main, ["--bin", "nonexistent/bin"])
|
||||
assert result.exit_code != 0
|
||||
assert "molecule not found" in result.output
|
||||
|
||||
def test_role_dir_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.molecule.molecule_all._run_molecule")
|
||||
def test_role_dir_not_found(self, mock_run: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
@@ -104,7 +106,8 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "Role directory not found" in result.output
|
||||
|
||||
def test_role_dir_no_molecule(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.molecule.molecule_all._run_molecule")
|
||||
def test_role_dir_no_molecule(self, mock_run: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Roles dir exists but no role has molecule/ — should error."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
|
||||
@@ -165,6 +165,7 @@ class TestCli:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
@@ -193,6 +194,7 @@ class TestCli:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("devx.molecule.molecule_ci_guard.poll_for_other_failures") as mock_poll,
|
||||
patch("time.sleep"),
|
||||
@@ -212,8 +214,12 @@ class TestCli:
|
||||
"""Pair with fewer than 2 parts should raise."""
|
||||
from click.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["invalid_no_pipe"])
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen"),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["invalid_no_pipe"])
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid pair format" in result.output
|
||||
|
||||
@@ -222,6 +228,8 @@ class TestCli:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
@@ -253,6 +261,8 @@ class TestCli:
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
@@ -275,6 +285,8 @@ class TestCli:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("time.sleep", side_effect=KeyboardInterrupt),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
@@ -321,6 +333,7 @@ class TestCli:
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
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,
|
||||
@@ -358,6 +371,7 @@ class TestCli:
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
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)),
|
||||
@@ -404,6 +418,7 @@ class TestCli:
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
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,
|
||||
@@ -451,6 +466,7 @@ class TestCli:
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
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,
|
||||
@@ -546,6 +562,7 @@ class TestCliMultiRole:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
|
||||
@@ -9,9 +9,10 @@ from devx.gitea_cli import TeaCLIError, configure_tea_login
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
|
||||
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_tea.create_issue.return_value = {"index": 42, "title": "test"}
|
||||
@@ -36,9 +37,10 @@ class TestNotifyFailure:
|
||||
mock_tea.create_issue.assert_called_once()
|
||||
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
|
||||
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
|
||||
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
|
||||
mock_tea.create_issue.return_value = {"index": 43, "title": "test"}
|
||||
@@ -53,9 +55,10 @@ class TestNotifyFailure:
|
||||
assert "issue #43" in result.output
|
||||
mock_tea.add_label.assert_not_called()
|
||||
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
|
||||
def test_tea_error_raises(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
|
||||
"""When tea fails, the workflow fails — no fallback."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
@@ -70,9 +73,12 @@ class TestNotifyFailure:
|
||||
assert result.exit_code != 0
|
||||
assert "tea" in result.output.lower()
|
||||
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None:
|
||||
def test_tea_list_labels_error_continues_without_labels(
|
||||
self, mock_tea_cls: MagicMock, mock_login: MagicMock
|
||||
) -> None:
|
||||
"""If listing labels fails via tea, issue is still created without labels."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
@@ -87,9 +93,10 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
assert "issue #50" in result.output
|
||||
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None:
|
||||
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
|
||||
"""If adding label fails via tea, issue is still reported as created."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
@@ -105,8 +112,9 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
assert "issue #51" in result.output
|
||||
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
def test_missing_token_exits(self, mock_login: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
@@ -115,10 +123,13 @@ class TestNotifyFailure:
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value=None)
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
def test_auto_login_no_tea_skips(
|
||||
self, mock_tea_cls: MagicMock, mock_which: MagicMock, mock_login: MagicMock
|
||||
) -> None:
|
||||
"""--auto-login with tea not installed skips login and still creates issue."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = []
|
||||
@@ -133,10 +144,13 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
assert "issue #60" in result.output
|
||||
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
def test_auto_login_no_token_skips_login(
|
||||
self, mock_tea_cls: MagicMock, mock_which: MagicMock, mock_login: MagicMock
|
||||
) -> None:
|
||||
"""--auto-login with no CI_GITEA_TOKEN skips login but raises before creating issue."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
@@ -91,9 +91,14 @@ class TestResolveTaskId:
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_full_flow(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_full_flow(
|
||||
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
@@ -109,9 +114,14 @@ class TestMain:
|
||||
mock_client.post_comment.assert_called_once()
|
||||
mock_client.update_task.assert_called_once_with(267, done=True)
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_no_commit_sha(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_no_commit_sha(
|
||||
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
@@ -124,31 +134,47 @@ class TestMain:
|
||||
args, _ = mock_client.post_comment.call_args
|
||||
assert "unknown" in args[1]
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
def test_missing_token_exits(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code == 1
|
||||
assert "VIKUNJA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_task_id_non_release_fails(self) -> None:
|
||||
def test_no_task_id_non_release_fails(
|
||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
"""Non-release commits without DEVX-N prefix should fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["fix: resolve bug"])
|
||||
assert result.exit_code != 0
|
||||
assert "No task ID" in result.output
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_release_commit_without_task_id_skips(self) -> None:
|
||||
def test_release_commit_without_task_id_skips(
|
||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
"""Release commits without DEVX-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["release: v0.3.2"])
|
||||
assert result.exit_code == 0
|
||||
assert "skipping" in result.output
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_revert_commit_skips(self) -> None:
|
||||
def test_revert_commit_skips(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None:
|
||||
"""Revert commits without DEVX-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["revert: remove v0.6.0 release"])
|
||||
@@ -156,17 +182,25 @@ class TestMain:
|
||||
assert "Infrastructure commit" in result.output
|
||||
assert "skipping" in result.output
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_merge_commit_skips(self) -> None:
|
||||
def test_merge_commit_skips(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None:
|
||||
"""Merge commits without DEVX-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["Merge pull request #42"])
|
||||
assert result.exit_code == 0
|
||||
assert "Infrastructure commit" in result.output
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_resolve_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_resolve_failure_fails(
|
||||
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
"""Missing Vikunja task is a fatal error — every PR must have a task."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
@@ -176,9 +210,14 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "Could not find" in result.output
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_post_comment_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_post_comment_failure_fails(
|
||||
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
"""Vikunja API errors should fail — the task was not updated."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
@@ -191,9 +230,14 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "Vikunja API error" in result.output
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_mark_done_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_mark_done_failure_fails(
|
||||
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
"""Vikunja API errors should fail — the task was not updated."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
@@ -235,11 +279,14 @@ class TestGetGitCommitSha:
|
||||
|
||||
|
||||
class TestFromGit:
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug")
|
||||
def test_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock) -> None:
|
||||
def test_from_git(
|
||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
@@ -250,12 +297,13 @@ class TestFromGit:
|
||||
assert result.exit_code == 0
|
||||
assert "updated and marked done" in result.output
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug")
|
||||
def test_from_git_with_explicit_sha(
|
||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock
|
||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock, mock_subproc: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
@@ -266,8 +314,11 @@ class TestFromGit:
|
||||
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("devx.ci.post_merge.subprocess.run")
|
||||
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
||||
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_msg_and_no_from_git(self) -> None:
|
||||
def test_no_msg_and_no_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -11,7 +11,8 @@ from devx.tools.pr_label import cli
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_no_token_raises(self, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
runner = CliRunner()
|
||||
@@ -23,16 +24,20 @@ class TestCli:
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_label.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_owner_raises(self, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_adds_new_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_adds_new_label(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -44,8 +49,11 @@ class TestCli:
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
assert "Added label" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_skips_existing_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_skips_existing_label(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -57,8 +65,11 @@ class TestCli:
|
||||
client.add_pr_label.assert_not_called()
|
||||
assert "already" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_mixed_new_and_existing(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_mixed_new_and_existing(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
|
||||
@@ -152,7 +152,8 @@ class TestPrintLogs:
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_no_token_raises(self, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
runner = CliRunner()
|
||||
@@ -164,8 +165,9 @@ class TestCli:
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_logs.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_owner_raises(self, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
@@ -190,8 +192,11 @@ class TestCli:
|
||||
assert result.exit_code != 0
|
||||
assert "Fetching logs for PR #42" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_runs_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_runs_found(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -203,8 +208,11 @@ class TestCli:
|
||||
assert result.exit_code != 0
|
||||
assert "No workflow runs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_jobs(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -219,8 +227,11 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "No jobs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_failed_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_failed_jobs(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -237,8 +248,11 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "No failed jobs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_failed_job_logs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_failed_job_logs(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -266,8 +280,11 @@ class TestCli:
|
||||
assert "FAILED step #3" in result.output
|
||||
assert "error: test failed" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_specific_job(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_specific_job(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -286,8 +303,11 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "lint output here" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_job_not_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_job_not_found(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -304,8 +324,11 @@ class TestCli:
|
||||
assert result.exit_code != 0
|
||||
assert "No job matching" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_sha_raises(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
|
||||
@@ -121,7 +121,8 @@ class TestWaitForCompletion:
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_no_token_raises(self, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
runner = CliRunner()
|
||||
@@ -133,17 +134,23 @@ class TestCli:
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_status.REPO_OWNER", "")
|
||||
@patch("devx.tools.pr_status.get_repo_name", side_effect=Exception("should not reach"))
|
||||
def test_no_owner_raises(self, mock_repo_name: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_owner_raises(
|
||||
self, mock_repo_name: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_check_pr_status(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_check_pr_status(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -157,8 +164,11 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_check_sha_directly(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_check_sha_directly(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -171,8 +181,11 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_failure_raises_exception(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_failure_raises_exception(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -234,8 +247,11 @@ class TestCli:
|
||||
assert result.exit_code != 0
|
||||
assert "Could not detect" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_sha_raises(
|
||||
self, mock_client_cls: MagicMock, mock_subproc: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
@@ -246,11 +262,17 @@ class TestCli:
|
||||
assert result.exit_code != 0
|
||||
assert "SHA" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status._get_current_branch_pr")
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_wait_success(
|
||||
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
self,
|
||||
mock_client_cls: MagicMock,
|
||||
mock_time: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_branch_pr: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
@@ -266,11 +288,17 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status._get_current_branch_pr")
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_wait_timeout(
|
||||
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
self,
|
||||
mock_client_cls: MagicMock,
|
||||
mock_time: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_branch_pr: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
|
||||
@@ -97,9 +97,10 @@ class TestCli:
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("devx.tools.pre_push_check.get_current_branch")
|
||||
@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:
|
||||
def test_explicit_branch(self, mock_exists: MagicMock, mock_branch: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
+292
-18
@@ -165,6 +165,9 @@ class TestDefaultGiteaRegistryUrl:
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@@ -176,6 +179,9 @@ class TestMain:
|
||||
mock_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
@@ -190,6 +196,9 @@ class TestMain:
|
||||
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
||||
)
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@@ -201,6 +210,9 @@ class TestMain:
|
||||
mock_gitea_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
|
||||
mock_tea = MagicMock()
|
||||
@@ -213,6 +225,9 @@ class TestMain:
|
||||
mock_gitea_publish.assert_called_once()
|
||||
mock_tea.create_release.assert_called_once()
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@@ -224,6 +239,9 @@ class TestMain:
|
||||
mock_gitea_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""--registry-url flag publishes to the specified Gitea registry."""
|
||||
mock_tea = MagicMock()
|
||||
@@ -237,6 +255,9 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_gitea_publish.assert_called_once_with("https://custom.registry.com/pypi", "gitea-tok")
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CI_GITEA_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"},
|
||||
@@ -252,6 +273,9 @@ class TestMain:
|
||||
mock_gitea_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
|
||||
mock_tea = MagicMock()
|
||||
@@ -262,6 +286,9 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok")
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@@ -273,6 +300,9 @@ class TestMain:
|
||||
mock_build: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
|
||||
mock_tea = MagicMock()
|
||||
@@ -284,20 +314,33 @@ class TestMain:
|
||||
assert "PYPI_TOKEN not set" in result.output
|
||||
mock_tea.create_release.assert_called_once()
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
def test_missing_repo_token_exits(self) -> None:
|
||||
def test_missing_repo_token_exits(self, mock_run: MagicMock, mock_tag: MagicMock, mock_login: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_build_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
mock_build.side_effect = click.ClickException("build failed")
|
||||
runner = CliRunner()
|
||||
@@ -305,13 +348,23 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "build" in result.output
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_publish_failure_continues_to_gitea_release(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""PyPI publish failure is non-fatal — Gitea release is still created."""
|
||||
mock_tea = MagicMock()
|
||||
@@ -326,14 +379,27 @@ class TestMain:
|
||||
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
||||
)
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("time.sleep")
|
||||
def test_release_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""Release creation failure after retries raises ClickException."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
||||
@@ -342,13 +408,24 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "Release creation failed" in result.output
|
||||
# Retried 3 times (stop_after_attempt(3))
|
||||
assert mock_tea.create_release.call_count == 3
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_skip_build_skips_build_and_publish(
|
||||
self, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
|
||||
mock_tea = MagicMock()
|
||||
@@ -361,13 +438,23 @@ class TestMain:
|
||||
mock_build.assert_not_called()
|
||||
mock_tea.create_release.assert_called_once()
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_skips_release_creation_when_already_exists(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""If the Gitea release already exists, skip creation (idempotent)."""
|
||||
mock_tea = MagicMock()
|
||||
@@ -379,13 +466,23 @@ class TestMain:
|
||||
assert "already exists" in result.output
|
||||
mock_tea.create_release.assert_not_called()
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_proceeds_to_create_when_list_releases_fails(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""If list_releases raises TeaCLIError, proceed to create the release."""
|
||||
mock_tea = MagicMock()
|
||||
@@ -395,21 +492,29 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_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")
|
||||
@patch("time.sleep")
|
||||
def test_create_release_already_exists_is_idempotent(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_gitea_pub: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""If create_release fails with 'already exists', treat as success."""
|
||||
"""If create_release fails with 'already exists', treat as success (no retry)."""
|
||||
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")
|
||||
@@ -418,22 +523,32 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "already exists" in result.output
|
||||
# "already exists" is caught immediately — no retry
|
||||
assert mock_tea.create_release.call_count == 1
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_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")
|
||||
@patch("time.sleep")
|
||||
def test_create_release_other_error_raises(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_gitea_pub: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""If create_release fails with a non-'already exists' error, raise."""
|
||||
"""If create_release fails with a non-'already exists' error, raise after retries."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||
mock_tea.create_release.side_effect = TeaCLIError("network error")
|
||||
@@ -442,6 +557,75 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code != 0
|
||||
assert "Release creation failed" in result.output
|
||||
# Retried 3 times before giving up
|
||||
assert mock_tea.create_release.call_count == 3
|
||||
|
||||
|
||||
class TestReleaseRetry:
|
||||
"""Tests for retry logic on transient release creation failures."""
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("time.sleep")
|
||||
def test_transient_failure_retried_and_succeeds(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""Transient failure on first attempt succeeds on retry."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea.create_release.side_effect = [
|
||||
TeaCLIError("connection timeout"),
|
||||
None, # second attempt succeeds
|
||||
]
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
||||
assert result.exit_code == 0
|
||||
assert "Gitea release v1.0.0 created" in result.output
|
||||
assert mock_tea.create_release.call_count == 2
|
||||
mock_sleep.assert_called() # slept between attempts
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@patch("time.sleep")
|
||||
def test_all_retries_exhausted_raises(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_build: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
"""All 3 retry attempts fail — raises ClickException."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
mock_tea.create_release.side_effect = TeaCLIError("503 service unavailable")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
||||
assert result.exit_code == 1
|
||||
assert "Release creation failed" in result.output
|
||||
assert mock_tea.create_release.call_count == 3
|
||||
assert mock_sleep.call_count == 2 # slept between 3 attempts (2 sleeps)
|
||||
|
||||
|
||||
class TestFromTag:
|
||||
@@ -487,40 +671,74 @@ class TestFromTag:
|
||||
result = is_release_commit("v1.2.3")
|
||||
assert result is False
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||
def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None:
|
||||
def test_from_tag_no_tag_skips(self, _mock: MagicMock, mock_run: MagicMock, mock_login: 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.subprocess.run")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock) -> None:
|
||||
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock, mock_run: MagicMock, mock_login: 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.subprocess.run")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock) -> None:
|
||||
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock, mock_run: MagicMock, mock_login: 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.subprocess.run")
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@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:
|
||||
def test_from_tag_not_release_commit_skips(
|
||||
self,
|
||||
_mock_tag: MagicMock,
|
||||
_mock_rel: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_pypi: MagicMock,
|
||||
mock_gitea_reg: MagicMock,
|
||||
mock_login: 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.subprocess.run")
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@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:
|
||||
def test_from_tag_publishes(
|
||||
self,
|
||||
_mock_tag: MagicMock,
|
||||
_mock_rel: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_pypi: MagicMock,
|
||||
mock_gitea_reg: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
|
||||
mock_tea = MagicMock()
|
||||
@@ -532,9 +750,23 @@ class TestFromTag:
|
||||
assert result.exit_code == 0
|
||||
assert "Publishing release v1.0.0" in result.output
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
@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:
|
||||
def test_from_tag_publishes_no_repo_arg(
|
||||
self,
|
||||
_mock_tag: MagicMock,
|
||||
_mock_rel: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_pypi: MagicMock,
|
||||
mock_gitea_reg: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
|
||||
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
|
||||
mock_tea = MagicMock()
|
||||
@@ -546,7 +778,21 @@ class TestFromTag:
|
||||
assert result.exit_code == 0
|
||||
assert "Publishing release v1.0.0" in result.output
|
||||
|
||||
def test_no_tag_no_from_tag_raises(self) -> None:
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch("devx.gitea_cli.configure_tea_login")
|
||||
def test_no_tag_no_from_tag_raises(
|
||||
self,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_pypi: MagicMock,
|
||||
mock_gitea_reg: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
|
||||
assert result.exit_code != 0
|
||||
@@ -556,10 +802,24 @@ class TestFromTag:
|
||||
class TestPublishAutoLogin:
|
||||
"""Tests for --auto-login flag in publish."""
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.publish.configure_tea_login")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
def test_auto_login_calls_configure(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
|
||||
def test_auto_login_calls_configure(
|
||||
self,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_pypi: MagicMock,
|
||||
mock_gitea_reg: MagicMock,
|
||||
) -> None:
|
||||
"""--auto-login calls configure_tea_login before creating release."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
@@ -571,10 +831,24 @@ class TestPublishAutoLogin:
|
||||
assert result.exit_code == 0
|
||||
mock_login.assert_called_once()
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.publish.configure_tea_login")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
def test_no_auto_login_skips_configure(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
|
||||
def test_no_auto_login_skips_configure(
|
||||
self,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_login: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
mock_pypi: MagicMock,
|
||||
mock_gitea_reg: MagicMock,
|
||||
) -> None:
|
||||
"""Without --auto-login, configure_tea_login is not called."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
|
||||
@@ -278,9 +278,10 @@ class TestRebaseTool:
|
||||
class TestPrRebaseTool:
|
||||
"""Tests for the server-side PR rebase tool (devx.tools.pr_rebase)."""
|
||||
|
||||
@patch("devx.tools._shared.detect_pr_number")
|
||||
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||
def test_pr_rebase_success(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_pr_rebase_success(self, mock_client_cls: MagicMock, mock_detect: MagicMock) -> None:
|
||||
"""Successful API rebase prints confirmation."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
@@ -291,9 +292,10 @@ class TestPrRebaseTool:
|
||||
assert "rebased successfully" in result.output.lower()
|
||||
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
|
||||
|
||||
@patch("devx.tools._shared.detect_pr_number")
|
||||
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
||||
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||
def test_pr_rebase_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_pr_rebase_api_error(self, mock_client_cls: MagicMock, mock_detect: MagicMock) -> None:
|
||||
"""API error during rebase exits with error."""
|
||||
from devx.api_clients import APIError
|
||||
|
||||
@@ -306,9 +308,10 @@ class TestPrRebaseTool:
|
||||
assert result.exit_code != 0
|
||||
assert "rebase failed" in result.output.lower()
|
||||
|
||||
@patch("devx.tools._shared.detect_pr_number")
|
||||
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_pr_rebase_no_token(self, _mock_load: MagicMock) -> None:
|
||||
def test_pr_rebase_no_token(self, _mock_load: MagicMock, mock_detect: MagicMock) -> None:
|
||||
"""Missing CI_GITEA_TOKEN should fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||
@@ -324,20 +327,26 @@ class TestPrRebaseTool:
|
||||
assert result.exit_code != 0
|
||||
assert "could not detect" in result.output.lower()
|
||||
|
||||
@patch("devx.tools._shared.detect_pr_number")
|
||||
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||
def test_pr_rebase_no_repo_env(self, _mock_client: MagicMock, _mock_load: MagicMock) -> None:
|
||||
def test_pr_rebase_no_repo_env(
|
||||
self, _mock_client: MagicMock, _mock_load: MagicMock, mock_detect: MagicMock
|
||||
) -> None:
|
||||
"""Missing repo env vars should fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "DEVX_REPO_OWNER" in result.output
|
||||
|
||||
@patch("devx.tools._shared.detect_pr_number")
|
||||
@patch("devx.tools.pr_rebase.load_dotenv")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
|
||||
@patch("devx.tools.pr_rebase.GiteaClient")
|
||||
def test_pr_rebase_github_repo_fallback(self, mock_client_cls: MagicMock, _mock_load: MagicMock) -> None:
|
||||
def test_pr_rebase_github_repo_fallback(
|
||||
self, mock_client_cls: MagicMock, _mock_load: MagicMock, mock_detect: MagicMock
|
||||
) -> None:
|
||||
"""GITHUB_REPOSITORY env var is used as fallback for owner/repo."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
+68
-13
@@ -952,22 +952,32 @@ class TestMain:
|
||||
verify specific git call sequences mock run_cmd with side_effect.
|
||||
"""
|
||||
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None:
|
||||
def test_not_on_master_exits(
|
||||
self, mock_run_cmd: MagicMock, mock_update_docs: MagicMock, mock_ufc: MagicMock
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "master" in result.output
|
||||
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.5.0")
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_dry_run_on_non_master_warns(
|
||||
self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: MagicMock, mock_glt: MagicMock
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_uf: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_glt: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
) -> None:
|
||||
"""Dry-run mode should not fail on non-master branches."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
||||
@@ -976,6 +986,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "Dry-run mode" in result.output
|
||||
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_head_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@@ -991,6 +1002,7 @@ class TestMain:
|
||||
mock_vtc: MagicMock,
|
||||
mock_tc: MagicMock,
|
||||
mock_hc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit and the tag exists, skip."""
|
||||
mock_run_cmd.side_effect = [
|
||||
@@ -1004,6 +1016,7 @@ class TestMain:
|
||||
assert "already a release commit" in result.output
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_head_commit", return_value="def456")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@@ -1019,6 +1032,7 @@ class TestMain:
|
||||
mock_vtc: MagicMock,
|
||||
mock_tc: MagicMock,
|
||||
mock_hc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit but tag points elsewhere, error."""
|
||||
mock_run_cmd.side_effect = [
|
||||
@@ -1031,6 +1045,8 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "misalignment" in result.output
|
||||
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@@ -1044,6 +1060,8 @@ class TestMain:
|
||||
mock_changelog: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_ufc: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit but the tag is missing, create the tag."""
|
||||
mock_run_cmd.side_effect = [
|
||||
@@ -1058,21 +1076,25 @@ class TestMain:
|
||||
assert "Recovering" in result.output
|
||||
mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False)
|
||||
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=False)
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_no_unreleased_changes(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -1080,6 +1102,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "No unreleased changes" in result.output
|
||||
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@@ -1105,6 +1128,7 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
) -> None:
|
||||
"""Empty changelog should fail, not warn."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -1113,6 +1137,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "empty changelog" in result.output.lower()
|
||||
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@@ -1138,6 +1163,7 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -1149,6 +1175,7 @@ class TestMain:
|
||||
mock_commit.assert_not_called()
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@@ -1162,6 +1189,7 @@ class TestMain:
|
||||
mock_latest: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
) -> None:
|
||||
"""Release is skipped when only workflow/infrastructure files changed."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -1171,11 +1199,11 @@ class TestMain:
|
||||
assert "No user-facing changes" in result.output
|
||||
assert "Skipping release" in result.output
|
||||
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@@ -1184,10 +1212,12 @@ class TestMain:
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_full_flow(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
@@ -1196,10 +1226,10 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -1216,7 +1246,6 @@ class TestMain:
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=False)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=False)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@@ -1225,10 +1254,12 @@ class TestMain:
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_full_flow_tag_exists(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
@@ -1237,7 +1268,6 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
@@ -1250,11 +1280,11 @@ class TestMain:
|
||||
assert "already existed" in result.output
|
||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
||||
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@@ -1263,12 +1293,14 @@ class TestMain:
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch("devx.ci.release.time.sleep")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_push_retry_succeeds_after_rebase_failure(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
@@ -1277,10 +1309,10 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
) -> None:
|
||||
"""Push should retry after rebase failure and succeed on second attempt."""
|
||||
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -1297,11 +1329,11 @@ class TestMain:
|
||||
assert "Rebase attempt 1/3 failed" in result.output
|
||||
assert "Pushed release commit to master" in result.output
|
||||
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@@ -1310,12 +1342,14 @@ class TestMain:
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch("devx.ci.release.time.sleep")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_push_fails_after_all_retries(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
@@ -1324,10 +1358,10 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
) -> None:
|
||||
"""Push should fail after 3 unsuccessful rebase attempts."""
|
||||
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -1350,11 +1384,11 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to push release commit after 3 attempts" in result.output
|
||||
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_changelog")
|
||||
@@ -1363,12 +1397,14 @@ class TestMain:
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch("devx.ci.release.time.sleep")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_push_retry_succeeds_after_push_failure(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
@@ -1377,10 +1413,10 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
) -> None:
|
||||
"""Push should retry after push rejection and succeed on second attempt."""
|
||||
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -1397,6 +1433,7 @@ class TestMain:
|
||||
assert "Push attempt 1/3 failed" in result.output
|
||||
assert "Pushed release commit to master" in result.output
|
||||
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@@ -1414,6 +1451,7 @@ class TestMain:
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
) -> None:
|
||||
"""Release is skipped when git-cliff doesn't bump the version."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -1435,10 +1473,12 @@ class TestMain:
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_full_flow_skip_tests(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
@@ -1473,10 +1513,12 @@ class TestMain:
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tests_fail_aborts_before_tag(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
@@ -1517,10 +1559,12 @@ class TestMain:
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_lint_fail_aborts_before_tag(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_bumped: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
@@ -1548,6 +1592,8 @@ class TestMain:
|
||||
mock_commit.assert_not_called()
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_changelog_versions", return_value=[])
|
||||
@patch("devx.ci.release.get_init_version", return_value="0.1.0")
|
||||
@@ -1561,6 +1607,8 @@ class TestMain:
|
||||
mock_tags: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_ufc: MagicMock,
|
||||
) -> None:
|
||||
"""--verify checks alignment and exits without releasing."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
@@ -1569,12 +1617,19 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "Release Alignment Verification" in result.output
|
||||
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.update_doc_versions")
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[" v0.1.0 → bad"])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_preflight_tag_consistency_fails(
|
||||
self, mock_run_cmd: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_update_docs: MagicMock,
|
||||
mock_ufc: MagicMock,
|
||||
) -> None:
|
||||
"""Pre-flight tag consistency check aborts if tags are misaligned."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
|
||||
@@ -312,7 +312,13 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_tea.assert_not_called()
|
||||
|
||||
def test_main_missing_bin_dir(self) -> None:
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("devx.tools.setup._configure_tea_login")
|
||||
@patch("devx.tools.setup._install_python_deps")
|
||||
@patch("devx.tools.setup._verify")
|
||||
def test_main_missing_bin_dir(
|
||||
self, mock_tea: MagicMock, mock_deps: MagicMock, mock_verify: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--bin", "/nonexistent/path"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -185,12 +185,14 @@ class TestFallbackToSetupCi:
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.setup_image._fallback_to_setup_ci")
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_with_opt_venv_present(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
mock_fallback: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = True
|
||||
runner = CliRunner()
|
||||
@@ -198,12 +200,14 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
mock_install.assert_called_once()
|
||||
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image._fallback_to_setup_ci")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_falls_back_when_no_opt_venv(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_fallback: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = False
|
||||
runner = CliRunner()
|
||||
@@ -211,12 +215,14 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
mock_fallback.assert_called_once()
|
||||
|
||||
@patch("devx.tools.setup_image._fallback_to_setup_ci")
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_default_values(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
mock_fallback: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = True
|
||||
runner = CliRunner()
|
||||
@@ -229,12 +235,14 @@ class TestCli:
|
||||
assert call_args[3] == "git.oblachno.oblachno.fyi"
|
||||
assert call_args[4] == "oblachno-oss"
|
||||
|
||||
@patch("devx.tools.setup_image._fallback_to_setup_ci")
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_custom_venv_and_gitea(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
mock_fallback: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = True
|
||||
runner = CliRunner()
|
||||
@@ -248,12 +256,14 @@ class TestCli:
|
||||
assert call_args[3] == "gitea.io"
|
||||
assert call_args[4] == "myorg"
|
||||
|
||||
@patch("devx.tools.setup_image._fallback_to_setup_ci")
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_with_extras(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
mock_fallback: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = True
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -255,7 +255,17 @@ class TestCommitAndPush:
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_no_token_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.ci.sync_wiki.commit_and_push")
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki")
|
||||
def test_no_token_raises(
|
||||
self,
|
||||
mock_clone: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
for name in ("CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
runner = CliRunner()
|
||||
@@ -263,7 +273,17 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
def test_no_mapping_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@patch("devx.ci.sync_wiki.commit_and_push")
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki")
|
||||
def test_no_mapping_raises(
|
||||
self,
|
||||
mock_clone: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "fake")
|
||||
monkeypatch.setattr("devx.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json")
|
||||
runner = CliRunner()
|
||||
@@ -271,6 +291,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "mapping.json" in result.output
|
||||
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
|
||||
@@ -279,6 +300,7 @@ class TestMain:
|
||||
mock_sync: MagicMock,
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -296,6 +318,7 @@ class TestMain:
|
||||
assert "dry-run" in result.output
|
||||
mock_push.assert_not_called()
|
||||
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.sync_files", return_value=(2, 0))
|
||||
@@ -304,6 +327,7 @@ class TestMain:
|
||||
mock_sync: MagicMock,
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -348,6 +372,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_init.assert_called_once()
|
||||
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.time.sleep")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki")
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@@ -358,6 +383,7 @@ class TestMain:
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -383,6 +409,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "Verification" in result.output
|
||||
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=False)
|
||||
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
|
||||
@@ -391,6 +418,7 @@ class TestMain:
|
||||
mock_sync: MagicMock,
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -407,6 +435,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "No push needed" in result.output
|
||||
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.time.sleep")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki", side_effect=[True, False])
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@@ -417,6 +446,7 @@ class TestMain:
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -433,6 +463,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "could not clone" in result.output
|
||||
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.time.sleep")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki")
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@@ -443,6 +474,7 @@ class TestMain:
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -467,6 +499,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "page(s) missing" in result.output
|
||||
|
||||
@patch("devx.ci.sync_wiki.init_wiki")
|
||||
@patch("devx.ci.sync_wiki.clone_wiki", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.commit_and_push", return_value=True)
|
||||
@patch("devx.ci.sync_wiki.sync_files", return_value=(1, 0))
|
||||
@@ -475,6 +508,7 @@ class TestMain:
|
||||
mock_sync: MagicMock,
|
||||
mock_push: MagicMock,
|
||||
mock_clone: MagicMock,
|
||||
mock_init: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -70,7 +70,8 @@ class TestMain:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
def test_rejects_task_id_on_feature_branch(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_rejects_task_id_on_feature_branch(self, mock_commit: MagicMock) -> None:
|
||||
msg_path = self._write_msg("DEVX-19: feat: add feature")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="DEVX-19"):
|
||||
runner = CliRunner()
|
||||
@@ -78,21 +79,24 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "task ID" in result.output
|
||||
|
||||
def test_accepts_conventional_on_feature_branch(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_accepts_conventional_on_feature_branch(self, mock_commit: MagicMock) -> None:
|
||||
msg_path = self._write_msg("feat: add feature")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="DEVX-19"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_accepts_valid_master_commit(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_accepts_valid_master_commit(self, mock_commit: MagicMock) -> None:
|
||||
msg_path = self._write_msg("DEVX-19: feat: add feature")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_rejects_master_without_task_id(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_rejects_master_without_task_id(self, mock_commit: MagicMock) -> None:
|
||||
msg_path = self._write_msg("feat: add feature")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
@@ -100,7 +104,8 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "task ID" in result.output
|
||||
|
||||
def test_rejects_master_with_non_conventional_after_task_id(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_rejects_master_with_non_conventional_after_task_id(self, mock_commit: MagicMock) -> None:
|
||||
msg_path = self._write_msg("DEVX-19: random message")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
@@ -108,7 +113,8 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "conventional" in result.output
|
||||
|
||||
def test_rejects_non_conventional_on_feature_branch(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_rejects_non_conventional_on_feature_branch(self, mock_commit: MagicMock) -> None:
|
||||
msg_path = self._write_msg("random message")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature"):
|
||||
runner = CliRunner()
|
||||
@@ -116,26 +122,33 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "conventional" in result.output
|
||||
|
||||
def test_accepts_multiline_conventional(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_accepts_multiline_conventional(self, mock_commit: MagicMock) -> None:
|
||||
msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_usage_message_without_args(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_branch")
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_usage_message_without_args(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_branch_override_accepts_master_commit(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_branch")
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_branch_override_accepts_master_commit(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None:
|
||||
"""--branch master overrides branch detection (for CI use)."""
|
||||
msg_path = self._write_msg("DEVX-19: feat: add feature")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path, "--branch", "master"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_branch_override_rejects_missing_task_id(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_branch")
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_branch_override_rejects_missing_task_id(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None:
|
||||
"""--branch master still enforces DEVX-N: prefix."""
|
||||
msg_path = self._write_msg("feat: add feature")
|
||||
runner = CliRunner()
|
||||
@@ -143,7 +156,9 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "task ID" in result.output
|
||||
|
||||
def test_branch_override_feature_accepts_conventional(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_branch")
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_branch_override_feature_accepts_conventional(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None:
|
||||
"""--branch feature still rejects DEVX-N prefix."""
|
||||
msg_path = self._write_msg("DEVX-19: feat: add feature")
|
||||
runner = CliRunner()
|
||||
@@ -166,8 +181,9 @@ class TestCustomPrefix:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"})
|
||||
def test_master_accepts_proj_prefix(self) -> None:
|
||||
def test_master_accepts_proj_prefix(self, mock_commit: MagicMock) -> None:
|
||||
"""Master branch accepts PROJ-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
@@ -188,8 +204,9 @@ class TestCustomPrefix:
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"})
|
||||
def test_master_rejects_devx_prefix_when_proj_configured(self) -> None:
|
||||
def test_master_rejects_devx_prefix_when_proj_configured(self, mock_commit: MagicMock) -> None:
|
||||
"""Master branch rejects DEVX-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
@@ -211,8 +228,9 @@ class TestCustomPrefix:
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"})
|
||||
def test_feature_branch_rejects_proj_prefix(self) -> None:
|
||||
def test_feature_branch_rejects_proj_prefix(self, mock_commit: MagicMock) -> None:
|
||||
"""Feature branch rejects PROJ-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
@@ -283,7 +301,9 @@ class TestGitMode:
|
||||
result = runner.invoke(main, ["--git", "--branch", "master"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_no_file_no_git_raises(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_branch")
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_no_file_no_git_raises(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--branch", "master"])
|
||||
assert result.exit_code != 0
|
||||
@@ -294,7 +314,8 @@ class TestGitMode:
|
||||
result = get_latest_commit_msg()
|
||||
assert result == "feat: test\n\nBody"
|
||||
|
||||
def test_stdin_input(self) -> None:
|
||||
@patch("devx.ci.validate_commit_msg.get_latest_commit_msg")
|
||||
def test_stdin_input(self, mock_commit: MagicMock) -> 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"])
|
||||
|
||||
@@ -27,13 +27,15 @@ class TestValidateDeployRef:
|
||||
assert result.exit_code == 1
|
||||
assert "does not exist" in result.output
|
||||
|
||||
def test_no_tag_without_allow_empty_exits_nonzero(self) -> None:
|
||||
@patch("devx.ci.validate_deploy_ref.subprocess.run")
|
||||
def test_no_tag_without_allow_empty_exits_nonzero(self, mock_subproc: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 1
|
||||
assert "No tag specified" in result.output
|
||||
|
||||
def test_allow_empty_prints_pr_mode(self) -> None:
|
||||
@patch("devx.ci.validate_deploy_ref.subprocess.run")
|
||||
def test_allow_empty_prints_pr_mode(self, mock_subproc: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--allow-empty"])
|
||||
assert result.exit_code == 0
|
||||
@@ -60,7 +62,8 @@ class TestValidateDeployRef:
|
||||
assert result.exit_code == 1
|
||||
assert "GITHUB_OUTPUT" in result.output
|
||||
|
||||
def test_allow_empty_with_github_output(self, tmp_path: Path) -> None:
|
||||
@patch("devx.ci.validate_deploy_ref.subprocess.run")
|
||||
def test_allow_empty_with_github_output(self, mock_subproc: MagicMock, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
gh_output = tmp_path / "github_output"
|
||||
gh_output.write_text("")
|
||||
|
||||
Reference in New Issue
Block a user