From e1b589efab4fcdc67e4af1ca335f8efb0e39725b Mon Sep 17 00:00:00 2001 From: Emil Simeonov Date: Fri, 19 Jun 2026 19:33:04 +0200 Subject: [PATCH] GRM-20: feat: user-friendly click errors with i18n in configure_repo --- scripts/configure_repo.py | 74 ++++++++++++++++------- src/gitea_runner_manager/i18n.py | 98 +++++++++++++++++++++++++++++++ tests/unit/test_configure_repo.py | 38 +++++++++++- 3 files changed, 187 insertions(+), 23 deletions(-) diff --git a/scripts/configure_repo.py b/scripts/configure_repo.py index 28f2e92..300fbc4 100644 --- a/scripts/configure_repo.py +++ b/scripts/configure_repo.py @@ -5,10 +5,12 @@ Usage: GITEA_ADMIN_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 diff --git a/src/gitea_runner_manager/i18n.py b/src/gitea_runner_manager/i18n.py index 83a8290..ba442a3 100644 --- a/src/gitea_runner_manager/i18n.py +++ b/src/gitea_runner_manager/i18n.py @@ -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}", diff --git a/tests/unit/test_configure_repo.py b/tests/unit/test_configure_repo.py index 2f6dad2..76fef5d 100644 --- a/tests/unit/test_configure_repo.py +++ b/tests/unit/test_configure_repo.py @@ -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: