Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f382408115 | ||
|
|
19bec24f45 | ||
|
|
37772f21a9 | ||
|
|
b07132e3c6 | ||
|
|
7b3b604c2c | ||
|
|
53990dc10c | ||
|
|
8ea044a942 | ||
|
|
8411c92c95 | ||
|
|
621b9936c8 | ||
|
|
33063038a1 |
+59
-19
@@ -2,15 +2,65 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
## [0.5.0] - 2026-06-23
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
### Features
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
- Add tag verification, idempotency, and --verify mode to release script
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
## [0.4.4] - 2026-06-22
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
### Bug Fixes
|
||||
|
||||
- Configurable task prefix and CWD-relative DOCS_DIR
|
||||
|
||||
## [0.4.3] - 2026-06-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Expand DEFAULT_INFRASTRUCTURE to cover all common project files
|
||||
|
||||
## [0.4.2] - 2026-06-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make all warnings into errors across devx tools
|
||||
|
||||
## [0.4.1] - 2026-06-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct version tags, changelog, and release script recovery
|
||||
|
||||
## [0.4.0] - 2026-06-22
|
||||
|
||||
### Features
|
||||
|
||||
- Add DEFAULT_INFRASTRUCTURE and configurable task prefix
|
||||
|
||||
## [0.3.0] - 2026-06-22
|
||||
|
||||
### Features
|
||||
|
||||
- Add --no-ansible-collections option to setup tool
|
||||
|
||||
## [0.2.0] - 2026-06-22
|
||||
|
||||
### Features
|
||||
|
||||
- Pluggable change classification framework
|
||||
|
||||
## [0.1.2] - 2026-06-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make sync-wiki and vikunja depend on release
|
||||
|
||||
## [0.1.1] - 2026-06-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Disable push whitelist, allow direct pushes to master
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
|
||||
@@ -18,18 +68,8 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
- Extract reusable dev/CI tools from GRM into devx package
|
||||
|
||||
## [unreleased]
|
||||
|
||||
### Features
|
||||
|
||||
- Extract reusable development and CI/CD tools from GRM into a standalone Python package
|
||||
- Port core modules: config, exceptions, i18n, api_clients, gitea_cli
|
||||
- Port 14 CI scripts: auto_merge, check_translations, classify_changes, detect_release_commit, discover_runners, doc_coverage, notify_failure, post_merge, pr_review, publish, push_badges, release, sync_wiki, validate_commit_msg
|
||||
- Port 6 dev tools: check_test_speed, generate_badges, install_checkmake, install_tools, setup, configure_repo
|
||||
- Port 5 molecule tools as optional extra: platforms, distribute_molecule, discover_runners, molecule_ci_guard, molecule_all
|
||||
- Add CLI entry point with subcommands: devx ci, devx tools, devx molecule
|
||||
- Add Gitea PyPI registry publishing support in publish.py
|
||||
- Add configurable workflow-only patterns in classify_changes.py
|
||||
- Add configurable version file path in release.py
|
||||
- Replicate GRM's automated workflow: CI, auto-merge, post-merge, release, badges, wiki sync, Vikunja
|
||||
### Bug Fixes
|
||||
|
||||
- Use python3 and venv python in workflows and Makefile
|
||||
- Fix post-merge job failures (configure-repo, badges, notify-failure)
|
||||
- Allow release bot to push to protected master
|
||||
|
||||
+5
-2
@@ -39,8 +39,8 @@ sort_commits = "oldest"
|
||||
recurse_submodules = false
|
||||
|
||||
commit_preprocessors = [
|
||||
# Strip DEVX-N task ID prefix from merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^DEVX-\\d+\\s+", replace = "" },
|
||||
# Strip DEVX-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^DEVX-\\d+:\\s+", replace = "" },
|
||||
]
|
||||
|
||||
commit_parsers = [
|
||||
@@ -66,3 +66,6 @@ commit_parsers = [
|
||||
features_always_bump_minor = true
|
||||
breaking_always_bump_major = false
|
||||
initial_tag = "0.1.0"
|
||||
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
|
||||
# affect users even though no new feature was added.
|
||||
refactor_always_bump_patch = true
|
||||
|
||||
@@ -74,6 +74,17 @@ Configure repository: branch protection + labels via Gitea API.
|
||||
|
||||
Generate self-contained SVG badge files from project metrics.
|
||||
|
||||
### `devx tools generate-cliff-config`
|
||||
|
||||
Generate a `cliff.toml` configuration file with the correct task ID prefix.
|
||||
Eliminates the need to manually duplicate and maintain cliff.toml across
|
||||
repos that use devx.
|
||||
|
||||
```bash
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
|
||||
```
|
||||
|
||||
### `devx tools install-checkmake`
|
||||
|
||||
Install checkmake (Makefile linter) if not already present.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -21,7 +22,7 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
try:
|
||||
body: dict[str, Any] = response.json() if response is not None else {}
|
||||
message: str = body.get("message", str(e))
|
||||
except Exception:
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
message = str(e)
|
||||
return status, message
|
||||
|
||||
|
||||
+18
-14
@@ -114,12 +114,11 @@ def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||
def get_vikunja_task_title(task_id: str) -> str:
|
||||
"""Fetch the Vikunja task title for the given DEVX-N identifier.
|
||||
|
||||
Returns empty string if VIKUNJA_TOKEN is not set (local dev without token).
|
||||
Raises ClickException if the token is set but the task is not found.
|
||||
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
return ""
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."))
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
@@ -145,14 +144,10 @@ def get_vikunja_task_title(task_id: str) -> str:
|
||||
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
||||
"""Validate that PR title matches the Vikunja task title.
|
||||
|
||||
Skips validation if VIKUNJA_TOKEN is not set (local dev).
|
||||
Raises ClickException if the task is not found or the title doesn't match.
|
||||
Raises ClickException if VIKUNJA_TOKEN is not set, the task is not found,
|
||||
or the title doesn't match.
|
||||
"""
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
if not vikunja_title:
|
||||
# VIKUNJA_TOKEN not set — skip validation (local dev)
|
||||
click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation."))
|
||||
return
|
||||
expected = f"{task_id}: {vikunja_title}"
|
||||
if pr_title != expected:
|
||||
raise click.ClickException(
|
||||
@@ -193,7 +188,16 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
# Validate PR number is an integer
|
||||
try:
|
||||
pr_num = int(pr_number)
|
||||
except ValueError:
|
||||
raise click.ClickException(_("PR number must be an integer, got: {pr_number}", pr_number=pr_number)) from None
|
||||
|
||||
# Validate repo format
|
||||
if "/" not in repo:
|
||||
raise click.ClickException(_("Repo must be in 'owner/name' format, got: {repo}", repo=repo))
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
task_id = read_taskid(branch)
|
||||
@@ -210,14 +214,14 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
validate_pr_title_matches_vikunja(pr_title, task_id)
|
||||
|
||||
# Build merge title: DEVX-N: <conventional commit message>
|
||||
commits = client.get_pr_commits(pr_number)
|
||||
commits = client.get_pr_commits(pr_num)
|
||||
conv_msg = extract_conventional_msg(commits)
|
||||
if not conv_msg:
|
||||
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
|
||||
merge_title = f"{task_id}: {conv_msg}"
|
||||
|
||||
try:
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
client.merge_pr(pr_num, merge_title)
|
||||
except APIError as e:
|
||||
if e.status == 405 and "behind" in e.message.lower():
|
||||
# Head branch is behind master — pull master and rebase, then retry
|
||||
@@ -229,7 +233,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
run_cmd(["git", "rebase", "origin/master"])
|
||||
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
|
||||
click.echo(_("Rebased and pushed. Retrying merge..."))
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
client.merge_pr(pr_num, merge_title)
|
||||
except (APIError, Exception) as retry_err:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
@@ -250,7 +254,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
pr_number=pr_number,
|
||||
pr_number=pr_num,
|
||||
merge_title=merge_title,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,13 +12,13 @@ Checks performed (all fail with exit code 1 on error):
|
||||
translations file.
|
||||
- **Dead keys**: a key in a translations file is not used in any code.
|
||||
- **Missing languages**: a key exists but is missing one of the 5 supported
|
||||
languages (en, bg, de, ru, zh). Reported as a warning, not an error.
|
||||
languages (en, bg, de, ru, zh). This is an error — all supported languages
|
||||
must have translations for every key.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.check_translations
|
||||
python3 -m devx.ci.check_translations --translations path/to/translations.json
|
||||
python3 -m devx.ci.check_translations --strict # warnings are errors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -130,14 +130,15 @@ def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> Transla
|
||||
# Check for dead keys (in translations but not used in code)
|
||||
result.dead_keys = result.defined_keys - result.used_keys
|
||||
for key in sorted(result.dead_keys):
|
||||
result.warnings.append(f"Dead key in {name}: {key!r}")
|
||||
result.errors.append(f"Dead key in {name}: {key!r}")
|
||||
|
||||
# Check for missing languages
|
||||
# Check for missing languages — this is an error, not a warning.
|
||||
# All supported languages must have translations for every key.
|
||||
for key, langs in translations.items():
|
||||
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
|
||||
if missing:
|
||||
result.missing_langs[key] = missing
|
||||
result.warnings.append(f"Missing languages {missing} for key {key!r} in {name}")
|
||||
result.errors.append(f"Missing languages {missing} for key {key!r} in {name}")
|
||||
|
||||
return result
|
||||
|
||||
@@ -170,8 +171,7 @@ def print_result(result: TranslationCheckResult) -> None:
|
||||
type=click.Path(exists=False, path_type=Path),
|
||||
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
|
||||
)
|
||||
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
|
||||
def main(translations: tuple[Path, ...], strict: bool) -> None:
|
||||
def main(translations: tuple[Path, ...]) -> None:
|
||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
||||
if not translations:
|
||||
# Default: check the devx package's own translations
|
||||
@@ -187,25 +187,16 @@ def main(translations: tuple[Path, ...], strict: bool) -> None:
|
||||
results.append(check_translation_set(name, src_dir, trans_file))
|
||||
|
||||
has_errors = False
|
||||
has_warnings = False
|
||||
for result in results:
|
||||
print_result(result)
|
||||
if result.errors:
|
||||
has_errors = True
|
||||
if result.warnings:
|
||||
has_warnings = True
|
||||
|
||||
click.echo()
|
||||
if has_errors:
|
||||
click.echo("FAIL: Translation check found errors.", err=True)
|
||||
sys.exit(1)
|
||||
if strict and has_warnings:
|
||||
click.echo("FAIL: Translation check found warnings (--strict mode).", err=True)
|
||||
sys.exit(1)
|
||||
if has_warnings:
|
||||
click.echo("PASS with warnings: Translation check passed (warnings present).")
|
||||
else:
|
||||
click.echo("PASS: All translations are complete and up to date.")
|
||||
click.echo("PASS: All translations are complete and up to date.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -271,19 +271,28 @@ DEFAULT_INFRASTRUCTURE: list[str] = [
|
||||
# Build tooling
|
||||
"Makefile",
|
||||
"cliff.toml",
|
||||
"uv.lock",
|
||||
# Linting / formatting config
|
||||
".pre-commit-config.yaml",
|
||||
".ruff.toml",
|
||||
".ansible-lint",
|
||||
".checkmake.ini",
|
||||
".editorconfig",
|
||||
# Environment templates (not the actual .env which is gitignored)
|
||||
".env.example",
|
||||
# Git config
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
# Project-level documentation (not part of the installed package)
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"TROUBLESHOOTING.md",
|
||||
"CONTRIBUTING.md",
|
||||
"CODE_OF_CONDUCT.md",
|
||||
"REVIEW_CHECKLIST.md",
|
||||
# Agent/CI tooling config (not part of the installed package)
|
||||
".devin/**",
|
||||
# Generated venv activation scripts (created by `make setup`)
|
||||
"activate.sh",
|
||||
"activate.fish",
|
||||
|
||||
@@ -39,7 +39,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
|
||||
Returns the total count of active runners. If the API call fails
|
||||
(e.g., no admin access for instance-level runners), falls back to
|
||||
what we can see.
|
||||
what we can see. Fallbacks are logged to stderr for debugging.
|
||||
"""
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
total = 0
|
||||
@@ -54,8 +54,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
else:
|
||||
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
|
||||
|
||||
# 2. Organization-level runners
|
||||
try:
|
||||
@@ -67,8 +69,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
else:
|
||||
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
|
||||
|
||||
# 3. Instance-level runners (requires admin scope)
|
||||
try:
|
||||
@@ -80,8 +84,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
elif r.status_code != 403: # 403 is expected without admin scope
|
||||
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
|
||||
|
||||
return total
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
import click
|
||||
@@ -27,25 +27,33 @@ from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("devx")
|
||||
|
||||
|
||||
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
||||
"""Create issue via tea CLI. Returns issue index.
|
||||
|
||||
Raises TeaCLIError if tea is not installed or the command fails.
|
||||
Label operations are best-effort — failures are logged but don't
|
||||
prevent issue creation.
|
||||
"""
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
# Check if "bug" label exists
|
||||
# Check if "bug" label exists (best-effort)
|
||||
labels: list[str] = []
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
try:
|
||||
existing_labels = tea.list_labels(repo)
|
||||
if any(label.get("name") == "bug" for label in existing_labels):
|
||||
labels = ["bug"]
|
||||
except TeaCLIError as e:
|
||||
logger.warning("Could not fetch labels (best-effort): %s", e)
|
||||
|
||||
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
|
||||
if labels:
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
try:
|
||||
tea.add_label(repo, issue["index"], labels)
|
||||
except TeaCLIError as e:
|
||||
logger.warning("Could not add label to issue #%s (best-effort): %s", issue.get("index"), e)
|
||||
return int(issue.get("index", 0))
|
||||
|
||||
|
||||
|
||||
+14
-13
@@ -13,7 +13,7 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import VikunjaClient
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -151,14 +151,16 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
|
||||
)
|
||||
)
|
||||
return
|
||||
# Non-infrastructure commits without DEVX-N prefix — warn but don't fail
|
||||
click.echo(
|
||||
# Non-infrastructure commits without DEVX-N prefix — this is a
|
||||
# convention violation. Fail the post-merge job so the issue is visible.
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
|
||||
"No task ID ({prefix}-N) found in commit message: {msg}. "
|
||||
"Every non-infrastructure commit must have a task ID.",
|
||||
prefix=TASK_PREFIX,
|
||||
msg=first_line,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
vikunja_task_id = resolve_task_id(client, task_id)
|
||||
@@ -170,19 +172,18 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
|
||||
client.post_comment(vikunja_task_id, html)
|
||||
client.update_task(vikunja_task_id, done=True)
|
||||
except APIError as e:
|
||||
# Vikunja is a project management tool — if it's down, the merge
|
||||
# still succeeded. Warn but don't fail the post-merge workflow.
|
||||
click.echo(
|
||||
# Vikunja API failures must be visible — the task was not updated
|
||||
# and needs manual intervention. Failing the CI job makes this visible.
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. The merge succeeded — "
|
||||
"please update the Vikunja task manually.",
|
||||
"Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. "
|
||||
"The merge succeeded but the Vikunja task needs manual update.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
return
|
||||
) from e
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
|
||||
+365
-12
@@ -23,8 +23,14 @@ This script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits with a message and does nothing. If the tag already exists
|
||||
(e.g., from a partial previous run), it skips tag creation and only pushes.
|
||||
|
||||
**Tag consistency**: Before releasing, the script fetches remote tags and
|
||||
verifies all existing tags point to commits whose message matches the tag
|
||||
version. This prevents duplicate release commits (a common issue when CI
|
||||
checkouts don't fetch tags) and ensures tag/version/commit alignment.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
|
||||
python3 -m devx.ci.release --verify # Check tag/version/release alignment
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -32,6 +38,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -79,6 +86,81 @@ def tag_exists(tag: str) -> bool:
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_tag_commit(tag: str) -> str:
|
||||
"""Get the commit hash a tag points to."""
|
||||
result = run_cmd(["git", "rev-list", "-n1", tag], check=False)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_head_commit() -> str:
|
||||
"""Get the current HEAD commit hash."""
|
||||
result = run_cmd(["git", "rev-parse", "HEAD"], check=False)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def fetch_tags() -> None:
|
||||
"""Fetch tags from remote to ensure local tag state is current.
|
||||
|
||||
This is critical in CI environments where a fresh checkout may not
|
||||
include tags from previous runs. Without this, the script may
|
||||
create duplicate release commits because ``tag_exists`` returns False
|
||||
for a tag that exists on the remote but wasn't fetched.
|
||||
"""
|
||||
result = run_cmd(["git", "fetch", "--tags", "origin"], check=False)
|
||||
if result.returncode != 0:
|
||||
# Don't fail hard — maybe there's no remote (local-only repo)
|
||||
click.echo(_("Warning: could not fetch tags from origin."))
|
||||
|
||||
|
||||
def get_all_tags() -> list[str]:
|
||||
"""Get all git tags sorted by version (newest first)."""
|
||||
result = run_cmd(["git", "tag", "-l", "--sort=-v:refname"], check=False)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
||||
|
||||
|
||||
def get_commit_version(commit: str) -> str | None:
|
||||
"""Extract version from a release commit message.
|
||||
|
||||
Returns the version string (e.g., '0.4.4') or None if the commit
|
||||
is not a release commit.
|
||||
"""
|
||||
result = run_cmd(["git", "log", "-1", "--pretty=%s", commit], check=False)
|
||||
match = re.match(r"^release: v(\d+\.\d+\.\d+)", result.stdout.strip())
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def verify_tag_consistency() -> list[str]:
|
||||
"""Verify all tags point to commits with matching version in message.
|
||||
|
||||
Returns a list of error messages for inconsistent tags.
|
||||
An empty list means all tags are consistent.
|
||||
|
||||
The first release (v0.1.0 or earliest tag) is exempt — initial releases
|
||||
often don't have a "release:" commit message (e.g., the initial commit
|
||||
serves as the first release).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
tags = get_all_tags()
|
||||
# Sort oldest first to identify the first tag
|
||||
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
|
||||
first_tag = sorted_tags[0] if sorted_tags else None
|
||||
for tag in tags:
|
||||
tag_version = tag.lstrip("v")
|
||||
commit_version = get_commit_version(tag)
|
||||
if commit_version is None:
|
||||
# First tag is allowed to point to a non-release commit (initial release)
|
||||
if tag == first_tag:
|
||||
continue
|
||||
errors.append(
|
||||
f" {tag} → points to non-release commit (expected 'release: v{tag_version}', got non-release commit)"
|
||||
)
|
||||
elif commit_version != tag_version:
|
||||
errors.append(f" {tag} → commit says 'release: v{commit_version}' (expected 'release: v{tag_version}')")
|
||||
return errors
|
||||
|
||||
|
||||
def get_bumped_version() -> str:
|
||||
"""Use git-cliff to calculate the next version from conventional commits."""
|
||||
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
|
||||
@@ -86,7 +168,13 @@ def get_bumped_version() -> str:
|
||||
if not version:
|
||||
raise click.ClickException(_("git-cliff returned empty version."))
|
||||
# git-cliff may return with or without 'v' prefix
|
||||
return version.lstrip("v")
|
||||
version = version.lstrip("v")
|
||||
# Validate semver format
|
||||
if not re.match(r"^\d+\.\d+\.\d+$", version):
|
||||
raise click.ClickException(
|
||||
_("git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", version=version)
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def get_changelog(new_version: str) -> str:
|
||||
@@ -117,9 +205,12 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
||||
latest = get_latest_tag()
|
||||
if not latest:
|
||||
return True
|
||||
# Check for any commits since the last tag
|
||||
# Check for any commits since the last tag, excluding release commits
|
||||
# (release commits themselves are not "unreleased changes" — they ARE
|
||||
# the release). This prevents duplicate release commits when the
|
||||
# script runs multiple times.
|
||||
result = run_cmd(
|
||||
["git", "log", f"{latest}..HEAD", "--oneline"],
|
||||
["git", "log", f"{latest}..HEAD", "--oneline", "--no-merges", "--invert-grep", "--grep=^release: v"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
@@ -233,10 +324,27 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
"""Create an annotated tag with the changelog as message and push it.
|
||||
|
||||
Returns True if the tag was created/pushed, False if it already existed.
|
||||
Raises an error if the tag exists but points to a different commit than HEAD.
|
||||
"""
|
||||
tag = f"v{new_version}"
|
||||
if tag_exists(tag):
|
||||
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
|
||||
# Verify the tag points to HEAD — if it points elsewhere, that's
|
||||
# a consistency error, not a skip condition.
|
||||
tag_commit = get_tag_commit(tag)
|
||||
head_commit = get_head_commit()
|
||||
if tag_commit != head_commit:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Tag {tag} already exists but points to {tag_commit} "
|
||||
"(expected HEAD {head_commit}). "
|
||||
"This indicates a tag/commit misalignment. "
|
||||
"Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
tag=tag,
|
||||
tag_commit=tag_commit[:7],
|
||||
head_commit=head_commit[:7],
|
||||
)
|
||||
)
|
||||
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", tag], check=False)
|
||||
@@ -250,6 +358,178 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verification mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_init_version() -> str | None:
|
||||
"""Read __version__ from the version file."""
|
||||
try:
|
||||
with open(INIT_FILE) as f:
|
||||
content = f.read()
|
||||
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
|
||||
return match.group(1) if match else None
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def get_changelog_versions() -> list[str]:
|
||||
"""Extract version numbers from CHANGELOG.md headers, in order."""
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
content = f.read()
|
||||
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
|
||||
def verify_alignment() -> int:
|
||||
"""Verify tag/version/changelog alignment. Returns exit code (0=ok, 1=issues)."""
|
||||
click.echo(_("=== Release Alignment Verification ===\n"))
|
||||
|
||||
has_issues = False
|
||||
|
||||
# 1. Check __version__ matches latest tag
|
||||
init_version = get_init_version()
|
||||
latest_tag = get_latest_tag()
|
||||
latest_tag_version = latest_tag.lstrip("v") if latest_tag else None
|
||||
|
||||
click.echo(_("Version file: {file}", file=INIT_FILE))
|
||||
if init_version:
|
||||
click.echo(f' __version__ = "{init_version}"')
|
||||
else:
|
||||
click.echo(" __version__ = NOT FOUND")
|
||||
has_issues = True
|
||||
|
||||
click.echo(_("\nLatest tag: {tag}", tag=latest_tag or "(none)"))
|
||||
if latest_tag_version and init_version:
|
||||
if latest_tag_version == init_version:
|
||||
click.echo(f" ✓ Tag version matches __version__ ({init_version})")
|
||||
else:
|
||||
click.echo(f" ✗ MISMATCH: tag={latest_tag_version}, __version__={init_version}")
|
||||
has_issues = True
|
||||
|
||||
# 2. Check all tags point to commits with matching version
|
||||
click.echo(_("\nTag → Commit alignment:"))
|
||||
tag_errors = verify_tag_consistency()
|
||||
all_tags = get_all_tags()
|
||||
if not all_tags:
|
||||
click.echo(" (no tags)")
|
||||
elif not tag_errors:
|
||||
click.echo(f" ✓ All {len(all_tags)} tags point to matching release commits")
|
||||
else:
|
||||
has_issues = True
|
||||
for err in tag_errors:
|
||||
click.echo(f" ✗ {err}")
|
||||
|
||||
# 3. Check CHANGELOG versions are in descending order
|
||||
click.echo(_("\nCHANGELOG version ordering:"))
|
||||
changelog_versions = get_changelog_versions()
|
||||
if not changelog_versions:
|
||||
click.echo(" (no versions in CHANGELOG)")
|
||||
else:
|
||||
# Check for duplicates
|
||||
seen: set[str] = set()
|
||||
duplicates: list[str] = []
|
||||
for v in changelog_versions:
|
||||
if v in seen:
|
||||
duplicates.append(v)
|
||||
seen.add(v)
|
||||
|
||||
# Check ordering (should be descending)
|
||||
is_ordered = all(changelog_versions[i] >= changelog_versions[i + 1] for i in range(len(changelog_versions) - 1))
|
||||
|
||||
if duplicates:
|
||||
has_issues = True
|
||||
click.echo(f" ✗ Duplicate entries: {', '.join(duplicates)}")
|
||||
elif not is_ordered:
|
||||
has_issues = True
|
||||
click.echo(f" ✗ Versions not in descending order: {changelog_versions}")
|
||||
else:
|
||||
click.echo(f" ✓ {len(changelog_versions)} versions, all in descending order")
|
||||
|
||||
# Check latest CHANGELOG version matches latest tag.
|
||||
# The CHANGELOG may have one unreleased section ahead of the latest tag
|
||||
# (e.g., CHANGELOG has 0.6.4 but latest tag is v0.6.3 — 0.6.4 is unreleased).
|
||||
if changelog_versions and latest_tag_version:
|
||||
if changelog_versions[0] == latest_tag_version:
|
||||
click.echo(f" ✓ Latest CHANGELOG version matches latest tag ({latest_tag_version})")
|
||||
elif latest_tag_version in changelog_versions:
|
||||
tag_idx = changelog_versions.index(latest_tag_version)
|
||||
# Latest tag should be at index 0 or 1 (0 = released, 1 = unreleased ahead)
|
||||
if tag_idx == 1:
|
||||
click.echo(
|
||||
f" ✓ Latest CHANGELOG version ({changelog_versions[0]}) is unreleased, "
|
||||
f"latest tag is {latest_tag_version}"
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, "
|
||||
f"tag={latest_tag_version} (tag is at position {tag_idx})"
|
||||
)
|
||||
has_issues = True
|
||||
else:
|
||||
click.echo(f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, tag={latest_tag_version}")
|
||||
has_issues = True
|
||||
|
||||
# 4. Check for untagged release commits.
|
||||
# Distinguish between:
|
||||
# - Truly untagged: no tag exists for that version (needs a tag)
|
||||
# - Duplicates: a tag for that version exists but on a different commit
|
||||
# (historical artifact from buggy release script — informational, not an error)
|
||||
click.echo(_("\nUntagged release commits:"))
|
||||
result = run_cmd(
|
||||
["git", "log", "--all", "--format=%h %s", "--grep=^release: v"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
all_release_commits = result.stdout.strip().split("\n")
|
||||
all_tags_set = {t.lstrip("v") for t in get_all_tags()}
|
||||
truly_untagged: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
for line in all_release_commits:
|
||||
short_hash = line.split()[0]
|
||||
tags_at = run_cmd(["git", "tag", "--points-at", short_hash], check=False)
|
||||
if not tags_at.stdout.strip():
|
||||
# Check if a tag for this version exists elsewhere
|
||||
match = re.search(r"release: v(\d+\.\d+\.\d+)", line)
|
||||
if match and match.group(1) in all_tags_set:
|
||||
duplicates.append(line)
|
||||
else:
|
||||
truly_untagged.append(line)
|
||||
if truly_untagged:
|
||||
has_issues = True
|
||||
click.echo(f" ✗ {len(truly_untagged)} untagged release commits (no tag for version):")
|
||||
for c in truly_untagged[:10]:
|
||||
click.echo(f" {c}")
|
||||
if len(truly_untagged) > 10:
|
||||
click.echo(f" ... and {len(truly_untagged) - 10} more")
|
||||
else:
|
||||
click.echo(" ✓ All release commits have tags")
|
||||
if duplicates:
|
||||
click.echo(f" ℹ {len(duplicates)} duplicate release commits (tag exists on different commit):")
|
||||
for c in duplicates[:5]:
|
||||
click.echo(f" {c}")
|
||||
if len(duplicates) > 5:
|
||||
click.echo(f" ... and {len(duplicates) - 5} more")
|
||||
else:
|
||||
click.echo(" (no release commits found)")
|
||||
|
||||
# Summary
|
||||
click.echo(_("\n=== Summary ==="))
|
||||
if has_issues:
|
||||
click.echo("✗ Issues found — see above for details.")
|
||||
return 1
|
||||
click.echo("✓ All checks passed — tags, versions, and changelog are aligned.")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
@click.option(
|
||||
@@ -258,7 +538,20 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
default=False,
|
||||
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
|
||||
)
|
||||
def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
@click.option(
|
||||
"--verify",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Verify tag/version/changelog alignment and exit (no changes made).",
|
||||
)
|
||||
def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
"""Automated release: calculate next version, update files, tag, and push.
|
||||
|
||||
Use --verify to check tag/version/changelog alignment without making changes.
|
||||
"""
|
||||
if verify:
|
||||
sys.exit(verify_alignment())
|
||||
|
||||
# Ensure we're on master (skip this check in dry-run mode for PR validation)
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
if branch != "master" and not dry_run:
|
||||
@@ -271,16 +564,71 @@ def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# Release lock: if HEAD is already a release commit, another release
|
||||
# run is in progress (or already completed). Skip to prevent duplicate tags.
|
||||
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
|
||||
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg):
|
||||
# Fetch tags from remote to ensure local tag state is current.
|
||||
# This is critical in CI where a fresh checkout may not include tags
|
||||
# from previous runs. Without this, tag_exists() returns False for
|
||||
# tags that exist on the remote, leading to duplicate release commits.
|
||||
if not dry_run:
|
||||
fetch_tags()
|
||||
|
||||
# Pre-flight: verify existing tags are consistent. If any tag points
|
||||
# to a commit with a mismatched version, abort before creating more
|
||||
# inconsistencies.
|
||||
tag_errors = verify_tag_consistency()
|
||||
if tag_errors:
|
||||
click.echo(_("ERROR: Tag consistency check failed. Existing tags are misaligned:"))
|
||||
for err in tag_errors:
|
||||
click.echo(err)
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.",
|
||||
msg=head_msg,
|
||||
"\nFix the misaligned tags before creating new releases. "
|
||||
"Run 'python3 -m devx.ci.release --verify' for a full report."
|
||||
)
|
||||
)
|
||||
raise click.ClickException(_("Tag consistency check failed."))
|
||||
|
||||
# Release lock: if HEAD is already a release commit, check if the tag
|
||||
# exists AND points to HEAD. If the tag is missing (e.g., tag push
|
||||
# failed in a previous run), create and push it. If the tag exists
|
||||
# but points elsewhere, that's an error.
|
||||
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
|
||||
release_match = re.match(r"^release: v(\d+\.\d+\.\d+)", head_msg)
|
||||
if release_match:
|
||||
release_version = release_match.group(1)
|
||||
release_tag = f"v{release_version}"
|
||||
if tag_exists(release_tag):
|
||||
tag_commit = get_tag_commit(release_tag)
|
||||
head_commit = get_head_commit()
|
||||
if tag_commit != head_commit:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"HEAD is a release commit for v{version} but tag {tag} "
|
||||
"points to a different commit ({tag_commit} vs HEAD {head_commit}). "
|
||||
"This indicates a tag/commit misalignment.",
|
||||
version=release_version,
|
||||
tag=release_tag,
|
||||
tag_commit=tag_commit[:7],
|
||||
head_commit=head_commit[:7],
|
||||
)
|
||||
)
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
msg=head_msg,
|
||||
tag=release_tag,
|
||||
)
|
||||
)
|
||||
return
|
||||
# Tag is missing — recover by creating and pushing it
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
|
||||
msg=head_msg,
|
||||
tag=release_tag,
|
||||
)
|
||||
)
|
||||
changelog = get_changelog(release_version)
|
||||
create_and_push_tag(release_version, changelog, dry_run)
|
||||
return
|
||||
|
||||
# Check if any user-facing files changed since the last tag.
|
||||
@@ -315,7 +663,12 @@ def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
# Generate changelog
|
||||
changelog = get_changelog(new_version)
|
||||
if not changelog:
|
||||
click.echo(_("Warning: git-cliff generated empty changelog."))
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
|
||||
@@ -34,14 +34,31 @@ from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs"
|
||||
# DOCS_DIR is the repo's docs/ directory. When devx is installed as a
|
||||
# package (e.g., in .venv/lib/python3.12/site-packages/devx/), the
|
||||
# __file__-relative path would point inside the venv, not the repo.
|
||||
# Use DEVX_DOCS_DIR env var if set, otherwise fall back to ./docs
|
||||
# (relative to the current working directory, which is the repo root
|
||||
# in CI and local development).
|
||||
DOCS_DIR = Path(os.environ.get("DEVX_DOCS_DIR", "docs"))
|
||||
MAPPING_FILE = DOCS_DIR / "mapping.json"
|
||||
|
||||
|
||||
def load_mapping() -> dict[str, str]:
|
||||
"""Load the file-to-wiki-page mapping from mapping.json."""
|
||||
"""Load the file-to-wiki-page mapping from mapping.json.
|
||||
|
||||
Validates that the mapping is a dict of string-to-string pairs.
|
||||
"""
|
||||
with open(MAPPING_FILE) as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise click.ClickException(
|
||||
_("mapping.json must be a dict of file-path -> page-title, got {type}", type=type(data).__name__)
|
||||
)
|
||||
for k, v in data.items():
|
||||
if not isinstance(k, str) or not isinstance(v, str):
|
||||
raise click.ClickException(_("mapping.json keys and values must be strings, got {k}={v}", k=k, v=v))
|
||||
return data
|
||||
|
||||
|
||||
def read_doc_content(file_path: str) -> str:
|
||||
@@ -239,14 +256,14 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
try:
|
||||
content = read_doc_content(file_path)
|
||||
except FileNotFoundError:
|
||||
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
raise click.ClickException(
|
||||
_("Mapped file {file} not found. Update mapping.json or create the file.", file=file_path)
|
||||
) from None
|
||||
|
||||
if not content.strip():
|
||||
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
raise click.ClickException(
|
||||
_("Mapped file {file} is empty. Update the content or remove from mapping.json.", file=file_path)
|
||||
) from None
|
||||
|
||||
result = sync_page(client, page_title, content, existing_pages, dry_run)
|
||||
if result == "created":
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
"""Validate commit messages for devx.
|
||||
|
||||
Rules:
|
||||
- On feature branches: conventional commits ONLY, must NOT include DEVX-N prefix.
|
||||
- On feature branches: conventional commits ONLY, must NOT include <PREFIX>-N prefix.
|
||||
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
|
||||
e.g. 'DEVX-24: fix: resolve timeout'.
|
||||
|
||||
The task ID prefix is configurable via the ``DEVX_TASK_PREFIX`` environment
|
||||
variable (default: ``DEVX``). Projects consuming devx (e.g., GRM) set
|
||||
their own prefix (e.g., ``GRM``) so the validator enforces the correct
|
||||
task ID format for each project.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -12,10 +17,10 @@ import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import CONVENTIONAL_RE
|
||||
from devx.config import CONVENTIONAL_RE, TASK_PREFIX
|
||||
from devx.i18n import _
|
||||
|
||||
MASTER_TASK_ID_RE = re.compile(r"^DEVX-\d+:")
|
||||
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
|
||||
|
||||
|
||||
def first_line(text: str) -> str:
|
||||
@@ -51,8 +56,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commits must start with a task ID.\n"
|
||||
" Expected: DEVX-N: <conventional commit message>\n"
|
||||
" Expected: {prefix}-N: <conventional commit message>\n"
|
||||
" Got: {subject}",
|
||||
prefix=TASK_PREFIX,
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
@@ -61,8 +67,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n"
|
||||
" Expected: DEVX-N: <type>: <description>\n"
|
||||
" Expected: {prefix}-N: <type>: <description>\n"
|
||||
" Got: {subject}",
|
||||
prefix=TASK_PREFIX,
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
@@ -71,8 +78,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
if MASTER_TASK_ID_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n"
|
||||
" The task ID will be added automatically on merge via CI."
|
||||
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n"
|
||||
" The task ID will be added automatically on merge via CI.",
|
||||
prefix=TASK_PREFIX,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -177,6 +177,13 @@ def tools_generate_badges(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.tools.generate_badges", list(args))
|
||||
|
||||
|
||||
@tools.command("generate-cliff-config")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_generate_cliff_config(args: tuple[str, ...]) -> None:
|
||||
"""Generate a cliff.toml configuration file for the project."""
|
||||
_run_module("devx.tools.generate_cliff_config", list(args))
|
||||
|
||||
|
||||
@tools.command("install-checkmake")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_install_checkmake(args: tuple[str, ...]) -> None:
|
||||
|
||||
@@ -174,6 +174,10 @@ def cli(
|
||||
_write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
# Validate runner index is in range
|
||||
if runner_index < 1:
|
||||
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
||||
|
||||
# Convert 1-based CLI index to 0-based internal index
|
||||
zero_based = runner_index - 1
|
||||
assigned = pairs_for_runner(pairs, zero_based, max_runners)
|
||||
|
||||
@@ -159,8 +159,11 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
if failed_event.is_set():
|
||||
sys.exit(1)
|
||||
|
||||
scenario = pair.split("|")[0]
|
||||
platform_name = pair.split("|")[1]
|
||||
parts = pair.split("|")
|
||||
if len(parts) < 2:
|
||||
raise click.ClickException(f"Invalid pair format: {pair!r} (expected at least 2 pipe-delimited parts)")
|
||||
scenario = parts[0]
|
||||
platform_name = parts[1]
|
||||
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
||||
|
||||
cmd = build_molecule_cmd(scenario)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a cliff.toml configuration file for a project.
|
||||
|
||||
Produces a git-cliff configuration with the correct task ID prefix
|
||||
preprocessor, matching the format used by devx itself. Downstream
|
||||
repos can use this to avoid duplicating the entire cliff.toml by hand.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --output cliff.toml
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --force
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import TASK_PREFIX
|
||||
from devx.i18n import _
|
||||
|
||||
# Template uses __PREFIX__ and __PREFIX_REGEX__ as placeholders to avoid
|
||||
# conflicts with Jinja2's {{ }} and {% %} syntax in the cliff.toml body.
|
||||
CLIFF_TEMPLATE = """\
|
||||
# git-cliff configuration for __PREFIX__
|
||||
# https://git-cliff.org/docs/configuration
|
||||
# Generated by: python -m devx.tools.generate_cliff_config --prefix __PREFIX__
|
||||
|
||||
[changelog]
|
||||
header = \"\"\"
|
||||
# Changelog\\n
|
||||
All notable changes to this project will be documented in this file.\\n
|
||||
\"\"\"
|
||||
body = \"\"\"
|
||||
{% if version %}\\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\\
|
||||
## [unreleased]
|
||||
{% endif %}\\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\\
|
||||
{% if commit.breaking %}[**breaking**] {% endif %}\\
|
||||
{{ commit.message | upper_first }}\\
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
\"\"\"
|
||||
trim = true
|
||||
render_always = true
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
require_conventional = false
|
||||
split_commits = false
|
||||
protect_breaking_commits = false
|
||||
filter_commits = false
|
||||
fail_on_unmatched_commit = false
|
||||
use_branch_tags = false
|
||||
topo_order = false
|
||||
topo_order_commits = true
|
||||
sort_commits = "oldest"
|
||||
recurse_submodules = false
|
||||
|
||||
commit_preprocessors = [
|
||||
# Strip __PREFIX__-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^__PREFIX_REGEX__-\\\\d+:\\\\s+", replace = "" },
|
||||
]
|
||||
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^perf", group = "<!-- 4 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
|
||||
# Skip infrastructure-only commits — they don't affect users
|
||||
{ message = "^doc", skip = true },
|
||||
{ message = "^test", skip = true },
|
||||
{ message = "^style", skip = true },
|
||||
{ message = "^chore", skip = true },
|
||||
{ message = "^ci", skip = true },
|
||||
# Skip release commits — they are release artifacts, not features
|
||||
{ message = "^release:", skip = true },
|
||||
{ body = ".*security", group = "<!-- 8 -->Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->Revert" },
|
||||
# Skip anything that doesn't match above — safe default
|
||||
{ message = ".*", skip = true },
|
||||
]
|
||||
|
||||
[bump]
|
||||
features_always_bump_minor = true
|
||||
breaking_always_bump_major = false
|
||||
initial_tag = "0.1.0"
|
||||
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
|
||||
# affect users even though no new feature was added.
|
||||
refactor_always_bump_patch = true
|
||||
"""
|
||||
|
||||
|
||||
def _generate(prefix: str) -> str:
|
||||
"""Generate cliff.toml content for the given prefix."""
|
||||
prefix_regex = prefix.replace("\\", "\\\\")
|
||||
return CLIFF_TEMPLATE.replace("__PREFIX__", prefix).replace("__PREFIX_REGEX__", prefix_regex)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--prefix",
|
||||
default=TASK_PREFIX,
|
||||
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
default="cliff.toml",
|
||||
type=click.Path(),
|
||||
help="Output file path (default: cliff.toml).",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Overwrite existing file without prompting.",
|
||||
)
|
||||
def main(prefix: str, output: str, force: bool) -> None:
|
||||
"""Generate a cliff.toml configuration file."""
|
||||
output_path = Path(output)
|
||||
|
||||
if output_path.exists() and not force:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"{file} already exists. Use --force to overwrite.",
|
||||
file=str(output_path),
|
||||
)
|
||||
)
|
||||
|
||||
content = _generate(prefix)
|
||||
output_path.write_text(content)
|
||||
click.echo(
|
||||
_(
|
||||
"Generated {file} with prefix '{prefix}'.",
|
||||
file=str(output_path),
|
||||
prefix=prefix,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
+707
-212
@@ -1,63 +1,185 @@
|
||||
{
|
||||
"\nAll documentation coverage checks passed!": {
|
||||
"en": "\nAll documentation coverage checks passed!"
|
||||
"\n=== Summary ===": {
|
||||
"en": "\n=== Summary ===",
|
||||
"bg": "\n=== Summary ===",
|
||||
"de": "\n=== Summary ===",
|
||||
"ru": "\n=== Summary ===",
|
||||
"zh": "\n=== Summary ==="
|
||||
},
|
||||
"\nAnsible files changed ({count}):": {
|
||||
"en": "\nAnsible files changed ({count}):"
|
||||
"\nAll documentation coverage checks passed!": {
|
||||
"en": "\nAll documentation coverage checks passed!",
|
||||
"bg": "\nAll documentation coverage checks passed!",
|
||||
"de": "\nAll documentation coverage checks passed!",
|
||||
"ru": "\nAll documentation coverage checks passed!",
|
||||
"zh": "\nAll documentation coverage checks passed!"
|
||||
},
|
||||
"\nCHANGELOG version ordering:": {
|
||||
"en": "\nCHANGELOG version ordering:",
|
||||
"bg": "\nCHANGELOG version ordering:",
|
||||
"de": "\nCHANGELOG version ordering:",
|
||||
"ru": "\nCHANGELOG version ordering:",
|
||||
"zh": "\nCHANGELOG version ordering:"
|
||||
},
|
||||
"\nChecking CI script documentation in ci-cd-workflow.md...": {
|
||||
"en": "\nChecking CI script documentation in ci-cd-workflow.md..."
|
||||
"en": "\nChecking CI script documentation in ci-cd-workflow.md...",
|
||||
"bg": "\nChecking CI script documentation in ci-cd-workflow.md...",
|
||||
"de": "\nChecking CI script documentation in ci-cd-workflow.md...",
|
||||
"ru": "\nChecking CI script documentation in ci-cd-workflow.md...",
|
||||
"zh": "\nChecking CI script documentation in ci-cd-workflow.md..."
|
||||
},
|
||||
"\nChecking module documentation in architecture.md...": {
|
||||
"en": "\nChecking module documentation in architecture.md..."
|
||||
"en": "\nChecking module documentation in architecture.md...",
|
||||
"bg": "\nChecking module documentation in architecture.md...",
|
||||
"de": "\nChecking module documentation in architecture.md...",
|
||||
"ru": "\nChecking module documentation in architecture.md...",
|
||||
"zh": "\nChecking module documentation in architecture.md..."
|
||||
},
|
||||
"\nDoc coverage: {covered}/{total} ({pct}%)": {
|
||||
"en": "\nDoc coverage: {covered}/{total} ({pct}%)"
|
||||
"en": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"bg": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"de": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
|
||||
},
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
|
||||
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||
},
|
||||
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
|
||||
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
|
||||
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
|
||||
},
|
||||
"\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": {
|
||||
"en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
|
||||
},
|
||||
"\nIntegrity check FAILED ({count} issues):": {
|
||||
"en": "\nIntegrity check FAILED ({count} issues):"
|
||||
"en": "\nIntegrity check FAILED ({count} issues):",
|
||||
"bg": "\nIntegrity check FAILED ({count} issues):",
|
||||
"de": "\nIntegrity check FAILED ({count} issues):",
|
||||
"ru": "\nIntegrity check FAILED ({count} issues):",
|
||||
"zh": "\nIntegrity check FAILED ({count} issues):"
|
||||
},
|
||||
"\nIntegrity check passed — all {count} pages verified.": {
|
||||
"en": "\nIntegrity check passed — all {count} pages verified."
|
||||
"en": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"bg": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"de": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"ru": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"zh": "\nIntegrity check passed — all {count} pages verified."
|
||||
},
|
||||
"\nLatest tag: {tag}": {
|
||||
"en": "\nLatest tag: {tag}",
|
||||
"bg": "\nLatest tag: {tag}",
|
||||
"de": "\nLatest tag: {tag}",
|
||||
"ru": "\nLatest tag: {tag}",
|
||||
"zh": "\nLatest tag: {tag}"
|
||||
},
|
||||
"\nMissing documentation:": {
|
||||
"en": "\nMissing documentation:"
|
||||
"en": "\nMissing documentation:",
|
||||
"bg": "\nMissing documentation:",
|
||||
"de": "\nMissing documentation:",
|
||||
"ru": "\nMissing documentation:",
|
||||
"zh": "\nMissing documentation:"
|
||||
},
|
||||
"\nResult: {status}": {
|
||||
"en": "\nResult: {status}"
|
||||
"en": "\nResult: {status}",
|
||||
"bg": "\nResult: {status}",
|
||||
"de": "\nResult: {status}",
|
||||
"ru": "\nResult: {status}",
|
||||
"zh": "\nResult: {status}"
|
||||
},
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
||||
},
|
||||
"\nRunning full wiki integrity check...": {
|
||||
"en": "\nRunning full wiki integrity check..."
|
||||
"en": "\nRunning full wiki integrity check...",
|
||||
"bg": "\nRunning full wiki integrity check...",
|
||||
"de": "\nRunning full wiki integrity check...",
|
||||
"ru": "\nRunning full wiki integrity check...",
|
||||
"zh": "\nRunning full wiki integrity check..."
|
||||
},
|
||||
"\nTag → Commit alignment:": {
|
||||
"en": "\nTag → Commit alignment:",
|
||||
"bg": "\nTag → Commit alignment:",
|
||||
"de": "\nTag → Commit alignment:",
|
||||
"ru": "\nTag → Commit alignment:",
|
||||
"zh": "\nTag → Commit alignment:"
|
||||
},
|
||||
"\nUntagged release commits:": {
|
||||
"en": "\nUntagged release commits:",
|
||||
"bg": "\nUntagged release commits:",
|
||||
"de": "\nUntagged release commits:",
|
||||
"ru": "\nUntagged release commits:",
|
||||
"zh": "\nUntagged release commits:"
|
||||
},
|
||||
"\nUser-facing changes ({count}):": {
|
||||
"en": "\nUser-facing changes ({count}):"
|
||||
},
|
||||
"\nUser-facing files changed ({count}):": {
|
||||
"en": "\nUser-facing files changed ({count}):"
|
||||
"en": "\nUser-facing changes ({count}):",
|
||||
"bg": "\nUser-facing changes ({count}):",
|
||||
"de": "\nUser-facing changes ({count}):",
|
||||
"ru": "\nUser-facing changes ({count}):",
|
||||
"zh": "\nUser-facing changes ({count}):"
|
||||
},
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
|
||||
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
|
||||
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
|
||||
},
|
||||
"\nVerification passed — all wiki pages have correct content.": {
|
||||
"en": "\nVerification passed — all wiki pages have correct content."
|
||||
"en": "\nVerification passed — all wiki pages have correct content.",
|
||||
"bg": "\nVerification passed — all wiki pages have correct content.",
|
||||
"de": "\nVerification passed — all wiki pages have correct content.",
|
||||
"ru": "\nVerification passed — all wiki pages have correct content.",
|
||||
"zh": "\nVerification passed — all wiki pages have correct content."
|
||||
},
|
||||
"\nVerifying wiki pages have content...": {
|
||||
"en": "\nVerifying wiki pages have content..."
|
||||
"en": "\nVerifying wiki pages have content...",
|
||||
"bg": "\nVerifying wiki pages have content...",
|
||||
"de": "\nVerifying wiki pages have content...",
|
||||
"ru": "\nVerifying wiki pages have content...",
|
||||
"zh": "\nVerifying wiki pages have content..."
|
||||
},
|
||||
"\nWorkflow-only changes ({count}):": {
|
||||
"en": "\nWorkflow-only changes ({count}):"
|
||||
"en": "\nWorkflow-only changes ({count}):",
|
||||
"bg": "\nWorkflow-only changes ({count}):",
|
||||
"de": "\nWorkflow-only changes ({count}):",
|
||||
"ru": "\nWorkflow-only changes ({count}):",
|
||||
"zh": "\nWorkflow-only changes ({count}):"
|
||||
},
|
||||
"\n[dry-run] Changelog:\n{changelog}": {
|
||||
"en": "\n[dry-run] Changelog:\n{changelog}"
|
||||
"en": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"bg": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"de": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"ru": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"zh": "\n[dry-run] Changelog:\n{changelog}"
|
||||
},
|
||||
"\n{label} files changed ({count}):": {
|
||||
"en": "\n{label} files changed ({count}):",
|
||||
"bg": "\n{label} files changed ({count}):",
|
||||
"de": "\n{label} files changed ({count}):",
|
||||
"ru": "\n{label} files changed ({count}):",
|
||||
"zh": "\n{label} files changed ({count}):"
|
||||
},
|
||||
"\n{tag} files ({count}):": {
|
||||
"en": "\n{tag} files ({count}):",
|
||||
"bg": "\n{tag} files ({count}):",
|
||||
"de": "\n{tag} files ({count}):",
|
||||
"ru": "\n{tag} files ({count}):",
|
||||
"zh": "\n{tag} files ({count}):"
|
||||
},
|
||||
" - Auto-delete branch after merge: yes": {
|
||||
"en": " - Auto-delete branch after merge: yes",
|
||||
@@ -81,7 +203,11 @@
|
||||
"zh": " - 阻止被拒绝的审查: 是"
|
||||
},
|
||||
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
|
||||
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
|
||||
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
|
||||
"bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
|
||||
"de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
|
||||
"ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
|
||||
"zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
|
||||
},
|
||||
" - Dismiss stale approvals: yes": {
|
||||
"en": " - Dismiss stale approvals: yes",
|
||||
@@ -105,10 +231,18 @@
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" Created: {title}": {
|
||||
"en": " Created: {title}"
|
||||
"en": " Created: {title}",
|
||||
"bg": " Created: {title}",
|
||||
"de": " Created: {title}",
|
||||
"ru": " Created: {title}",
|
||||
"zh": " Created: {title}"
|
||||
},
|
||||
" FAIL: {title} — content mismatch or empty!": {
|
||||
"en": " FAIL: {title} — content mismatch or empty!"
|
||||
"en": " FAIL: {title} — content mismatch or empty!",
|
||||
"bg": " FAIL: {title} — content mismatch or empty!",
|
||||
"de": " FAIL: {title} — content mismatch or empty!",
|
||||
"ru": " FAIL: {title} — content mismatch or empty!",
|
||||
"zh": " FAIL: {title} — content mismatch or empty!"
|
||||
},
|
||||
" MISSING: devx {cmd}": {
|
||||
"en": " MISSING: devx {cmd}",
|
||||
@@ -117,14 +251,19 @@
|
||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
||||
"zh": " 缺失: devx {cmd}"
|
||||
},
|
||||
" MISSING: grm {cmd}": {
|
||||
"en": " MISSING: grm {cmd}"
|
||||
},
|
||||
" MISSING: {module}": {
|
||||
"en": " MISSING: {module}"
|
||||
"en": " MISSING: {module}",
|
||||
"bg": " MISSING: {module}",
|
||||
"de": " MISSING: {module}",
|
||||
"ru": " MISSING: {module}",
|
||||
"zh": " MISSING: {module}"
|
||||
},
|
||||
" MISSING: {script}": {
|
||||
"en": " MISSING: {script}"
|
||||
"en": " MISSING: {script}",
|
||||
"bg": " MISSING: {script}",
|
||||
"de": " MISSING: {script}",
|
||||
"ru": " MISSING: {script}",
|
||||
"zh": " MISSING: {script}"
|
||||
},
|
||||
" OK: devx {cmd}": {
|
||||
"en": " OK: devx {cmd}",
|
||||
@@ -133,41 +272,89 @@
|
||||
"ru": " ОК: devx {cmd}",
|
||||
"zh": " 正常: devx {cmd}"
|
||||
},
|
||||
" OK: grm {cmd}": {
|
||||
"en": " OK: grm {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"en": " OK: {module}"
|
||||
"en": " OK: {module}",
|
||||
"bg": " OK: {module}",
|
||||
"de": " OK: {module}",
|
||||
"ru": " OK: {module}",
|
||||
"zh": " OK: {module}"
|
||||
},
|
||||
" OK: {script}": {
|
||||
"en": " OK: {script}"
|
||||
"en": " OK: {script}",
|
||||
"bg": " OK: {script}",
|
||||
"de": " OK: {script}",
|
||||
"ru": " OK: {script}",
|
||||
"zh": " OK: {script}"
|
||||
},
|
||||
" OK: {title} ({chars} chars)": {
|
||||
"en": " OK: {title} ({chars} chars)"
|
||||
"en": " OK: {title} ({chars} chars)",
|
||||
"bg": " OK: {title} ({chars} chars)",
|
||||
"de": " OK: {title} ({chars} chars)",
|
||||
"ru": " OK: {title} ({chars} chars)",
|
||||
"zh": " OK: {title} ({chars} chars)"
|
||||
},
|
||||
" Updated: {title}": {
|
||||
"en": " Updated: {title}"
|
||||
"en": " Updated: {title}",
|
||||
"bg": " Updated: {title}",
|
||||
"de": " Updated: {title}",
|
||||
"ru": " Updated: {title}",
|
||||
"zh": " Updated: {title}"
|
||||
},
|
||||
"=== Release Alignment Verification ===\n": {
|
||||
"en": "=== Release Alignment Verification ===\n",
|
||||
"bg": "=== Release Alignment Verification ===\n",
|
||||
"de": "=== Release Alignment Verification ===\n",
|
||||
"ru": "=== Release Alignment Verification ===\n",
|
||||
"zh": "=== Release Alignment Verification ===\n"
|
||||
},
|
||||
"API poll warning: {exc}": {
|
||||
"en": "API poll warning: {exc}"
|
||||
"en": "API poll warning: {exc}",
|
||||
"bg": "API poll warning: {exc}",
|
||||
"de": "API poll warning: {exc}",
|
||||
"ru": "API poll warning: {exc}",
|
||||
"zh": "API poll warning: {exc}"
|
||||
},
|
||||
"All molecule tests passed.": {
|
||||
"en": "All molecule tests passed."
|
||||
"en": "All molecule tests passed.",
|
||||
"bg": "All molecule tests passed.",
|
||||
"de": "All molecule tests passed.",
|
||||
"ru": "All molecule tests passed.",
|
||||
"zh": "All molecule tests passed."
|
||||
},
|
||||
"Another molecule runner failed. Stopping this runner early.": {
|
||||
"en": "Another molecule runner failed. Stopping this runner early."
|
||||
"en": "Another molecule runner failed. Stopping this runner early.",
|
||||
"bg": "Another molecule runner failed. Stopping this runner early.",
|
||||
"de": "Another molecule runner failed. Stopping this runner early.",
|
||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||
},
|
||||
"Bumping version: {current} -> v{new_version}": {
|
||||
"en": "Bumping version: {current} -> v{new_version}"
|
||||
"en": "Bumping version: {current} -> v{new_version}",
|
||||
"bg": "Bumping version: {current} -> v{new_version}",
|
||||
"de": "Bumping version: {current} -> v{new_version}",
|
||||
"ru": "Bumping version: {current} -> v{new_version}",
|
||||
"zh": "Bumping version: {current} -> v{new_version}"
|
||||
},
|
||||
"Checking CLI command documentation...": {
|
||||
"en": "Checking CLI command documentation..."
|
||||
"en": "Checking CLI command documentation...",
|
||||
"bg": "Checking CLI command documentation...",
|
||||
"de": "Checking CLI command documentation...",
|
||||
"ru": "Checking CLI command documentation...",
|
||||
"zh": "Checking CLI command documentation..."
|
||||
},
|
||||
"Command failed ({cmd}): {stderr}": {
|
||||
"en": "Command failed ({cmd}): {stderr}"
|
||||
"en": "Command failed ({cmd}): {stderr}",
|
||||
"bg": "Command failed ({cmd}): {stderr}",
|
||||
"de": "Command failed ({cmd}): {stderr}",
|
||||
"ru": "Command failed ({cmd}): {stderr}",
|
||||
"zh": "Command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"Comparing {base}..{head} ({count} files changed)": {
|
||||
"en": "Comparing {base}..{head} ({count} files changed)"
|
||||
"en": "Comparing {base}..{head} ({count} files changed)",
|
||||
"bg": "Comparing {base}..{head} ({count} files changed)",
|
||||
"de": "Comparing {base}..{head} ({count} files changed)",
|
||||
"ru": "Comparing {base}..{head} ({count} files changed)",
|
||||
"zh": "Comparing {base}..{head} ({count} files changed)"
|
||||
},
|
||||
"Configuring branch protection for {branch}...": {
|
||||
"en": "Configuring branch protection for {branch}...",
|
||||
@@ -184,25 +371,53 @@
|
||||
"zh": "正在配置仓库设置..."
|
||||
},
|
||||
"Could not extract conventional commit message from PR commits.": {
|
||||
"en": "Could not extract conventional commit message from PR commits."
|
||||
"en": "Could not extract conventional commit message from PR commits.",
|
||||
"bg": "Could not extract conventional commit message from PR commits.",
|
||||
"de": "Could not extract conventional commit message from PR commits.",
|
||||
"ru": "Could not extract conventional commit message from PR commits.",
|
||||
"zh": "Could not extract conventional commit message from PR commits."
|
||||
},
|
||||
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
|
||||
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
|
||||
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
|
||||
},
|
||||
"Could not find __version__ in {file}": {
|
||||
"en": "Could not find __version__ in {file}"
|
||||
"en": "Could not find __version__ in {file}",
|
||||
"bg": "Could not find __version__ in {file}",
|
||||
"de": "Could not find __version__ in {file}",
|
||||
"ru": "Could not find __version__ in {file}",
|
||||
"zh": "Could not find __version__ in {file}"
|
||||
},
|
||||
"Could not parse test execution time from output.": {
|
||||
"en": "Could not parse test execution time from output."
|
||||
"en": "Could not parse test execution time from output.",
|
||||
"bg": "Could not parse test execution time from output.",
|
||||
"de": "Could not parse test execution time from output.",
|
||||
"ru": "Could not parse test execution time from output.",
|
||||
"zh": "Could not parse test execution time from output."
|
||||
},
|
||||
"Created issue #{issue_id}: {title}": {
|
||||
"en": "Created issue #{issue_id}: {title}"
|
||||
"en": "Created issue #{issue_id}: {title}",
|
||||
"bg": "Created issue #{issue_id}: {title}",
|
||||
"de": "Created issue #{issue_id}: {title}",
|
||||
"ru": "Created issue #{issue_id}: {title}",
|
||||
"zh": "Created issue #{issue_id}: {title}"
|
||||
},
|
||||
"Created release commit.": {
|
||||
"en": "Created release commit."
|
||||
"en": "Created release commit.",
|
||||
"bg": "Created release commit.",
|
||||
"de": "Created release commit.",
|
||||
"ru": "Created release commit.",
|
||||
"zh": "Created release commit."
|
||||
},
|
||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
||||
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
|
||||
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
|
||||
},
|
||||
"ERROR: REPO_TOKEN is not set.": {
|
||||
"en": "ERROR: REPO_TOKEN is not set.",
|
||||
@@ -211,13 +426,6 @@
|
||||
"ru": "ОШИБКА: REPO_TOKEN не задан.",
|
||||
"zh": "错误:未设置 REPO_TOKEN。"
|
||||
},
|
||||
"ERROR: VIKUNJA_TOKEN is not set.": {
|
||||
"en": "ERROR: VIKUNJA_TOKEN is not set.",
|
||||
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
|
||||
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
|
||||
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
|
||||
"zh": "错误:未设置 VIKUNJA_TOKEN。"
|
||||
},
|
||||
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
|
||||
"en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.",
|
||||
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
|
||||
@@ -225,23 +433,82 @@
|
||||
"ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.",
|
||||
"zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。"
|
||||
},
|
||||
"ERROR: Tag consistency check failed. Existing tags are misaligned:": {
|
||||
"en": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
|
||||
"bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
|
||||
"de": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
|
||||
"ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
|
||||
"zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:"
|
||||
},
|
||||
"ERROR: VIKUNJA_TOKEN is not set.": {
|
||||
"en": "ERROR: VIKUNJA_TOKEN is not set.",
|
||||
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
|
||||
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
|
||||
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
|
||||
"zh": "错误:未设置 VIKUNJA_TOKEN。"
|
||||
},
|
||||
"ERROR: mapping.json not found at {path}": {
|
||||
"en": "ERROR: mapping.json not found at {path}"
|
||||
"en": "ERROR: mapping.json not found at {path}",
|
||||
"bg": "ERROR: mapping.json not found at {path}",
|
||||
"de": "ERROR: mapping.json not found at {path}",
|
||||
"ru": "ERROR: mapping.json not found at {path}",
|
||||
"zh": "ERROR: mapping.json not found at {path}"
|
||||
},
|
||||
"FAILED: {pair} exited with code {code}": {
|
||||
"en": "FAILED: {pair} exited with code {code}"
|
||||
"en": "FAILED: {pair} exited with code {code}",
|
||||
"bg": "FAILED: {pair} exited with code {code}",
|
||||
"de": "FAILED: {pair} exited with code {code}",
|
||||
"ru": "FAILED: {pair} exited with code {code}",
|
||||
"zh": "FAILED: {pair} exited with code {code}"
|
||||
},
|
||||
"Failed to create issue via tea: {error}": {
|
||||
"en": "Failed to create issue via tea: {error}"
|
||||
"en": "Failed to create issue via tea: {error}",
|
||||
"bg": "Failed to create issue via tea: {error}",
|
||||
"de": "Failed to create issue via tea: {error}",
|
||||
"ru": "Failed to create issue via tea: {error}",
|
||||
"zh": "Failed to create issue via tea: {error}"
|
||||
},
|
||||
"Found {count} existing wiki pages.": {
|
||||
"en": "Found {count} existing wiki pages."
|
||||
"en": "Found {count} existing wiki pages.",
|
||||
"bg": "Found {count} existing wiki pages.",
|
||||
"de": "Found {count} existing wiki pages.",
|
||||
"ru": "Found {count} existing wiki pages.",
|
||||
"zh": "Found {count} existing wiki pages."
|
||||
},
|
||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
||||
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
},
|
||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.": {
|
||||
"en": "HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping."
|
||||
"Generated {file} with prefix '{prefix}'.": {
|
||||
"en": "Generated {file} with prefix '{prefix}'.",
|
||||
"bg": "Generated {file} with prefix '{prefix}'.",
|
||||
"de": "Generated {file} with prefix '{prefix}'.",
|
||||
"ru": "Generated {file} with prefix '{prefix}'.",
|
||||
"zh": "Generated {file} with prefix '{prefix}'."
|
||||
},
|
||||
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": {
|
||||
"en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
|
||||
"bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
|
||||
"de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
|
||||
"ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
|
||||
"zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag."
|
||||
},
|
||||
"HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.": {
|
||||
"en": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
|
||||
"bg": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
|
||||
"de": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
|
||||
"ru": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
|
||||
"zh": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment."
|
||||
},
|
||||
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": {
|
||||
"en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
|
||||
},
|
||||
"HTTP error: {status} — {message}": {
|
||||
"en": "HTTP error: {status} — {message}",
|
||||
@@ -258,7 +525,11 @@
|
||||
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
|
||||
},
|
||||
"Head branch is behind master. Pulling and rebasing...": {
|
||||
"en": "Head branch is behind master. Pulling and rebasing..."
|
||||
"en": "Head branch is behind master. Pulling and rebasing...",
|
||||
"bg": "Head branch is behind master. Pulling and rebasing...",
|
||||
"de": "Head branch is behind master. Pulling and rebasing...",
|
||||
"ru": "Head branch is behind master. Pulling and rebasing...",
|
||||
"zh": "Head branch is behind master. Pulling and rebasing..."
|
||||
},
|
||||
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
|
||||
"en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
|
||||
@@ -267,17 +538,40 @@
|
||||
"ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}",
|
||||
"zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}"
|
||||
},
|
||||
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}": {
|
||||
"en": "Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}"
|
||||
},
|
||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
|
||||
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
|
||||
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
"ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
"zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
|
||||
},
|
||||
"Lint passed.": {
|
||||
"en": "Lint passed."
|
||||
"en": "Lint passed.",
|
||||
"bg": "Lint passed.",
|
||||
"de": "Lint passed.",
|
||||
"ru": "Lint passed.",
|
||||
"zh": "Lint passed."
|
||||
},
|
||||
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
|
||||
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
|
||||
},
|
||||
"Mapped file {file} not found. Update mapping.json or create the file.": {
|
||||
"en": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"bg": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"de": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"ru": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"zh": "Mapped file {file} not found. Update mapping.json or create the file."
|
||||
},
|
||||
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
|
||||
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
|
||||
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
"bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
"de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
"ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
"zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
|
||||
},
|
||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
|
||||
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
||||
@@ -315,7 +609,11 @@
|
||||
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
|
||||
},
|
||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
|
||||
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
|
||||
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
"bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
"de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
"ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
"zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
|
||||
},
|
||||
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
|
||||
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
||||
@@ -325,22 +623,53 @@
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"No changes between {base} and {head}.": {
|
||||
"en": "No changes between {base} and {head}."
|
||||
"en": "No changes between {base} and {head}.",
|
||||
"bg": "No changes between {base} and {head}.",
|
||||
"de": "No changes between {base} and {head}.",
|
||||
"ru": "No changes between {base} and {head}.",
|
||||
"zh": "No changes between {base} and {head}."
|
||||
},
|
||||
"No staged changes — version and changelog already up to date.": {
|
||||
"en": "No staged changes — version and changelog already up to date."
|
||||
"en": "No staged changes — version and changelog already up to date.",
|
||||
"bg": "No staged changes — version and changelog already up to date.",
|
||||
"de": "No staged changes — version and changelog already up to date.",
|
||||
"ru": "No staged changes — version and changelog already up to date.",
|
||||
"zh": "No staged changes — version and changelog already up to date."
|
||||
},
|
||||
"No tags found — treating all changes as user-facing.": {
|
||||
"en": "No tags found — treating all changes as user-facing."
|
||||
"en": "No tags found — treating all changes as user-facing.",
|
||||
"bg": "No tags found — treating all changes as user-facing.",
|
||||
"de": "No tags found — treating all changes as user-facing.",
|
||||
"ru": "No tags found — treating all changes as user-facing.",
|
||||
"zh": "No tags found — treating all changes as user-facing."
|
||||
},
|
||||
"No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": {
|
||||
"en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
||||
"bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
||||
"de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
||||
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
||||
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
|
||||
},
|
||||
"No unreleased changes found. Nothing to release.": {
|
||||
"en": "No unreleased changes found. Nothing to release."
|
||||
"en": "No unreleased changes found. Nothing to release.",
|
||||
"bg": "No unreleased changes found. Nothing to release.",
|
||||
"de": "No unreleased changes found. Nothing to release.",
|
||||
"ru": "No unreleased changes found. Nothing to release.",
|
||||
"zh": "No unreleased changes found. Nothing to release."
|
||||
},
|
||||
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
|
||||
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
||||
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
||||
},
|
||||
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
||||
"en": "Note: Self-approval not allowed. Posting COMMENT instead."
|
||||
"en": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
|
||||
},
|
||||
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
|
||||
"en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
@@ -349,19 +678,12 @@
|
||||
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
|
||||
},
|
||||
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
||||
"en": "Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"bg": "Опа! Не включвайте идентификатор на задача (DEVX-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
|
||||
"de": "Ups! Keine Task-ID (DEVX-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
|
||||
"ru": "Ой! Не включайте ID задачи (DEVX-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
|
||||
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (DEVX-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
|
||||
},
|
||||
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
||||
"en": "Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"bg": "Опа! Не включвайте идентификатор на задача (GRM-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
|
||||
"de": "Ups! Keine Task-ID (GRM-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
|
||||
"ru": "Ой! Не включайте ID задачи (GRM-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
|
||||
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (GRM-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
|
||||
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
||||
"en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"bg": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"de": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"ru": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"zh": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI."
|
||||
},
|
||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}": {
|
||||
"en": "Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
||||
@@ -370,49 +692,40 @@
|
||||
"ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}",
|
||||
"zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}"
|
||||
},
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: DEVX-N: <type>: <description>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: DEVX-N: <type>: <description>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: DEVX-N: <type>: <description>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: DEVX-N: <type>: <description>\n 实际: {subject}"
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}"
|
||||
},
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: <type>: <description>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: <type>: <description>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: <type>: <description>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: <type>: <description>\n 实际: {subject}"
|
||||
},
|
||||
"Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: DEVX-N: <conventional commit message>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: DEVX-N: <conventional commit message>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: DEVX-N: <conventional commit message>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: DEVX-N: <conventional commit message>\n 实际: {subject}"
|
||||
},
|
||||
"Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: <conventional commit message>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: <conventional commit message>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: <conventional commit message>\n Получено: {subject}",
|
||||
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: <conventional commit message>\n 实际: {subject}"
|
||||
"Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}"
|
||||
},
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
|
||||
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'."
|
||||
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"de": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"zh": "Oops! No task ID found in .taskid file or branch name '{branch}'."
|
||||
},
|
||||
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"bg": "Опа! Заглавието на PR трябва да следва формата 'DEVX-N: <заглавие на задача>'.\n Очаква се: {task_id}: <заглавие на задача>\n Получено: {pr_title}",
|
||||
"de": "Ups! PR-Titel muss dem Format 'DEVX-N: <Task-Titel>' folgen.\n Erwartet: {task_id}: <Task-Titel>\n Erhalten: {pr_title}",
|
||||
"ru": "Ой! Заголовок PR должен соответствовать формату 'DEVX-N: <название задачи>'.\n Ожидается: {task_id}: <название задачи>\n Получено: {pr_title}",
|
||||
"zh": "哎呀!PR 标题必须遵循格式 'DEVX-N: <任务标题>'。\n 预期格式: {task_id}: <任务标题>\n 实际: {pr_title}"
|
||||
},
|
||||
"Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
|
||||
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
|
||||
},
|
||||
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
|
||||
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
|
||||
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
"bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
"de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
"ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
"zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
|
||||
},
|
||||
"Oops! Package build failed:\n{stderr}": {
|
||||
"en": "Oops! Package build failed:\n{stderr}",
|
||||
@@ -429,10 +742,25 @@
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"en": "PASSED: {pair}"
|
||||
"en": "PASSED: {pair}",
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
"ru": "PASSED: {pair}",
|
||||
"zh": "PASSED: {pair}"
|
||||
},
|
||||
"PR number must be an integer, got: {pr_number}": {
|
||||
"en": "PR number must be an integer, got: {pr_number}",
|
||||
"bg": "PR number must be an integer, got: {pr_number}",
|
||||
"de": "PR number must be an integer, got: {pr_number}",
|
||||
"ru": "PR number must be an integer, got: {pr_number}",
|
||||
"zh": "PR number must be an integer, got: {pr_number}"
|
||||
},
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
|
||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
||||
},
|
||||
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
||||
"en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.",
|
||||
@@ -441,13 +769,6 @@
|
||||
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
||||
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
||||
"en": "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.",
|
||||
"bg": "PYPI_TOKEN не е зададен — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
|
||||
"de": "PYPI_TOKEN nicht gesetzt — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
|
||||
"ru": "PYPI_TOKEN не задан — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
||||
"zh": "未设置 PYPI_TOKEN — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"Published to Gitea PyPI registry.": {
|
||||
"en": "Published to Gitea PyPI registry.",
|
||||
"bg": "Публикувано в Gitea PyPI registry.",
|
||||
@@ -463,16 +784,39 @@
|
||||
"zh": "已发布到 PyPI。"
|
||||
},
|
||||
"Pushed release commit to master.": {
|
||||
"en": "Pushed release commit to master."
|
||||
"en": "Pushed release commit to master.",
|
||||
"bg": "Pushed release commit to master.",
|
||||
"de": "Pushed release commit to master.",
|
||||
"ru": "Pushed release commit to master.",
|
||||
"zh": "Pushed release commit to master."
|
||||
},
|
||||
"Rebased and pushed. Retrying merge...": {
|
||||
"en": "Rebased and pushed. Retrying merge..."
|
||||
"en": "Rebased and pushed. Retrying merge...",
|
||||
"bg": "Rebased and pushed. Retrying merge...",
|
||||
"de": "Rebased and pushed. Retrying merge...",
|
||||
"ru": "Rebased and pushed. Retrying merge...",
|
||||
"zh": "Rebased and pushed. Retrying merge..."
|
||||
},
|
||||
"Release creation failed: {error}": {
|
||||
"en": "Release creation failed: {error}"
|
||||
"en": "Release creation failed: {error}",
|
||||
"bg": "Release creation failed: {error}",
|
||||
"de": "Release creation failed: {error}",
|
||||
"ru": "Release creation failed: {error}",
|
||||
"zh": "Release creation failed: {error}"
|
||||
},
|
||||
"Release must be run on master, currently on '{branch}'.": {
|
||||
"en": "Release must be run on master, currently on '{branch}'."
|
||||
"en": "Release must be run on master, currently on '{branch}'.",
|
||||
"bg": "Release must be run on master, currently on '{branch}'.",
|
||||
"de": "Release must be run on master, currently on '{branch}'.",
|
||||
"ru": "Release must be run on master, currently on '{branch}'.",
|
||||
"zh": "Release must be run on master, currently on '{branch}'."
|
||||
},
|
||||
"Repo must be in 'owner/name' format, got: {repo}": {
|
||||
"en": "Repo must be in 'owner/name' format, got: {repo}",
|
||||
"bg": "Repo must be in 'owner/name' format, got: {repo}",
|
||||
"de": "Repo must be in 'owner/name' format, got: {repo}",
|
||||
"ru": "Repo must be in 'owner/name' format, got: {repo}",
|
||||
"zh": "Repo must be in 'owner/name' format, got: {repo}"
|
||||
},
|
||||
"Repository configuration complete.": {
|
||||
"en": "Repository configuration complete.",
|
||||
@@ -489,101 +833,221 @@
|
||||
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
|
||||
},
|
||||
"Running lint checks...": {
|
||||
"en": "Running lint checks..."
|
||||
"en": "Running lint checks...",
|
||||
"bg": "Running lint checks...",
|
||||
"de": "Running lint checks...",
|
||||
"ru": "Running lint checks...",
|
||||
"zh": "Running lint checks..."
|
||||
},
|
||||
"Running tests...": {
|
||||
"en": "Running tests..."
|
||||
"en": "Running tests...",
|
||||
"bg": "Running tests...",
|
||||
"de": "Running tests...",
|
||||
"ru": "Running tests...",
|
||||
"zh": "Running tests..."
|
||||
},
|
||||
"Running: {scenario} on {platform}": {
|
||||
"en": "Running: {scenario} on {platform}"
|
||||
"en": "Running: {scenario} on {platform}",
|
||||
"bg": "Running: {scenario} on {platform}",
|
||||
"de": "Running: {scenario} on {platform}",
|
||||
"ru": "Running: {scenario} on {platform}",
|
||||
"zh": "Running: {scenario} on {platform}"
|
||||
},
|
||||
"Skipping commit push — no staged changes.": {
|
||||
"en": "Skipping commit push — no staged changes."
|
||||
"en": "Skipping commit push — no staged changes.",
|
||||
"bg": "Skipping commit push — no staged changes.",
|
||||
"de": "Skipping commit push — no staged changes.",
|
||||
"ru": "Skipping commit push — no staged changes.",
|
||||
"zh": "Skipping commit push — no staged changes."
|
||||
},
|
||||
"Syncing {count} documentation pages to wiki...": {
|
||||
"en": "Syncing {count} documentation pages to wiki..."
|
||||
"en": "Syncing {count} documentation pages to wiki...",
|
||||
"bg": "Syncing {count} documentation pages to wiki...",
|
||||
"de": "Syncing {count} documentation pages to wiki...",
|
||||
"ru": "Syncing {count} documentation pages to wiki...",
|
||||
"zh": "Syncing {count} documentation pages to wiki..."
|
||||
},
|
||||
"Tag consistency check failed.": {
|
||||
"en": "Tag consistency check failed.",
|
||||
"bg": "Tag consistency check failed.",
|
||||
"de": "Tag consistency check failed.",
|
||||
"ru": "Tag consistency check failed.",
|
||||
"zh": "Tag consistency check failed."
|
||||
},
|
||||
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
||||
"en": "Tag v{version} already existed. Publish workflow should already have been triggered."
|
||||
"en": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
"ru": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
"zh": "Tag v{version} already existed. Publish workflow should already have been triggered."
|
||||
},
|
||||
"Tag {tag} already exists, skipping creation.": {
|
||||
"en": "Tag {tag} already exists, skipping creation."
|
||||
"Tag {tag} already exists and points to HEAD. Skipping creation.": {
|
||||
"en": "Tag {tag} already exists and points to HEAD. Skipping creation.",
|
||||
"bg": "Tag {tag} already exists and points to HEAD. Skipping creation.",
|
||||
"de": "Tag {tag} already exists and points to HEAD. Skipping creation.",
|
||||
"ru": "Tag {tag} already exists and points to HEAD. Skipping creation.",
|
||||
"zh": "Tag {tag} already exists and points to HEAD. Skipping creation."
|
||||
},
|
||||
"Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.": {
|
||||
"en": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
"bg": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
"de": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
"ru": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
"zh": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details."
|
||||
},
|
||||
"Task ID: {task_id}": {
|
||||
"en": "Task ID: {task_id}"
|
||||
"en": "Task ID: {task_id}",
|
||||
"bg": "Task ID: {task_id}",
|
||||
"de": "Task ID: {task_id}",
|
||||
"ru": "Task ID: {task_id}",
|
||||
"zh": "Task ID: {task_id}"
|
||||
},
|
||||
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
|
||||
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
|
||||
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
||||
"bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
||||
"de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
||||
"ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
||||
"zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
|
||||
},
|
||||
"Tests passed.": {
|
||||
"en": "Tests passed."
|
||||
"en": "Tests passed.",
|
||||
"bg": "Tests passed.",
|
||||
"de": "Tests passed.",
|
||||
"ru": "Tests passed.",
|
||||
"zh": "Tests passed."
|
||||
},
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).": {
|
||||
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
|
||||
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
||||
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
||||
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
||||
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
||||
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
|
||||
},
|
||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
|
||||
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
|
||||
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
"bg": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
"de": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
"ru": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
"zh": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
|
||||
},
|
||||
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
|
||||
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"bg": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"de": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
||||
},
|
||||
"Updated version in {init}": {
|
||||
"en": "Updated version in {init}"
|
||||
"en": "Updated version in {init}",
|
||||
"bg": "Updated version in {init}",
|
||||
"de": "Updated version in {init}",
|
||||
"ru": "Updated version in {init}",
|
||||
"zh": "Updated version in {init}"
|
||||
},
|
||||
"Updated {changelog_file}": {
|
||||
"en": "Updated {changelog_file}"
|
||||
"en": "Updated {changelog_file}",
|
||||
"bg": "Updated {changelog_file}",
|
||||
"de": "Updated {changelog_file}",
|
||||
"ru": "Updated {changelog_file}",
|
||||
"zh": "Updated {changelog_file}"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
|
||||
"en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||
"ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||
"zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."
|
||||
},
|
||||
"Version file: {file}": {
|
||||
"en": "Version file: {file}",
|
||||
"bg": "Version file: {file}",
|
||||
"de": "Version file: {file}",
|
||||
"ru": "Version file: {file}",
|
||||
"zh": "Version file: {file}"
|
||||
},
|
||||
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
|
||||
"en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
|
||||
},
|
||||
"WARNING: --skip-tests passed — skipping test verification.": {
|
||||
"en": "WARNING: --skip-tests passed — skipping test verification."
|
||||
"en": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"bg": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"de": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"ru": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"zh": "WARNING: --skip-tests passed — skipping test verification."
|
||||
},
|
||||
"WARNING: File {file} is empty — skipping.": {
|
||||
"en": "WARNING: File {file} is empty — skipping."
|
||||
},
|
||||
"WARNING: File {file} not found — skipping.": {
|
||||
"en": "WARNING: File {file} not found — skipping."
|
||||
},
|
||||
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.": {
|
||||
"en": "Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
|
||||
"bg": "Предупреждение: Не е намерен идентификатор на задача (DEVX-N) в съобщението за commit: {msg}. Пропускаме обновяването на Vikunja.",
|
||||
"de": "Warnung: Keine Task-ID (DEVX-N) in Commit-Nachricht gefunden: {msg}. Vikunja-Update wird übersprungen.",
|
||||
"ru": "Предупреждение: ID задачи (DEVX-N) не найден в сообщении коммита: {msg}. Пропуск обновления Vikunja.",
|
||||
"zh": "警告:提交消息中未找到任务 ID (DEVX-N): {msg}。跳过 Vikunja 更新。"
|
||||
},
|
||||
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.": {
|
||||
"en": "Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update."
|
||||
},
|
||||
"Warning: VIKUNJA_TOKEN not set, skipping title match validation.": {
|
||||
"en": "Warning: VIKUNJA_TOKEN not set, skipping title match validation."
|
||||
},
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually.": {
|
||||
"en": "Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually."
|
||||
},
|
||||
"Warning: git-cliff generated empty changelog.": {
|
||||
"en": "Warning: git-cliff generated empty changelog."
|
||||
"Warning: could not fetch tags from origin.": {
|
||||
"en": "Warning: could not fetch tags from origin.",
|
||||
"bg": "Warning: could not fetch tags from origin.",
|
||||
"de": "Warning: could not fetch tags from origin.",
|
||||
"ru": "Warning: could not fetch tags from origin.",
|
||||
"zh": "Warning: could not fetch tags from origin."
|
||||
},
|
||||
"Wiki integrity check failed — {count} issue(s)": {
|
||||
"en": "Wiki integrity check failed — {count} issue(s)"
|
||||
"en": "Wiki integrity check failed — {count} issue(s)",
|
||||
"bg": "Wiki integrity check failed — {count} issue(s)",
|
||||
"de": "Wiki integrity check failed — {count} issue(s)",
|
||||
"ru": "Wiki integrity check failed — {count} issue(s)",
|
||||
"zh": "Wiki integrity check failed — {count} issue(s)"
|
||||
},
|
||||
"Wiki verification failed — {failures} page(s) empty or mismatched": {
|
||||
"en": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
"en": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"de": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version}": {
|
||||
"en": "[dry-run] Would commit: release: v{version}"
|
||||
"en": "[dry-run] Would commit: release: v{version}",
|
||||
"bg": "[dry-run] Would commit: release: v{version}",
|
||||
"de": "[dry-run] Would commit: release: v{version}",
|
||||
"ru": "[dry-run] Would commit: release: v{version}",
|
||||
"zh": "[dry-run] Would commit: release: v{version}"
|
||||
},
|
||||
"[dry-run] Would create tag: v{version}": {
|
||||
"en": "[dry-run] Would create tag: v{version}"
|
||||
"en": "[dry-run] Would create tag: v{version}",
|
||||
"bg": "[dry-run] Would create tag: v{version}",
|
||||
"de": "[dry-run] Would create tag: v{version}",
|
||||
"ru": "[dry-run] Would create tag: v{version}",
|
||||
"zh": "[dry-run] Would create tag: v{version}"
|
||||
},
|
||||
"[dry-run] Would create tag: {tag}": {
|
||||
"en": "[dry-run] Would create tag: {tag}"
|
||||
"en": "[dry-run] Would create tag: {tag}",
|
||||
"bg": "[dry-run] Would create tag: {tag}",
|
||||
"de": "[dry-run] Would create tag: {tag}",
|
||||
"ru": "[dry-run] Would create tag: {tag}",
|
||||
"zh": "[dry-run] Would create tag: {tag}"
|
||||
},
|
||||
"[dry-run] Would push commit to master": {
|
||||
"en": "[dry-run] Would push commit to master"
|
||||
"en": "[dry-run] Would push commit to master",
|
||||
"bg": "[dry-run] Would push commit to master",
|
||||
"de": "[dry-run] Would push commit to master",
|
||||
"ru": "[dry-run] Would push commit to master",
|
||||
"zh": "[dry-run] Would push commit to master"
|
||||
},
|
||||
"[dry-run] Would sync page: {title} ({chars} chars)": {
|
||||
"en": "[dry-run] Would sync page: {title} ({chars} chars)"
|
||||
"en": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"bg": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"de": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"ru": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"zh": "[dry-run] Would sync page: {title} ({chars} chars)"
|
||||
},
|
||||
"[dry-run] Would update {changelog_file}": {
|
||||
"en": "[dry-run] Would update {changelog_file}"
|
||||
"en": "[dry-run] Would update {changelog_file}",
|
||||
"bg": "[dry-run] Would update {changelog_file}",
|
||||
"de": "[dry-run] Would update {changelog_file}",
|
||||
"ru": "[dry-run] Would update {changelog_file}",
|
||||
"zh": "[dry-run] Would update {changelog_file}"
|
||||
},
|
||||
"[dry-run] Would update {init}": {
|
||||
"en": "[dry-run] Would update {init}"
|
||||
"en": "[dry-run] Would update {init}",
|
||||
"bg": "[dry-run] Would update {init}",
|
||||
"de": "[dry-run] Would update {init}",
|
||||
"ru": "[dry-run] Would update {init}",
|
||||
"zh": "[dry-run] Would update {init}"
|
||||
},
|
||||
"active": {
|
||||
"en": "active",
|
||||
@@ -607,10 +1071,39 @@
|
||||
"zh": "失败"
|
||||
},
|
||||
"git command failed ({cmd}): {stderr}": {
|
||||
"en": "git command failed ({cmd}): {stderr}"
|
||||
"en": "git command failed ({cmd}): {stderr}",
|
||||
"bg": "git command failed ({cmd}): {stderr}",
|
||||
"de": "git command failed ({cmd}): {stderr}",
|
||||
"ru": "git command failed ({cmd}): {stderr}",
|
||||
"zh": "git command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": {
|
||||
"en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
|
||||
"bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
|
||||
"de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
|
||||
"ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
|
||||
"zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history."
|
||||
},
|
||||
"git-cliff returned empty version.": {
|
||||
"en": "git-cliff returned empty version."
|
||||
"en": "git-cliff returned empty version.",
|
||||
"bg": "git-cliff returned empty version.",
|
||||
"de": "git-cliff returned empty version.",
|
||||
"ru": "git-cliff returned empty version.",
|
||||
"zh": "git-cliff returned empty version."
|
||||
},
|
||||
"git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": {
|
||||
"en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
|
||||
"bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
|
||||
"de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
|
||||
"ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
|
||||
"zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)."
|
||||
},
|
||||
"in_progress": {
|
||||
"en": "in progress",
|
||||
"bg": "в процес",
|
||||
"de": "in Bearbeitung",
|
||||
"ru": "в процессе",
|
||||
"zh": "进行中"
|
||||
},
|
||||
"inactive": {
|
||||
"en": "inactive",
|
||||
@@ -619,12 +1112,19 @@
|
||||
"ru": "неактивен",
|
||||
"zh": "未激活"
|
||||
},
|
||||
"in_progress": {
|
||||
"en": "in progress",
|
||||
"bg": "в процес",
|
||||
"de": "in Bearbeitung",
|
||||
"ru": "в процессе",
|
||||
"zh": "进行中"
|
||||
"mapping.json keys and values must be strings, got {k}={v}": {
|
||||
"en": "mapping.json keys and values must be strings, got {k}={v}",
|
||||
"bg": "mapping.json keys and values must be strings, got {k}={v}",
|
||||
"de": "mapping.json keys and values must be strings, got {k}={v}",
|
||||
"ru": "mapping.json keys and values must be strings, got {k}={v}",
|
||||
"zh": "mapping.json keys and values must be strings, got {k}={v}"
|
||||
},
|
||||
"mapping.json must be a dict of file-path -> page-title, got {type}": {
|
||||
"en": "mapping.json must be a dict of file-path -> page-title, got {type}",
|
||||
"bg": "mapping.json must be a dict of file-path -> page-title, got {type}",
|
||||
"de": "mapping.json must be a dict of file-path -> page-title, got {type}",
|
||||
"ru": "mapping.json must be a dict of file-path -> page-title, got {type}",
|
||||
"zh": "mapping.json must be a dict of file-path -> page-title, got {type}"
|
||||
},
|
||||
"pending": {
|
||||
"en": "pending",
|
||||
@@ -640,16 +1140,11 @@
|
||||
"ru": "неизвестно",
|
||||
"zh": "未知"
|
||||
},
|
||||
"\n{label} files changed ({count}):": {
|
||||
"en": "\n{label} files changed ({count}):"
|
||||
},
|
||||
"\n{tag} files ({count}):": {
|
||||
"en": "\n{tag} files ({count}):"
|
||||
},
|
||||
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
|
||||
},
|
||||
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
|
||||
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
||||
"{file} already exists. Use --force to overwrite.": {
|
||||
"en": "{file} already exists. Use --force to overwrite.",
|
||||
"bg": "{file} already exists. Use --force to overwrite.",
|
||||
"de": "{file} already exists. Use --force to overwrite.",
|
||||
"ru": "{file} already exists. Use --force to overwrite.",
|
||||
"zh": "{file} already exists. Use --force to overwrite."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +77,10 @@ class TestValidatePrTitle:
|
||||
|
||||
class TestValidatePrTitleMatchesVikunja:
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_skips_when_no_token(self) -> None:
|
||||
# Should not raise — just warn
|
||||
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
|
||||
def test_raises_when_no_token(self) -> None:
|
||||
"""Should raise ClickException when VIKUNJA_TOKEN is not set."""
|
||||
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN is not set"):
|
||||
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
||||
@@ -192,9 +193,12 @@ class TestRunCmd:
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": ""}, clear=True)
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_full_merge_flow(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_full_merge_flow(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
@@ -210,7 +214,7 @@ class TestMain:
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.merge_pr.assert_called_once_with("7", "DEVX-19: fix: resolve timeout")
|
||||
mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_no_token_raises(self) -> None:
|
||||
@@ -240,9 +244,12 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "format" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_merge_behind_master_rebases(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_merge_behind_master_rebases(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
@@ -267,9 +274,12 @@ class TestMain:
|
||||
# Should have fetched, rebased, and pushed
|
||||
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_merge_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_merge_failure_raises(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
@@ -288,9 +298,12 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "Merge failed" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_no_conventional_msg_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_no_conventional_msg_raises(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When no conventional commit message is found in PR commits, raises."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
@@ -308,8 +321,31 @@ class TestMain:
|
||||
assert "conventional commit" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
|
||||
"""Non-integer PR number should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"])
|
||||
assert result.exit_code != 0
|
||||
assert "PR number must be an integer" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
|
||||
"""Repo without owner/name should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner/name" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_rebase_retry_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_rebase_retry_failure_raises(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When rebase retry also fails, raises with helpful message."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
@@ -67,7 +67,7 @@ class TestCheckTranslationSet:
|
||||
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
|
||||
|
||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
||||
assert any("Dead key" in w for w in result.warnings)
|
||||
assert any("Dead key" in e for e in result.errors)
|
||||
|
||||
def test_missing_language(self, tmp_path: Path) -> None:
|
||||
src_dir = tmp_path / "src"
|
||||
@@ -77,7 +77,7 @@ class TestCheckTranslationSet:
|
||||
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
|
||||
|
||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
||||
assert any("Missing languages" in w for w in result.warnings)
|
||||
assert any("Missing languages" in e for e in result.errors)
|
||||
|
||||
def test_missing_translations_file(self, tmp_path: Path) -> None:
|
||||
src_dir = tmp_path / "src"
|
||||
@@ -108,23 +108,19 @@ class TestMain:
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_strict_fails_on_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--strict should fail if there are missing language warnings."""
|
||||
warn_result = check_translations.TranslationCheckResult(
|
||||
def test_errors_fail(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Errors should cause exit code 1."""
|
||||
error_result = check_translations.TranslationCheckResult(
|
||||
name="devx",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
warnings=["Dead key: 'bar'"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_translations,
|
||||
"check_translation_set",
|
||||
lambda name, src, trans: warn_result,
|
||||
errors=["Dead key: 'bar'"],
|
||||
)
|
||||
monkeypatch.setattr(check_translations, "check_translation_set", lambda name, src, trans: error_result)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, ["--strict"])
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -311,26 +307,25 @@ class TestCollectKeys:
|
||||
assert "in_progress" in keys
|
||||
|
||||
|
||||
class TestMainWarnings:
|
||||
def test_passes_with_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Should pass with exit code 0 and 'PASS with warnings' message."""
|
||||
warn_result = check_translations.TranslationCheckResult(
|
||||
class TestMainCleanPass:
|
||||
def test_passes_clean(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Should pass with exit code 0 and 'PASS:' message when no errors."""
|
||||
ok_result = check_translations.TranslationCheckResult(
|
||||
name="devx",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
warnings=["Dead key: 'bar'"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_translations,
|
||||
"check_translation_set",
|
||||
lambda name, src, trans: warn_result,
|
||||
lambda name, src, trans: ok_result,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "PASS with warnings" in result.output
|
||||
assert "PASS:" in result.output
|
||||
|
||||
|
||||
class TestI18nProjectTranslations:
|
||||
|
||||
@@ -178,6 +178,39 @@ class TestClassifierConfig:
|
||||
assert "tests/**" in DEFAULT_INFRASTRUCTURE
|
||||
assert "docs/**" in DEFAULT_INFRASTRUCTURE
|
||||
|
||||
def test_default_infrastructure_covers_common_project_files(self) -> None:
|
||||
"""DEFAULT_INFRASTRUCTURE must cover common project-level files
|
||||
that are not part of the installed package.
|
||||
|
||||
This test prevents regression of the root cause of GRM-64
|
||||
misclassification: 28 files (scripts/**, REVIEW_CHECKLIST.md)
|
||||
were classified as user-facing because these patterns were
|
||||
missing from the defaults.
|
||||
"""
|
||||
# Project documentation
|
||||
assert "AGENTS.md" in DEFAULT_INFRASTRUCTURE
|
||||
assert "README.md" in DEFAULT_INFRASTRUCTURE
|
||||
assert "CHANGELOG.md" in DEFAULT_INFRASTRUCTURE
|
||||
assert "TROUBLESHOOTING.md" in DEFAULT_INFRASTRUCTURE
|
||||
assert "CONTRIBUTING.md" in DEFAULT_INFRASTRUCTURE
|
||||
assert "CODE_OF_CONDUCT.md" in DEFAULT_INFRASTRUCTURE
|
||||
assert "REVIEW_CHECKLIST.md" in DEFAULT_INFRASTRUCTURE
|
||||
# Build tooling
|
||||
assert "Makefile" in DEFAULT_INFRASTRUCTURE
|
||||
assert "cliff.toml" in DEFAULT_INFRASTRUCTURE
|
||||
assert "uv.lock" in DEFAULT_INFRASTRUCTURE
|
||||
# Lint config
|
||||
assert ".pre-commit-config.yaml" in DEFAULT_INFRASTRUCTURE
|
||||
assert ".ruff.toml" in DEFAULT_INFRASTRUCTURE
|
||||
assert ".ansible-lint" in DEFAULT_INFRASTRUCTURE
|
||||
assert ".checkmake.ini" in DEFAULT_INFRASTRUCTURE
|
||||
assert ".editorconfig" in DEFAULT_INFRASTRUCTURE
|
||||
# Git config
|
||||
assert ".gitignore" in DEFAULT_INFRASTRUCTURE
|
||||
assert ".gitattributes" in DEFAULT_INFRASTRUCTURE
|
||||
# Agent config
|
||||
assert ".devin/**" in DEFAULT_INFRASTRUCTURE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChangeClassifier tests
|
||||
|
||||
@@ -166,6 +166,13 @@ class TestToolsCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.generate_badges", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_tools_generate_cliff_config(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["tools", "generate-cliff-config"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.generate_cliff_config", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_tools_install_checkmake(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -120,6 +120,35 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 0
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
def test_query_runners_403_no_warning(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""403 on instance-level runners should not produce a warning (expected without admin scope)."""
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
||||
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
|
||||
]
|
||||
mock_get.side_effect = responses
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
captured = capsys.readouterr()
|
||||
assert "instance-level" not in captured.err
|
||||
|
||||
@patch("devx.ci.discover_runners.requests.get")
|
||||
def test_instance_level_non_403_warns(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Non-200, non-403 status on instance-level runners should produce a warning."""
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
||||
MagicMock(status_code=500, json=lambda: {"message": "server error"}),
|
||||
]
|
||||
mock_get.side_effect = responses
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 2
|
||||
captured = capsys.readouterr()
|
||||
assert "instance-level" in captured.err
|
||||
assert "500" in captured.err
|
||||
|
||||
|
||||
class TestGetRunnerCount:
|
||||
@patch("devx.ci.discover_runners.query_runners", return_value=5)
|
||||
|
||||
@@ -240,6 +240,16 @@ class TestGithubEnv:
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
class TestRunnerIndexValidation:
|
||||
def test_runner_index_zero_raises(self) -> None:
|
||||
"""Runner index < 1 should raise."""
|
||||
with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
|
||||
assert result.exit_code != 0
|
||||
assert "out of range" in result.output
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import devx.molecule.distribute_molecule as dm
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for devx.tools.generate_cliff_config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.generate_cliff_config import main
|
||||
|
||||
|
||||
class TestGenerateCliffConfig:
|
||||
"""Tests for the generate_cliff_config tool."""
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self) -> CliRunner:
|
||||
return CliRunner()
|
||||
|
||||
def test_generate_to_new_file(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Generate cliff.toml to a new file."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
assert output.exists()
|
||||
content = output.read_text()
|
||||
assert "git-cliff configuration for GRM" in content
|
||||
assert 'pattern = "^GRM-\\\\d+:\\\\s+"' in content
|
||||
|
||||
def test_generate_with_default_prefix(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Generate with default prefix (DEVX_TASK_PREFIX or 'DEVX')."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
with patch("devx.tools.generate_cliff_config.TASK_PREFIX", "DEVX"):
|
||||
result = runner.invoke(main, ["--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
content = output.read_text()
|
||||
assert "git-cliff configuration for DEVX" in content
|
||||
|
||||
def test_existing_file_without_force(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Refuse to overwrite existing file without --force."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
output.write_text("# existing")
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code != 0
|
||||
assert "already exists" in result.output
|
||||
assert output.read_text() == "# existing"
|
||||
|
||||
def test_existing_file_with_force(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Overwrite existing file with --force."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
output.write_text("# existing")
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output), "--force"])
|
||||
assert result.exit_code == 0
|
||||
content = output.read_text()
|
||||
assert "git-cliff configuration for GRM" in content
|
||||
assert "# existing" not in content
|
||||
|
||||
def test_generated_config_is_valid_toml(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Generated config must be valid TOML."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
with open(output, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert "changelog" in data
|
||||
assert "git" in data
|
||||
assert "bump" in data
|
||||
assert data["bump"]["initial_tag"] == "0.1.0"
|
||||
assert data["bump"]["features_always_bump_minor"] is True
|
||||
|
||||
def test_generated_config_has_correct_preprocessor(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Preprocessor pattern must match the given prefix."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "INFRA", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
with open(output, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
preprocessors = data["git"]["commit_preprocessors"]
|
||||
assert len(preprocessors) == 1
|
||||
pattern = preprocessors[0]["pattern"]
|
||||
assert "INFRA" in pattern
|
||||
|
||||
def test_generated_config_has_commit_parsers(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Generated config must have all standard commit parsers."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
with open(output, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
parsers = data["git"]["commit_parsers"]
|
||||
# Should have feat, fix, perf, refactor, doc, test, style, chore, ci, release, security, revert, catch-all
|
||||
messages = [p["message"] for p in parsers if "message" in p]
|
||||
assert "^feat" in messages
|
||||
assert "^fix" in messages
|
||||
assert "^perf" in messages
|
||||
assert "^refactor" in messages
|
||||
assert "^release:" in messages
|
||||
assert "^revert" in messages
|
||||
assert ".*" in messages # catch-all
|
||||
|
||||
def test_default_output_path(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Default output path is cliff.toml in current directory."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
# Change to tmp_path so default cliff.toml is created there
|
||||
import os
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
os.chdir(tmp_path)
|
||||
try:
|
||||
result = runner.invoke(main, ["--prefix", "GRM"])
|
||||
assert result.exit_code == 0
|
||||
assert output.exists()
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
def test_success_message(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Success message includes file and prefix."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
assert "Generated" in result.output
|
||||
assert "GRM" in result.output
|
||||
@@ -168,6 +168,15 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "All molecule tests passed" in result.output
|
||||
|
||||
def test_invalid_pair_format_raises(self) -> None:
|
||||
"""Pair with fewer than 2 parts should raise."""
|
||||
from click.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["invalid_no_pipe"])
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid pair format" in result.output
|
||||
|
||||
def test_failure_exits_nonzero(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
||||
@@ -132,13 +132,12 @@ class TestMain:
|
||||
assert "VIKUNJA_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_task_id_non_release_warns(self) -> None:
|
||||
"""Non-release commits without DEVX-N prefix should warn, not fail."""
|
||||
def test_no_task_id_non_release_fails(self) -> 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 result.exit_code != 0
|
||||
assert "No task ID" in result.output
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_release_commit_without_task_id_skips(self) -> None:
|
||||
@@ -179,8 +178,8 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
||||
def test_post_comment_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should fail — the task was not updated."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
@@ -189,14 +188,13 @@ class TestMain:
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
assert result.exit_code != 0
|
||||
assert "Vikunja API error" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
||||
def test_mark_done_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should fail — the task was not updated."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
@@ -206,9 +204,8 @@ class TestMain:
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
assert result.exit_code != 0
|
||||
assert "Vikunja API error" in result.output
|
||||
|
||||
|
||||
class TestGetGitCommitMessage:
|
||||
|
||||
+732
-11
@@ -9,9 +9,16 @@ from click.testing import CliRunner
|
||||
from devx.ci.release import (
|
||||
commit_release_changes,
|
||||
create_and_push_tag,
|
||||
fetch_tags,
|
||||
get_all_tags,
|
||||
get_bumped_version,
|
||||
get_changelog,
|
||||
get_changelog_versions,
|
||||
get_commit_version,
|
||||
get_head_commit,
|
||||
get_init_version,
|
||||
get_latest_tag,
|
||||
get_tag_commit,
|
||||
has_unreleased_changes,
|
||||
main,
|
||||
run_cmd,
|
||||
@@ -19,6 +26,8 @@ from devx.ci.release import (
|
||||
tag_exists,
|
||||
update_changelog,
|
||||
update_init_version,
|
||||
verify_alignment,
|
||||
verify_tag_consistency,
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +93,13 @@ class TestGetBumpedVersion:
|
||||
with pytest.raises(click.ClickException):
|
||||
get_bumped_version()
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_invalid_version_format_raises(self, mock_run_cmd: MagicMock) -> None:
|
||||
"""Non-semver version from git-cliff should raise."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="not-a-version\n", stderr="")
|
||||
with pytest.raises(click.ClickException, match="invalid version format"):
|
||||
get_bumped_version()
|
||||
|
||||
|
||||
class TestGetChangelog:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@@ -149,6 +165,534 @@ class TestUpdateInitVersion:
|
||||
update_init_version("0.2.0")
|
||||
|
||||
|
||||
class TestGetTagCommit:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_commit(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123\n", stderr="")
|
||||
assert get_tag_commit("v0.1.0") == "abc123"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_empty_on_failure(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
|
||||
assert get_tag_commit("v0.1.0") == ""
|
||||
|
||||
|
||||
class TestGetHeadCommit:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_head(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="def456\n", stderr="")
|
||||
assert get_head_commit() == "def456"
|
||||
|
||||
|
||||
class TestFetchTags:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_success(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
fetch_tags()
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_failure_warns(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
|
||||
# Should not raise
|
||||
fetch_tags()
|
||||
|
||||
|
||||
class TestGetAllTags:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_tags(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\nv0.2.0\nv0.1.0\n", stderr="")
|
||||
tags = get_all_tags()
|
||||
assert tags == ["v0.3.0", "v0.2.0", "v0.1.0"]
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_empty(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="\n", stderr="")
|
||||
assert get_all_tags() == []
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_failure_returns_empty(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
|
||||
assert get_all_tags() == []
|
||||
|
||||
|
||||
class TestGetCommitVersion:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_release_commit(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="release: v0.4.4 [skip ci]\n", stderr="")
|
||||
assert get_commit_version("abc123") == "0.4.4"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_non_release_commit(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="DEVX-9 feat: add thing\n", stderr="")
|
||||
assert get_commit_version("abc123") is None
|
||||
|
||||
|
||||
class TestVerifyTagConsistency:
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_all_consistent(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
mock_tags.return_value = ["v0.2.0", "v0.1.0"]
|
||||
mock_cv.side_effect = ["0.2.0", "0.1.0"]
|
||||
errors = verify_tag_consistency()
|
||||
assert errors == []
|
||||
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_tag_on_non_release_commit(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
# v0.1.0 is first (exempt), v0.2.0 is non-release (should error)
|
||||
mock_tags.return_value = ["v0.2.0", "v0.1.0"]
|
||||
mock_cv.side_effect = [None, "0.1.0"] # v0.2.0 non-release, v0.1.0 ok
|
||||
errors = verify_tag_consistency()
|
||||
assert len(errors) == 1
|
||||
assert "non-release commit" in errors[0]
|
||||
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_first_tag_exempt_from_release_check(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
"""The first (oldest) tag is allowed to point to a non-release commit."""
|
||||
mock_tags.return_value = ["v0.1.0"]
|
||||
mock_cv.return_value = None # non-release commit
|
||||
errors = verify_tag_consistency()
|
||||
assert errors == [] # no error — first tag is exempt
|
||||
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_tag_version_mismatch(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
mock_tags.return_value = ["v0.2.0"]
|
||||
mock_cv.return_value = "0.1.0"
|
||||
errors = verify_tag_consistency()
|
||||
assert len(errors) == 1
|
||||
assert "0.1.0" in errors[0]
|
||||
assert "0.2.0" in errors[0]
|
||||
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_no_tags(self, mock_tags: MagicMock) -> None:
|
||||
mock_tags.return_value = []
|
||||
assert verify_tag_consistency() == []
|
||||
|
||||
|
||||
class TestGetInitVersion:
|
||||
def test_returns_version(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('__version__ = "0.4.4"\n')
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
|
||||
assert get_init_version() == "0.4.4"
|
||||
|
||||
def test_file_not_found(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", "/nonexistent/path/__init__.py")
|
||||
assert get_init_version() is None
|
||||
|
||||
def test_no_version_string(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('"""module"""\n')
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
|
||||
assert get_init_version() is None
|
||||
|
||||
|
||||
class TestGetChangelogVersions:
|
||||
def test_returns_versions(self, tmp_path, monkeypatch) -> None:
|
||||
changelog = tmp_path / "CHANGELOG.md"
|
||||
changelog.write_text(
|
||||
"# Changelog\n\n## [0.4.4] - 2026-06-21\n\n### Features\n- new\n\n"
|
||||
"## [0.4.3] - 2026-06-20\n\n### Fixes\n- fix\n\n## [0.4.2] - 2026-06-19\n"
|
||||
)
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog))
|
||||
versions = get_changelog_versions()
|
||||
assert versions == ["0.4.4", "0.4.3", "0.4.2"]
|
||||
|
||||
def test_file_not_found(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", "/nonexistent/CHANGELOG.md")
|
||||
assert get_changelog_versions() == []
|
||||
|
||||
|
||||
class TestVerifyAlignment:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_all_aligned(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment passes when everything is consistent."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4", "v0.4.3"]
|
||||
mock_vtc.return_value = [] # no tag errors
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4", "0.4.3"]
|
||||
# run_cmd is called for untagged release commits check
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_misaligned_tags(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when tags are misaligned."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = [" v0.1.0 → bad"]
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_version_mismatch(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when __version__ != latest tag."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.3" # mismatch
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_duplicates(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when CHANGELOG has duplicate versions."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4", "0.4.4"] # duplicate
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_out_of_order(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when CHANGELOG versions are not descending."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.3", "0.4.4"] # out of order
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_latest_mismatch(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when CHANGELOG latest != latest tag."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.3"] # doesn't match tag
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_unreleased_section(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify passes when CHANGELOG has one unreleased section ahead of tag."""
|
||||
mock_lt.return_value = "v0.6.3"
|
||||
mock_tags.return_value = ["v0.6.3", "v0.6.2"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.6.3"
|
||||
mock_cv.return_value = ["0.6.4", "0.6.3"] # 0.6.4 is unreleased
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_tag_at_wrong_position(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify fails when latest tag is deep in CHANGELOG (not at position 0 or 1)."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.5.0", "0.4.5", "0.4.4"] # tag at position 2
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_duplicate_release_commits_info(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify reports duplicate release commits as info, not error."""
|
||||
mock_lt.return_value = "v0.6.1"
|
||||
mock_tags.return_value = ["v0.6.1"] # tag for 0.6.1 exists
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.6.1"
|
||||
mock_cv.return_value = ["0.6.1"]
|
||||
# git log finds 2 release commits for v0.6.1, neither has tag pointing at it
|
||||
# (the tag points to a third commit)
|
||||
commits = "abc123 release: v0.6.1 [skip ci]\ndef456 release: v0.6.1 [skip ci]\n"
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout=commits, stderr=""),
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # no tag at abc123
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # no tag at def456
|
||||
]
|
||||
# Should return 0 — duplicates are informational, not errors
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_many_duplicate_release_commits(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify handles >5 duplicate release commits (truncation message)."""
|
||||
mock_lt.return_value = "v0.6.1"
|
||||
mock_tags.return_value = ["v0.6.1"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.6.1"
|
||||
mock_cv.return_value = ["0.6.1"]
|
||||
# Generate 7 duplicate release commits for v0.6.1
|
||||
commits = "\n".join(f"abc{i:03d} release: v0.6.1 [skip ci]" for i in range(7))
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout=commits + "\n", stderr=""),
|
||||
] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(7)]
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_untagged_release_commits(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when there are untagged release commits."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
# git log finds release commits, then tag --points-at finds nothing
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="abc123 release: v0.3.0 [skip ci]\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # no tags at abc123
|
||||
]
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_no_init_version(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when __version__ is not found."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = None # not found
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_all_release_commits_tagged(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify passes when all release commits have tags."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
# git log finds release commit, tag --points-at finds the tag
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="abc123 release: v0.4.4 [skip ci]\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="v0.4.4\n", stderr=""), # tag found
|
||||
]
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_no_release_commits_found(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify handles case with no release commits at all."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_many_untagged_release_commits(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify handles >10 untagged release commits (truncation message)."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
# Generate 15 untagged release commits
|
||||
commits = "\n".join(f"abc{i:03d} release: v0.1.{i} [skip ci]" for i in range(15))
|
||||
# First call returns all commits, subsequent calls return empty (no tags)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout=commits + "\n", stderr=""),
|
||||
] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(15)]
|
||||
assert verify_alignment() == 1
|
||||
|
||||
|
||||
class TestUpdateChangelog:
|
||||
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
@@ -243,9 +787,17 @@ class TestCreateAndPushTag:
|
||||
assert call.args[0][0:2] != ["git", "push"]
|
||||
assert call.args[0][0:2] != ["git", "tag"]
|
||||
|
||||
@patch("devx.ci.release.get_head_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.tag_exists", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
def test_tag_exists_skips_creation(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_tag_exists: MagicMock,
|
||||
mock_tag_commit: MagicMock,
|
||||
mock_head_commit: MagicMock,
|
||||
) -> None:
|
||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=False)
|
||||
assert result is False
|
||||
# Should not create tag, but should ensure it's pushed
|
||||
@@ -253,13 +805,38 @@ class TestCreateAndPushTag:
|
||||
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
|
||||
assert ["git", "push", "origin", "v0.1.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.get_head_commit", return_value="def456")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.tag_exists", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
def test_tag_exists_mismatch_raises(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_tag_exists: MagicMock,
|
||||
mock_tag_commit: MagicMock,
|
||||
mock_head_commit: MagicMock,
|
||||
) -> None:
|
||||
"""Tag exists but points to different commit than HEAD → error."""
|
||||
with pytest.raises(click.ClickException, match="misalignment"):
|
||||
create_and_push_tag("0.1.0", "changelog", dry_run=False)
|
||||
|
||||
@patch("devx.ci.release.get_head_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.tag_exists", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tag_exists_dry_run_no_push(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_tag_exists: MagicMock,
|
||||
mock_tag_commit: MagicMock,
|
||||
mock_head_commit: MagicMock,
|
||||
) -> None:
|
||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=True)
|
||||
assert result is False
|
||||
# No git commands at all in dry-run when tag exists
|
||||
mock_run_cmd.assert_not_called()
|
||||
# No push in dry-run when tag exists, but alignment check still runs
|
||||
for call in mock_run_cmd.call_args_list:
|
||||
assert call.args[0][0:2] != ["git", "push"]
|
||||
assert call.args[0][0:2] != ["git", "tag"]
|
||||
|
||||
|
||||
class TestRunTests:
|
||||
@@ -295,6 +872,13 @@ class TestRunTests:
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main release command.
|
||||
|
||||
All tests mock fetch_tags and verify_tag_consistency since these
|
||||
are pre-flight checks that call git commands. Tests that need to
|
||||
verify specific git call sequences mock run_cmd with side_effect.
|
||||
"""
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None:
|
||||
@@ -305,9 +889,12 @@ class TestMain:
|
||||
assert "master" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@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) -> None:
|
||||
def test_dry_run_on_non_master_warns(
|
||||
self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: 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="")
|
||||
runner = CliRunner()
|
||||
@@ -316,14 +903,26 @@ class TestMain:
|
||||
assert "Dry-run mode" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_head_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@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_cmd")
|
||||
def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
|
||||
"""If HEAD is already a release commit, should skip to prevent duplicate releases."""
|
||||
# First call: git rev-parse (master), second: git log -1 (release commit)
|
||||
def test_release_lock_skips_when_head_is_release_commit_and_tag_exists(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_uf: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_tc: MagicMock,
|
||||
mock_hc: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit and the tag exists, skip."""
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
@@ -332,6 +931,62 @@ class TestMain:
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_head_commit", return_value="def456")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@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_cmd")
|
||||
def test_release_lock_tag_points_elsewhere(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_uf: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_tc: MagicMock,
|
||||
mock_hc: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit but tag points elsewhere, error."""
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "misalignment" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.get_changelog", return_value="## changelog")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_release_lock_recovers_when_tag_missing(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_create_tag: MagicMock,
|
||||
mock_changelog: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit but the tag is missing, create the tag."""
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # tag -l finds nothing
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "tag v0.5.0 is missing" in result.output
|
||||
assert "Recovering" in result.output
|
||||
mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False)
|
||||
|
||||
@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")
|
||||
@@ -342,6 +997,8 @@ class TestMain:
|
||||
mock_bumped: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -350,6 +1007,7 @@ class TestMain:
|
||||
assert "No unreleased changes" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@@ -360,7 +1018,7 @@ class TestMain:
|
||||
@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.run_cmd")
|
||||
def test_dry_run_empty_changelog(
|
||||
def test_dry_run_empty_changelog_fails(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
@@ -372,14 +1030,17 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""Empty changelog should fail, not warn."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "empty changelog" in result.output
|
||||
assert result.exit_code != 0
|
||||
assert "empty changelog" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@@ -402,6 +1063,7 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -414,6 +1076,8 @@ class TestMain:
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.3.0")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@@ -422,6 +1086,8 @@ class TestMain:
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_user_facing: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""Release is skipped when only workflow/infra files changed."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -432,6 +1098,8 @@ class TestMain:
|
||||
assert "Skipping release" in result.output
|
||||
|
||||
@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)
|
||||
@@ -456,6 +1124,8 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -469,6 +1139,8 @@ class TestMain:
|
||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
||||
|
||||
@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=False)
|
||||
@@ -493,6 +1165,8 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""When tag already exists, still update files but report existing tag."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -503,6 +1177,8 @@ class TestMain:
|
||||
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
|
||||
|
||||
@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.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@@ -525,6 +1201,8 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""--skip-tests bypasses test verification."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -537,6 +1215,8 @@ class TestMain:
|
||||
assert make_calls == []
|
||||
|
||||
@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.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@@ -559,6 +1239,8 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""If tests fail, release aborts — no commit, no tag."""
|
||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
||||
@@ -577,6 +1259,8 @@ class TestMain:
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@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.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@@ -599,6 +1283,8 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""If lint fails, release aborts — no commit, no tag."""
|
||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
||||
@@ -614,3 +1300,38 @@ class TestMain:
|
||||
assert "Lint failed" in result.output
|
||||
mock_commit.assert_not_called()
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@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")
|
||||
@patch("devx.ci.release.get_all_tags", return_value=[])
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_verify_mode_no_tags(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
) -> None:
|
||||
"""--verify checks alignment and exits without releasing."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--verify"])
|
||||
assert result.exit_code == 0
|
||||
assert "Release Alignment Verification" in result.output
|
||||
|
||||
@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
|
||||
) -> None:
|
||||
"""Pre-flight tag consistency check aborts if tags are misaligned."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Tag consistency check failed" in result.output
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -63,6 +64,22 @@ class TestLoadMapping:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_mapping()
|
||||
|
||||
def test_non_dict_mapping_raises(self, tmp_path: Path) -> None:
|
||||
"""Non-dict mapping.json should raise."""
|
||||
mapping_file = tmp_path / "mapping.json"
|
||||
mapping_file.write_text('["not", "a", "dict"]')
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
|
||||
with pytest.raises(click.ClickException, match="must be a dict"):
|
||||
load_mapping()
|
||||
|
||||
def test_non_string_values_raise(self, tmp_path: Path) -> None:
|
||||
"""Non-string values in mapping.json should raise."""
|
||||
mapping_file = tmp_path / "mapping.json"
|
||||
mapping_file.write_text('{"file.md": 123}')
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
|
||||
with pytest.raises(click.ClickException, match="must be strings"):
|
||||
load_mapping()
|
||||
|
||||
|
||||
class TestReadDocContent:
|
||||
def test_reads_file(self, tmp_path: Path) -> None:
|
||||
@@ -332,8 +349,8 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing doc files are skipped with a warning."""
|
||||
def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing doc files cause an error, not a warning."""
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("devx.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
|
||||
@@ -341,14 +358,13 @@ class TestMain:
|
||||
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code != 0
|
||||
assert "not found" in result.output
|
||||
assert "Skipped: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that empty doc files are skipped with a warning."""
|
||||
def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that empty doc files cause an error, not a warning."""
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("devx.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
|
||||
@@ -356,9 +372,8 @@ class TestMain:
|
||||
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code != 0
|
||||
assert "empty" in result.output.lower()
|
||||
assert "Skipped: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
|
||||
@@ -152,6 +152,89 @@ class TestMain:
|
||||
assert "task ID" in result.output
|
||||
|
||||
|
||||
class TestCustomPrefix:
|
||||
"""Tests for custom task ID prefix (e.g., GRM-N instead of DEVX-N).
|
||||
|
||||
The prefix is configured via the DEVX_TASK_PREFIX environment variable.
|
||||
This is critical for consumer projects like GRM that use their own
|
||||
Vikunja project with a different identifier prefix.
|
||||
"""
|
||||
|
||||
def _write_msg(self, content: str) -> str:
|
||||
fd, path = tempfile.mkstemp()
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
|
||||
def test_master_accepts_grm_prefix(self) -> None:
|
||||
"""Master branch accepts GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
import devx.ci.validate_commit_msg as vcm
|
||||
import devx.config
|
||||
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
try:
|
||||
msg_path = self._write_msg("GRM-66: fix: add scripts/** to infrastructure")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(vcm.main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
os.unlink(msg_path)
|
||||
finally:
|
||||
os.environ.pop("DEVX_TASK_PREFIX", None)
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
|
||||
def test_master_rejects_devx_prefix_when_grm_configured(self) -> None:
|
||||
"""Master branch rejects DEVX-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
import devx.ci.validate_commit_msg as vcm
|
||||
import devx.config
|
||||
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
try:
|
||||
msg_path = self._write_msg("DEVX-8: fix: wrong prefix")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(vcm.main, [msg_path])
|
||||
assert result.exit_code == 1
|
||||
assert "GRM-N" in result.output
|
||||
os.unlink(msg_path)
|
||||
finally:
|
||||
os.environ.pop("DEVX_TASK_PREFIX", None)
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
|
||||
def test_feature_branch_rejects_grm_prefix(self) -> None:
|
||||
"""Feature branch rejects GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
import devx.ci.validate_commit_msg as vcm
|
||||
import devx.config
|
||||
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
try:
|
||||
msg_path = self._write_msg("GRM-66: fix: should not have prefix on branch")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="GRM-66-fix"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(vcm.main, [msg_path])
|
||||
assert result.exit_code == 1
|
||||
assert "task ID" in result.output
|
||||
os.unlink(msg_path)
|
||||
finally:
|
||||
os.environ.pop("DEVX_TASK_PREFIX", None)
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import tempfile
|
||||
|
||||
|
||||
Reference in New Issue
Block a user