Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f982dc3ba | ||
|
|
a7a8637244 | ||
|
|
cdf3408a35 | ||
|
|
8fcac10286 | ||
|
|
c62c560c85 | ||
|
|
08b781f978 |
@@ -71,7 +71,7 @@ src/devx/
|
|||||||
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
|
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
|
||||||
├── ci/ # CI/CD automation modules (run by workflows)
|
├── ci/ # CI/CD automation modules (run by workflows)
|
||||||
│ ├── release.py # Automated versioning, tagging, changelog
|
│ ├── 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
|
│ ├── 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)
|
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
|
||||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
│ ├── _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_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
|
||||||
- `create_release()` / `list_releases()` — Release management
|
- `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
|
### git-cliff Commit Preprocessing
|
||||||
|
|
||||||
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [0.46.0] - 2026-07-14
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](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/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.46.0",
|
"devx>=0.47.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (for example, `"devx==0.46.0"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.47.1"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.46.0,<0.47"`).
|
> (for example, `"devx>=0.47.1,<0.48"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.46.0",
|
"devx>=0.47.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin a specific version if needed: `"devx==0.46.0"` or `"devx>=0.46.0,<0.47"`.
|
Pin a specific version if needed: `"devx==0.47.1"` or `"devx>=0.47.1,<0.48"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.46.0",
|
"devx>=0.47.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.46.0",
|
"devx>=0.47.1",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.46.0"
|
__version__ = "0.47.1"
|
||||||
|
|||||||
+37
-7
@@ -4,6 +4,10 @@
|
|||||||
Uses git-cliff to generate the release notes from conventional commits.
|
Uses git-cliff to generate the release notes from conventional commits.
|
||||||
Uses the ``tea`` Gitea CLI for release creation.
|
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):
|
Publishing destinations (checked in order):
|
||||||
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
|
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
|
||||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||||
@@ -27,6 +31,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
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.config import GITEA_API_URL, REPO_OWNER
|
||||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||||
@@ -312,13 +317,7 @@ def main(
|
|||||||
|
|
||||||
release_body = generate_release_notes(tag)
|
release_body = generate_release_notes(tag)
|
||||||
|
|
||||||
try:
|
_create_release_with_retry(tea, repo, tag, release_body)
|
||||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
|
||||||
except TeaCLIError as e:
|
|
||||||
if "already" in str(e).lower() and "release" in str(e).lower():
|
|
||||||
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
|
||||||
return
|
|
||||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
|
||||||
|
|
||||||
click.echo(
|
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
|
if __name__ == "__main__": # pragma: no cover
|
||||||
main()
|
main()
|
||||||
|
|||||||
+28
-5
@@ -61,6 +61,11 @@ def configure_tea_login(login_name: str = "devx") -> None:
|
|||||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
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.
|
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
|
Used by CI scripts (publish, notify_failure) that need tea login but
|
||||||
run in containerized environments where ``make setup`` was not called.
|
run in containerized environments where ``make setup`` was not called.
|
||||||
"""
|
"""
|
||||||
@@ -88,18 +93,31 @@ def configure_tea_login(login_name: str = "devx") -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url))
|
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],
|
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
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],
|
[tea_bin, "login", "default", login_name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
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:
|
class TeaCLI:
|
||||||
@@ -145,9 +163,14 @@ class TeaCLI:
|
|||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
|
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise TeaCLIError(
|
# tea writes some errors to stdout (for example, "no available
|
||||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
# login"), so include both stdout and stderr for debugging.
|
||||||
)
|
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 "",
|
||||||
|
]
|
||||||
|
raise TeaCLIError("\n".join(p for p in parts if p))
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
def _run_raw(self, args: list[str]) -> str:
|
def _run_raw(self, args: list[str]) -> str:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Handles installation of:
|
|||||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||||
- hadolint (Dockerfile linter)
|
- hadolint (Dockerfile linter)
|
||||||
- vale (prose linter for documentation quality)
|
- vale (prose linter for documentation quality)
|
||||||
|
- promtool (Prometheus rule validator)
|
||||||
|
|
||||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||||
Idempotent: skips tools that are already available.
|
Idempotent: skips tools that are already available.
|
||||||
@@ -47,6 +48,8 @@ TOFU_VERSION = "1.12.3"
|
|||||||
|
|
||||||
VALE_VERSION = "3.15.1"
|
VALE_VERSION = "3.15.1"
|
||||||
|
|
||||||
|
PROMTOOL_VERSION = "3.5.5"
|
||||||
|
|
||||||
|
|
||||||
def _arch() -> str:
|
def _arch() -> str:
|
||||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||||
@@ -212,7 +215,26 @@ def install_vale() -> bool:
|
|||||||
return True
|
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:
|
def _install_tool(name: str) -> bool:
|
||||||
@@ -231,6 +253,8 @@ def _install_tool(name: str) -> bool:
|
|||||||
return install_tofu()
|
return install_tofu()
|
||||||
if name == "vale":
|
if name == "vale":
|
||||||
return install_vale()
|
return install_vale()
|
||||||
|
if name == "promtool":
|
||||||
|
return install_promtool()
|
||||||
raise click.ClickException(f"Unknown tool: {name}")
|
raise click.ClickException(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for scripts/gitea_cli.py."""
|
"""Unit tests for devx/gitea_cli.py."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -77,6 +77,25 @@ class TestTeaCLIRun:
|
|||||||
with pytest.raises(TeaCLIError, match="auth error"):
|
with pytest.raises(TeaCLIError, match="auth error"):
|
||||||
cli._run(["labels", "list"])
|
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:
|
def test_run_tea_not_found_raises_tea_error(self) -> None:
|
||||||
cli = TeaCLI(tea_bin="tea")
|
cli = TeaCLI(tea_bin="tea")
|
||||||
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
|
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
|
||||||
@@ -380,9 +399,11 @@ class TestConfigureTeaLogin:
|
|||||||
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||||
"""configure_tea_login adds login when not already configured."""
|
"""configure_tea_login adds login when not already configured."""
|
||||||
mock_list = MagicMock(returncode=0, stdout="")
|
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()
|
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.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||||
@@ -393,3 +414,37 @@ class TestConfigureTeaLogin:
|
|||||||
mock_subprocess.return_value = mock_list
|
mock_subprocess.return_value = mock_list
|
||||||
configure_tea_login()
|
configure_tea_login()
|
||||||
assert mock_subprocess.call_count == 1 # only login list, no add
|
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()
|
||||||
|
|||||||
@@ -297,6 +297,47 @@ class TestInstallVale:
|
|||||||
assert (tmp_path / "vale").exists()
|
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:
|
class TestListTools:
|
||||||
def test_list(self, tmp_path: Path) -> None:
|
def test_list(self, tmp_path: Path) -> None:
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||||
@@ -341,6 +382,11 @@ class TestInstallTool:
|
|||||||
assert install_tools._install_tool("vale") is True
|
assert install_tools._install_tool("vale") is True
|
||||||
mock.assert_called_once()
|
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:
|
def test_unknown_tool(self) -> None:
|
||||||
with pytest.raises(ClickException, match="Unknown tool"):
|
with pytest.raises(ClickException, match="Unknown tool"):
|
||||||
install_tools._install_tool("unknown")
|
install_tools._install_tool("unknown")
|
||||||
@@ -359,7 +405,7 @@ class TestMain:
|
|||||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||||
result = runner.invoke(install_tools.main, [])
|
result = runner.invoke(install_tools.main, [])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_install.call_count == 7
|
assert mock_install.call_count == 8
|
||||||
|
|
||||||
def test_install_specific_tool(self) -> None:
|
def test_install_specific_tool(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -387,8 +387,10 @@ class TestMain:
|
|||||||
@patch("devx.ci.publish.TeaCLI")
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
|
@patch("time.sleep")
|
||||||
def test_release_failure_raises_click(
|
def test_release_failure_raises_click(
|
||||||
self,
|
self,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
mock_build: MagicMock,
|
mock_build: MagicMock,
|
||||||
mock_publish: MagicMock,
|
mock_publish: MagicMock,
|
||||||
mock_tea_cls: MagicMock,
|
mock_tea_cls: MagicMock,
|
||||||
@@ -397,6 +399,7 @@ class TestMain:
|
|||||||
mock_tag: MagicMock,
|
mock_tag: MagicMock,
|
||||||
mock_login: MagicMock,
|
mock_login: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Release creation failure after retries raises ClickException."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
mock_tea.list_releases.return_value = []
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
||||||
@@ -405,6 +408,8 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Release creation failed" in result.output
|
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.subprocess.run")
|
||||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||||
@@ -496,8 +501,10 @@ class TestMain:
|
|||||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
|
@patch("time.sleep")
|
||||||
def test_create_release_already_exists_is_idempotent(
|
def test_create_release_already_exists_is_idempotent(
|
||||||
self,
|
self,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
mock_build: MagicMock,
|
mock_build: MagicMock,
|
||||||
mock_publish: MagicMock,
|
mock_publish: MagicMock,
|
||||||
mock_gitea_pub: MagicMock,
|
mock_gitea_pub: MagicMock,
|
||||||
@@ -507,7 +514,7 @@ class TestMain:
|
|||||||
mock_tag: MagicMock,
|
mock_tag: MagicMock,
|
||||||
mock_login: MagicMock,
|
mock_login: MagicMock,
|
||||||
) -> None:
|
) -> 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 = MagicMock()
|
||||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||||
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
|
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
|
||||||
@@ -516,6 +523,8 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "already exists" in result.output
|
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.subprocess.run")
|
||||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
||||||
@@ -526,8 +535,10 @@ class TestMain:
|
|||||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
|
@patch("time.sleep")
|
||||||
def test_create_release_other_error_raises(
|
def test_create_release_other_error_raises(
|
||||||
self,
|
self,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
mock_build: MagicMock,
|
mock_build: MagicMock,
|
||||||
mock_publish: MagicMock,
|
mock_publish: MagicMock,
|
||||||
mock_gitea_pub: MagicMock,
|
mock_gitea_pub: MagicMock,
|
||||||
@@ -537,7 +548,7 @@ class TestMain:
|
|||||||
mock_tag: MagicMock,
|
mock_tag: MagicMock,
|
||||||
mock_login: MagicMock,
|
mock_login: MagicMock,
|
||||||
) -> None:
|
) -> 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 = MagicMock()
|
||||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||||
mock_tea.create_release.side_effect = TeaCLIError("network error")
|
mock_tea.create_release.side_effect = TeaCLIError("network error")
|
||||||
@@ -546,6 +557,75 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "Release creation failed" in result.output
|
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:
|
class TestFromTag:
|
||||||
|
|||||||
Reference in New Issue
Block a user