GRM-61: refactor: separate GRM and CI translations with validation

This commit is contained in:
2026-06-22 12:13:58 +00:00
parent 1e04d38d59
commit c9c46e88ac
27 changed files with 1618 additions and 711 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import subprocess # nosec B404
import click
from gitea_runner_manager.i18n import _
from scripts.i18n import _
DEFAULT_MAX_SECONDS = 2.0
TEST_COMMAND = ["make", "test-unit"]
+1 -1
View File
@@ -36,7 +36,7 @@ from gitea_runner_manager.config import (
VIKUNJA_PROJECT_ID,
)
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
from scripts.i18n import _
TASKID_FILE = ".taskid"
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Check translation files for gaps, dead keys, and missing languages.
Validates two separate translation sets:
1. GRM tool translations: ``src/gitea_runner_manager/translations.json``
— keys used by ``src/gitea_runner_manager/*.py``
2. CI/dev tool translations: ``scripts/translations.json``
— keys used by ``scripts/**/*.py``
Checks performed (all fail with exit code 1 on error):
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
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.
Usage::
python3 scripts/ci/check_translations.py
python3 scripts/ci/check_translations.py --strict # warnings are errors
"""
from __future__ import annotations
import ast
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
GRM_SRC_DIR = REPO_ROOT / "src" / "gitea_runner_manager"
GRM_TRANS_FILE = GRM_SRC_DIR / "translations.json"
CI_SRC_DIR = REPO_ROOT / "scripts"
CI_TRANS_FILE = CI_SRC_DIR / "translations.json"
# Functions that wrap _() and receive a translation key as first arg.
# Their string-literal arguments should be treated as translation keys.
_I18N_WRAPPERS = {"_handle_errors"}
# Known dynamic keys used via _(variable) that can't be detected by AST.
# These are status strings set as variable values and passed to _().
DYNAMIC_KEYS = {"completed", "pending", "in_progress", "failed", "active", "inactive", "unknown"}
@dataclass
class TranslationCheckResult:
"""Result of a translation check for one translation set."""
name: str
src_dir: Path
trans_file: Path
used_keys: set[str] = field(default_factory=set)
defined_keys: set[str] = field(default_factory=set)
missing_keys: set[str] = field(default_factory=set)
dead_keys: set[str] = field(default_factory=set)
missing_langs: dict[str, list[str]] = field(default_factory=dict)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def extract_keys(filepath: Path) -> set[str]:
"""Extract translation keys from a Python file using AST.
Detects:
- ``_("key")`` calls with string-literal first argument
- ``_handle_errors("key")`` and other wrapper calls (see ``_I18N_WRAPPERS``)
"""
try:
tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath))
except SyntaxError:
return set()
keys: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if (
isinstance(func, ast.Name)
and func.id in ("_", *_I18N_WRAPPERS)
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
):
keys.add(node.args[0].value)
return keys
def collect_keys(src_dir: Path) -> set[str]:
"""Collect all translation keys from .py files in a directory tree.
Also includes known dynamic keys (see ``DYNAMIC_KEYS``) that are used
via ``_(variable)`` and can't be detected by AST scanning.
"""
keys: set[str] = set()
for pyfile in src_dir.rglob("*.py"):
if pyfile.name == "i18n.py":
continue
keys |= extract_keys(pyfile)
# Add dynamic keys for the GRM source directory
if src_dir == GRM_SRC_DIR:
keys |= DYNAMIC_KEYS
return keys
def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> TranslationCheckResult:
"""Check one translation set for gaps and dead keys."""
result = TranslationCheckResult(name=name, src_dir=src_dir, trans_file=trans_file)
# Collect used keys from source code
result.used_keys = collect_keys(src_dir)
# Load defined keys from translations file
if not trans_file.exists():
result.errors.append(f"Translations file not found: {trans_file}")
return result
translations = json.loads(trans_file.read_text(encoding="utf-8"))
result.defined_keys = set(translations.keys())
# Check for missing keys (used in code but not in translations)
result.missing_keys = result.used_keys - result.defined_keys
for key in sorted(result.missing_keys):
result.errors.append(f"Missing key in {name}: {key!r}")
# 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}")
# Check for missing languages
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}")
return result
def print_result(result: TranslationCheckResult) -> None:
"""Print check results in a human-readable format."""
click.echo(f"\n=== {result.name} ===")
click.echo(f" Source dir: {result.src_dir}")
click.echo(f" Translations: {result.trans_file}")
click.echo(f" Used keys: {len(result.used_keys)}")
click.echo(f" Defined keys: {len(result.defined_keys)}")
click.echo(f" Missing keys: {len(result.missing_keys)}")
click.echo(f" Dead keys: {len(result.dead_keys)}")
click.echo(f" Missing langs: {len(result.missing_langs)} keys")
for err in result.errors:
click.echo(f" ERROR: {err}", err=True)
for warn in result.warnings:
click.echo(f" WARN: {warn}", err=True)
if not result.errors and not result.warnings:
click.echo(" All good!")
@click.command()
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
def main(strict: bool) -> None:
"""Check translation files for gaps, dead keys, and missing languages."""
results = [
check_translation_set("GRM tool", GRM_SRC_DIR, GRM_TRANS_FILE),
check_translation_set("CI/dev tools", CI_SRC_DIR, CI_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.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+1 -1
View File
@@ -55,7 +55,7 @@ import sys
import click
from gitea_runner_manager.i18n import _
from scripts.i18n import _
# Explicit allowlist of workflow-only path patterns.
# Anything NOT matching these is treated as user-facing (safe default).
+1 -1
View File
@@ -24,8 +24,8 @@ from pathlib import Path
import click
from gitea_runner_manager.i18n import _
from scripts.ci.platforms import PLATFORMS
from scripts.i18n import _
DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
+1 -1
View File
@@ -17,7 +17,7 @@ from pathlib import Path
import click
from gitea_runner_manager.i18n import _
from scripts.i18n import _
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DOCS_DIR = REPO_ROOT / "docs"
+1 -1
View File
@@ -35,7 +35,7 @@ from pathlib import Path
import click
import requests
from gitea_runner_manager.i18n import _
from scripts.i18n import _
POLL_INTERVAL = 10
+1 -1
View File
@@ -22,8 +22,8 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.i18n import _
from scripts.gitea_cli import TeaCLI, TeaCLIError
from scripts.i18n import _
load_dotenv(override=True)
+1 -1
View File
@@ -15,7 +15,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from gitea_runner_manager.api_clients import VikunjaClient
from gitea_runner_manager.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
from scripts.i18n import _
load_dotenv(override=True)
+1 -1
View File
@@ -33,7 +33,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
from scripts.i18n import _
load_dotenv(override=True)
+1 -1
View File
@@ -16,8 +16,8 @@ import sys
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.i18n import _
from scripts.gitea_cli import TeaCLI, TeaCLIError
from scripts.i18n import _
load_dotenv(override=True)
+1 -1
View File
@@ -35,8 +35,8 @@ import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.i18n import _
from scripts.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from scripts.i18n import _
load_dotenv(override=True)
+1 -1
View File
@@ -30,7 +30,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
from scripts.i18n import _
load_dotenv(override=True)
+1 -1
View File
@@ -13,7 +13,7 @@ import subprocess # nosec B404
import click
from gitea_runner_manager.config import CONVENTIONAL_RE
from gitea_runner_manager.i18n import _
from scripts.i18n import _
MASTER_TASK_ID_RE = re.compile(r"^GRM-\d+:")
+1 -1
View File
@@ -25,7 +25,7 @@ from gitea_runner_manager.config import (
REPO_SETTINGS_CONFIG,
)
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
from scripts.i18n import _
load_dotenv(override=True)
+31
View File
@@ -0,0 +1,31 @@
"""i18n for CI/dev scripts.
Separate from the GRM tool's i18n (``gitea_runner_manager.i18n``) so that
CI-only translation keys don't bloat the packaged CLI.
Set GRM_LANG environment variable to override the default English.
Supported: en, bg, de, ru, zh.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
)
def _(key: str, **kwargs: object) -> str:
"""Return a translated string for the given key.
Translation is opt-in via the ``GRM_LANG`` environment variable.
If unset, English is always returned regardless of system locale.
"""
lang = os.getenv("GRM_LANG", "en")
if lang not in ("en", "bg", "de", "ru", "zh"):
lang = "en"
template = TRANSLATIONS.get(key, {}).get(lang, key)
return template.format(**kwargs)
+44 -12
View File
@@ -28,10 +28,10 @@ def _run(cmd: list[str], bin_dir: str) -> None:
subprocess.run(cmd, check=True) # nosec B603
def _install_python_deps(bin_dir: str) -> None:
"""Install the project with dev extras in editable mode."""
def _install_python_deps(bin_dir: str, extras: str = "dev") -> None:
"""Install the project with the specified extras in editable mode."""
pip = str(Path(bin_dir) / "pip")
_run([pip, "install", "-e", ".[dev]"], bin_dir)
_run([pip, "install", "-e", f".[{extras}]"], bin_dir)
def _install_ansible_collections(bin_dir: str) -> None:
@@ -119,22 +119,54 @@ def _verify(bin_dir: str) -> None:
@click.command()
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
def main(bin_dir: str) -> None:
@click.option(
"--extras",
default="dev",
help="Dependency group to install: ci, lint, molecule, or dev (default: dev).",
)
@click.option(
"--no-ansible-collections",
is_flag=True,
default=False,
help="Skip Ansible Galaxy collections installation.",
)
@click.option(
"--no-pre-commit",
is_flag=True,
default=False,
help="Skip pre-commit hook installation.",
)
@click.option(
"--no-tea-login",
is_flag=True,
default=False,
help="Skip tea CLI login configuration.",
)
def main(
bin_dir: str,
extras: str,
no_ansible_collections: bool,
no_pre_commit: bool,
no_tea_login: bool,
) -> None:
"""Install Python deps, Ansible collections, and pre-commit hooks."""
if not Path(bin_dir).exists():
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
click.echo("Installing Python dependencies...")
_install_python_deps(bin_dir)
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
click.echo("Installing Ansible collections...")
_install_ansible_collections(bin_dir)
if not no_ansible_collections:
click.echo("Installing Ansible collections...")
_install_ansible_collections(bin_dir)
click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir)
if not no_pre_commit:
click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir)
click.echo("Configuring tea CLI login...")
_configure_tea_login()
if not no_tea_login:
click.echo("Configuring tea CLI login...")
_configure_tea_login()
click.echo("")
click.echo("Setup complete.")
+503
View File
@@ -0,0 +1,503 @@
{
"\nAll documentation coverage checks passed!": {
"en": "\nAll documentation coverage checks passed!"
},
"\nAnsible files changed ({count}):": {
"en": "\nAnsible files changed ({count}):"
},
"\nChecking CI script documentation in ci-cd-workflow.md...": {
"en": "\nChecking CI script documentation in ci-cd-workflow.md..."
},
"\nChecking module documentation in architecture.md...": {
"en": "\nChecking module documentation in architecture.md..."
},
"\nDoc coverage: {covered}/{total} ({pct}%)": {
"en": "\nDoc coverage: {covered}/{total} ({pct}%)"
},
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
"en": "\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."
},
"\nIntegrity check FAILED ({count} issues):": {
"en": "\nIntegrity check FAILED ({count} issues):"
},
"\nIntegrity check passed — all {count} pages verified.": {
"en": "\nIntegrity check passed — all {count} pages verified."
},
"\nMissing documentation:": {
"en": "\nMissing documentation:"
},
"\nResult: {status}": {
"en": "\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)."
},
"\nRunning full wiki integrity check...": {
"en": "\nRunning full wiki integrity check..."
},
"\nUser-facing changes ({count}):": {
"en": "\nUser-facing changes ({count}):"
},
"\nUser-facing files changed ({count}):": {
"en": "\nUser-facing files changed ({count}):"
},
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
"en": "\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."
},
"\nVerifying wiki pages have content...": {
"en": "\nVerifying wiki pages have content..."
},
"\nWorkflow-only changes ({count}):": {
"en": "\nWorkflow-only changes ({count}):"
},
"\n[dry-run] Changelog:\n{changelog}": {
"en": "\n[dry-run] Changelog:\n{changelog}"
},
" - Auto-delete branch after merge: yes": {
"en": " - Auto-delete branch after merge: yes",
"bg": " - Автоматично изтриване на клон след сливане: да",
"de": " - Branch nach Merge automatisch löschen: ja",
"ru": " - Автоудаление ветки после слияния: да",
"zh": " - 合并后自动删除分支: 是"
},
" - Block outdated branches: yes": {
"en": " - Block outdated branches: yes",
"bg": " - Блокиране на остарели клонове: да",
"de": " - Veraltete Branches blockieren: ja",
"ru": " - Блокировать устаревшие ветки: да",
"zh": " - 阻止过时分支: 是"
},
" - Block rejected reviews: yes": {
"en": " - Block rejected reviews: yes",
"bg": " - Блокиране на отхвърлени рецензии: да",
"de": " - Abgelehnte Reviews blockieren: ja",
"ru": " - Блокировать отклонённые ревью: да",
"zh": " - 阻止被拒绝的审查: 是"
},
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
},
" - Dismiss stale approvals: yes": {
"en": " - Dismiss stale approvals: yes",
"bg": " - Анулиране на остарели одобрения: да",
"de": " - Veraltete Genehmigungen ablehnen: ja",
"ru": " - Отклонять устаревшие одобрения: да",
"zh": " - 忽略过时审批: 是"
},
" - Required approvals: {count}": {
"en": " - Required approvals: {count}",
"bg": " - Необходими одобрения: {count}",
"de": " - Erforderliche Genehmigungen: {count}",
"ru": " - Требуемые одобрения: {count}",
"zh": " - 必需审批数: {count}"
},
" - Required status checks: {checks}": {
"en": " - Required status checks: {checks}",
"bg": " - Необходими проверки на състоянието: {checks}",
"de": " - Erforderliche Status-Checks: {checks}",
"ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}"
},
" Created: {title}": {
"en": " Created: {title}"
},
" FAIL: {title} — content mismatch or empty!": {
"en": " FAIL: {title} — content mismatch or empty!"
},
" MISSING: grm {cmd}": {
"en": " MISSING: grm {cmd}"
},
" MISSING: {module}": {
"en": " MISSING: {module}"
},
" MISSING: {script}": {
"en": " MISSING: {script}"
},
" OK: grm {cmd}": {
"en": " OK: grm {cmd}"
},
" OK: {module}": {
"en": " OK: {module}"
},
" OK: {script}": {
"en": " OK: {script}"
},
" OK: {title} ({chars} chars)": {
"en": " OK: {title} ({chars} chars)"
},
" Updated: {title}": {
"en": " Updated: {title}"
},
"API poll warning: {exc}": {
"en": "API poll warning: {exc}"
},
"All molecule tests passed.": {
"en": "All molecule tests passed."
},
"Another molecule runner failed. Stopping this runner early.": {
"en": "Another molecule runner failed. Stopping this runner early."
},
"Bumping version: {current} -> v{new_version}": {
"en": "Bumping version: {current} -> v{new_version}"
},
"Checking CLI command documentation...": {
"en": "Checking CLI command documentation..."
},
"Command failed ({cmd}): {stderr}": {
"en": "Command failed ({cmd}): {stderr}"
},
"Comparing {base}..{head} ({count} files changed)": {
"en": "Comparing {base}..{head} ({count} files changed)"
},
"Configuring branch protection for {branch}...": {
"en": "Configuring branch protection for {branch}...",
"bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...",
"ru": "Настройка защиты ветки {branch}...",
"zh": "正在配置 {branch} 的分支保护..."
},
"Configuring repository settings...": {
"en": "Configuring repository settings...",
"bg": "Конфигуриране на настройките на хранилището...",
"de": "Repository-Einstellungen konfigurieren...",
"ru": "Настройка параметров репозитория...",
"zh": "正在配置仓库设置..."
},
"Could not extract conventional commit message from PR commits.": {
"en": "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."
},
"Could not find __version__ in {file}": {
"en": "Could not find __version__ in {file}"
},
"Could not parse test execution time from output.": {
"en": "Could not parse test execution time from output."
},
"Created issue #{issue_id}: {title}": {
"en": "Created issue #{issue_id}: {title}"
},
"Created release commit.": {
"en": "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."
},
"ERROR: REPO_TOKEN is not set.": {
"en": "ERROR: REPO_TOKEN is not set.",
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
"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: mapping.json not found at {path}": {
"en": "ERROR: mapping.json not found at {path}"
},
"FAILED: {pair} exited with code {code}": {
"en": "FAILED: {pair} exited with code {code}"
},
"Failed to create issue via tea: {error}": {
"en": "Failed to create issue via tea: {error}"
},
"Found {count} existing wiki pages.": {
"en": "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."
},
"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."
},
"HTTP error: {status} — {message}": {
"en": "HTTP error: {status} — {message}",
"bg": "HTTP грешка: {status} — {message}",
"de": "HTTP-Fehler: {status} — {message}",
"ru": "Ошибка HTTP: {status} — {message}",
"zh": "HTTP 错误: {status} — {message}"
},
"HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.": {
"en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.",
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.",
"de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.",
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
},
"Head branch is behind master. Pulling and rebasing...": {
"en": "Head branch is behind master. Pulling and rebasing..."
},
"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}"
},
"Lint passed.": {
"en": "Lint passed."
},
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
"en": "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.",
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
},
"Molecule directory not found: {path}": {
"en": "Molecule directory not found: {path}",
"bg": "Директорията на molecule не е намерена: {path}",
"de": "Molecule-Verzeichnis nicht gefunden: {path}",
"ru": "Директория molecule не найдена: {path}",
"zh": "未找到 molecule 目录: {path}"
},
"Nice! Gitea release {tag} created.": {
"en": "Nice! Gitea release {tag} created.",
"bg": "Отлично! Gitea release {tag} е създаден.",
"de": "Prima! Gitea-Release {tag} erstellt.",
"ru": "Отлично! Gitea release {tag} создан.",
"zh": "不错!Gitea release {tag} 已创建。"
},
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
"bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}",
"de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.",
"ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}",
"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."
},
"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.",
"bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.",
"de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.",
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
},
"No changes between {base} and {head}.": {
"en": "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."
},
"No tags found — treating all changes as user-facing.": {
"en": "No tags found — treating all changes as user-facing."
},
"No unreleased changes found. Nothing to release.": {
"en": "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."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": {
"en": "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",
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"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 (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! 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: 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! No task ID found in .taskid file or branch name '{branch}'.": {
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'."
},
"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 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}"
},
"Oops! Package build failed:\n{stderr}": {
"en": "Oops! Package build failed:\n{stderr}",
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
"ru": "Ой! Сборка пакета не удалась:\n{stderr}",
"zh": "哎呀!包构建失败:\n{stderr}"
},
"Oops! PyPI publish failed:\n{stderr}": {
"en": "Oops! PyPI publish failed:\n{stderr}",
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
},
"PASSED: {pair}": {
"en": "PASSED: {pair}"
},
"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}"
},
"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 PyPI.": {
"en": "Published to PyPI.",
"bg": "Публикувано в PyPI.",
"de": "In PyPI veröffentlicht.",
"ru": "Опубликовано в PyPI.",
"zh": "已发布到 PyPI。"
},
"Pushed release commit to master.": {
"en": "Pushed release commit to master."
},
"Rebased and pushed. Retrying merge...": {
"en": "Rebased and pushed. Retrying merge..."
},
"Release creation failed: {error}": {
"en": "Release creation failed: {error}"
},
"Release must be run on master, currently on '{branch}'.": {
"en": "Release must be run on master, currently on '{branch}'."
},
"Repository configuration complete.": {
"en": "Repository configuration complete.",
"bg": "Конфигурирането на хранилището е завършено.",
"de": "Repository-Konfiguration abgeschlossen.",
"ru": "Конфигурация репозитория завершена.",
"zh": "仓库配置完成。"
},
"Runner index {index} out of range (0..{max})": {
"en": "Runner index {index} out of range (0..{max})",
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
"ru": "Индекс runner {index} вне диапазона (0..{max})",
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
},
"Running lint checks...": {
"en": "Running lint checks..."
},
"Running tests...": {
"en": "Running tests..."
},
"Running: {scenario} on {platform}": {
"en": "Running: {scenario} on {platform}"
},
"Skipping commit push — no staged changes.": {
"en": "Skipping commit push — no staged changes."
},
"Syncing {count} documentation pages to wiki...": {
"en": "Syncing {count} documentation pages to wiki..."
},
"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."
},
"Tag {tag} already exists, skipping creation.": {
"en": "Tag {tag} already exists, skipping creation."
},
"Task ID: {task_id}": {
"en": "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}"
},
"Tests passed.": {
"en": "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)."
},
"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."
},
"Updated version in {init}": {
"en": "Updated version in {init}"
},
"Updated {changelog_file}": {
"en": "Updated {changelog_file}"
},
"WARNING: --skip-tests passed — skipping test verification.": {
"en": "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 (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."
},
"Wiki integrity check failed — {count} issue(s)": {
"en": "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"
},
"[dry-run] Would commit: release: v{version}": {
"en": "[dry-run] Would commit: release: v{version}"
},
"[dry-run] Would create tag: v{version}": {
"en": "[dry-run] Would create tag: v{version}"
},
"[dry-run] Would create tag: {tag}": {
"en": "[dry-run] Would create tag: {tag}"
},
"[dry-run] Would push commit to master": {
"en": "[dry-run] Would push commit to master"
},
"[dry-run] Would sync page: {title} ({chars} chars)": {
"en": "[dry-run] Would sync page: {title} ({chars} chars)"
},
"[dry-run] Would update {changelog_file}": {
"en": "[dry-run] Would update {changelog_file}"
},
"[dry-run] Would update {init}": {
"en": "[dry-run] Would update {init}"
},
"git command failed ({cmd}): {stderr}": {
"en": "git command failed ({cmd}): {stderr}"
},
"git-cliff returned empty version.": {
"en": "git-cliff returned empty version."
}
}