GRM-20: feat: user-friendly click errors with i18n in configure_repo
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled

This commit is contained in:
Emil Simeonov
2026-06-19 19:33:04 +02:00
parent 4fa3aeb54c
commit e1b589efab
3 changed files with 187 additions and 23 deletions
+54 -20
View File
@@ -5,10 +5,12 @@ Usage:
GITEA_ADMIN_TOKEN=<token> python3 scripts/configure_repo.py
"""
import os
import sys
import click
import requests
from gitea_runner_manager.i18n import _
GITEA_API = "https://git.oblachno.oblachno.fyi/api/v1"
OWNER = "oblachno-oss"
@@ -98,33 +100,65 @@ class GiteaRepoConfig:
return self.create_label(name, color, description)
def _handle_http_error(e: requests.HTTPError) -> None:
"""Raise a user-friendly Click exception for HTTP errors."""
response = e.response
status = response.status_code if response else 0
if status == 403:
raise click.ClickException(
_(
"HTTP {status} Forbidden — your token lacks admin rights.\n"
"Make sure the token belongs to a repo owner or organisation admin.\n"
"Alternatively, configure branch protection manually in Settings → Branches.",
status=status,
)
)
try:
body = response.json() if response else {}
message = body.get("message", str(e))
except Exception:
message = str(e)
raise click.ClickException(
_("HTTP error: {status}{message}", status=status, message=message)
)
def main() -> None:
token = os.environ.get("GITEA_ADMIN_TOKEN", "")
if not token:
print("ERROR: GITEA_ADMIN_TOKEN is not set.", file=sys.stderr)
sys.exit(1)
raise click.ClickException(_("ERROR: GITEA_ADMIN_TOKEN is not set."))
cfg = GiteaRepoConfig(GITEA_API, token, OWNER, REPO)
print("Configuring branch protection for master...")
cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
print(" - Direct pushes: BLOCKED (require PR)")
print(" - Required approvals: 1")
print(" - Dismiss stale approvals: yes")
print(" - Block outdated branches: yes")
print(" - Block rejected reviews: yes")
print(" - Required status checks: lint, unit-tests, molecule-tests")
try:
click.echo(_("Configuring branch protection for {branch}...", branch="master"))
cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
click.echo(_(" - Direct pushes: BLOCKED (require PR)"))
click.echo(
_(
" - Required approvals: {count}",
count=BRANCH_PROTECTION_CONFIG["required_approvals"],
)
)
click.echo(_(" - Dismiss stale approvals: yes"))
click.echo(_(" - Block outdated branches: yes"))
click.echo(_(" - Block rejected reviews: yes"))
checks = ", ".join(BRANCH_PROTECTION_CONFIG["status_check_contexts"])
click.echo(_(" - Required status checks: {checks}", checks=checks))
print("")
print("Creating ready-to-merge label...")
result = cfg.ensure_label(**LABEL_CONFIG)
if result is None:
print(" Label 'ready-to-merge' already exists.")
else:
print(" Label 'ready-to-merge' created.")
click.echo("")
label_name = LABEL_CONFIG["name"]
click.echo(_("Creating {label} label...", label=label_name))
result = cfg.ensure_label(**LABEL_CONFIG)
if result is None:
click.echo(_(" Label '{label}' already exists.", label=label_name))
else:
click.echo(_(" Label '{label}' created.", label=label_name))
print("")
print("Repository configuration complete.")
click.echo("")
click.echo(_("Repository configuration complete."))
except requests.HTTPError as e:
_handle_http_error(e)
if __name__ == "__main__": # pragma: no cover
+98
View File
@@ -502,6 +502,104 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"ru": "Ad-hoc команда не удалась на {host}: {stderr}",
"zh": "Ad-hoc 命令在 {host} 上失败: {stderr}",
},
"ERROR: GITEA_ADMIN_TOKEN is not set.": {
"en": "ERROR: GITEA_ADMIN_TOKEN is not set.",
"bg": "ГРЕШКА: GITEA_ADMIN_TOKEN не е зададен.",
"de": "FEHLER: GITEA_ADMIN_TOKEN ist nicht gesetzt.",
"ru": "ОШИБКА: GITEA_ADMIN_TOKEN не задан.",
"zh": "错误:未设置 GITEA_ADMIN_TOKEN。",
},
"Configuring branch protection for {branch}...": {
"en": "Configuring branch protection for {branch}...",
"bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...",
"ru": "Настройка защиты ветки {branch}...",
"zh": "正在配置 {branch} 的分支保护...",
},
" - Direct pushes: BLOCKED (require PR)": {
"en": " - Direct pushes: BLOCKED (require PR)",
"bg": " - Директни push-ове: БЛОКИРАНИ (изисква PR)",
"de": " - Direkte Pushes: BLOCKIERT (PR erforderlich)",
"ru": " - Прямые push: ЗАБЛОКИРОВАНЫ (требуется PR)",
"zh": " - 直接推送:已阻止(需要 PR",
},
" - Required approvals: {count}": {
"en": " - Required approvals: {count}",
"bg": " - Необходими одобрения: {count}",
"de": " - Erforderliche Genehmigungen: {count}",
"ru": " - Требуемые одобрения: {count}",
"zh": " - 必需审批数: {count}",
},
" - Dismiss stale approvals: yes": {
"en": " - Dismiss stale approvals: yes",
"bg": " - Анулиране на остарели одобрения: да",
"de": " - Veraltete Genehmigungen ablehnen: 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": " - 阻止被拒绝的审查: 是",
},
" - Required status checks: {checks}": {
"en": " - Required status checks: {checks}",
"bg": " - Необходими проверки на състоянието: {checks}",
"de": " - Erforderliche Status-Checks: {checks}",
"ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}",
},
"Creating {label} label...": {
"en": "Creating {label} label...",
"bg": "Създаване на етикет {label}...",
"de": "Erstelle Label {label}...",
"ru": "Создание метки {label}...",
"zh": "正在创建标签 {label}...",
},
" Label '{label}' already exists.": {
"en": " Label '{label}' already exists.",
"bg": " Етикетът '{label}' вече съществува.",
"de": " Label '{label}' existiert bereits.",
"ru": " Метка '{label}' уже существует.",
"zh": " 标签 '{label}' 已存在。",
},
" Label '{label}' created.": {
"en": " Label '{label}' created.",
"bg": " Етикетът '{label}' е създаден.",
"de": " Label '{label}' erstellt.",
"ru": " Метка '{label}' создана.",
"zh": " 标签 '{label}' 已创建。",
},
"Repository configuration complete.": {
"en": "Repository configuration complete.",
"bg": "Конфигурирането на хранилището е завършено.",
"de": "Repository-Konfiguration abgeschlossen.",
"ru": "Конфигурация репозитория завершена.",
"zh": "仓库配置完成。",
},
"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.": { # noqa: E501
"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.", # noqa: E501
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.", # noqa: E501
"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.", # noqa: E501
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.", # noqa: E501
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。", # noqa: E501
},
"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}",
},
"exit code {code}": {
"en": "exit code {code}",
"bg": "код за изход {code}",
+35 -3
View File
@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch
import click
import pytest
import requests
@@ -9,6 +10,7 @@ from scripts.configure_repo import (
BRANCH_PROTECTION_CONFIG,
LABEL_CONFIG,
GiteaRepoConfig,
_handle_http_error,
main,
)
@@ -203,9 +205,9 @@ class TestGiteaRepoConfig:
class TestMain:
def test_main_missing_token(self) -> None:
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(SystemExit) as exc:
with pytest.raises(click.ClickException) as exc:
main()
assert exc.value.code == 1
assert "GITEA_ADMIN_TOKEN" in str(exc.value)
def test_main_success(self) -> None:
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
@@ -237,8 +239,38 @@ class TestMain:
mock_cfg.ensure_branch_protection.side_effect = requests.HTTPError("403")
mock_cfg_class.return_value = mock_cfg
with pytest.raises(requests.HTTPError):
with pytest.raises(click.ClickException) as exc:
main()
assert "HTTP" in str(exc.value)
def test_handle_http_error_403(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 403
mock_response.json.return_value = {"message": "Forbidden"}
err = requests.HTTPError("403", response=mock_response)
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
msg = str(exc.value)
assert "admin rights" in msg
assert "Settings → Branches" in msg
def test_handle_http_error_other(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.json.return_value = {"message": "Internal Server Error"}
err = requests.HTTPError("500", response=mock_response)
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
assert "500" in str(exc.value)
def test_handle_http_error_json_parse_fails(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 502
mock_response.json.side_effect = ValueError("not json")
err = requests.HTTPError("502", response=mock_response)
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
assert "502" in str(exc.value)
def test_main_module_block() -> None: