diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index babd920..e6d2d1d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: hooks: - id: validate-commit-msg name: validate commit message - entry: python3 scripts/validate_commit_msg.py + entry: .venv/bin/python scripts/validate_commit_msg.py language: system stages: [commit-msg] pass_filenames: true diff --git a/scripts/auto_merge.py b/scripts/auto_merge.py index 72b68c2..eef181c 100644 --- a/scripts/auto_merge.py +++ b/scripts/auto_merge.py @@ -4,13 +4,15 @@ Usage: GITEA_TOKEN= python3 scripts/auto_merge.py """ -import argparse + import os import re -import sys +import click import requests +from gitea_runner_manager.i18n import _ + GITEA_API = "https://git.oblachno.oblachno.fyi/api/v1" TASK_ID_RE = re.compile(r"GRM-\d+") CONVENTIONAL_RE = re.compile( @@ -25,12 +27,16 @@ def extract_task_id(branch: str) -> str: def validate_pr_title(pr_title: str) -> None: - """Raise SystemExit if PR title does not follow conventional commits.""" + """Raise ClickException if PR title does not follow conventional commits.""" if not CONVENTIONAL_RE.match(pr_title): - print("ERROR: PR title must follow conventional commit format.") - print(" Expected: : ") - print(f" Got: {pr_title}") - sys.exit(1) + raise click.ClickException( + _( + "Oops! PR title must follow conventional commit format.\n" + " Expected: : \n" + " Got: {pr_title}", + pr_title=pr_title, + ) + ) def merge_pr(token: str, repo: str, pr_number: str, merge_title: str) -> None: @@ -45,30 +51,53 @@ def merge_pr(token: str, repo: str, pr_number: str, merge_title: str) -> None: response.raise_for_status() -def main(args: list[str] | None = None) -> None: # pragma: no cover - argv = args if args is not None else sys.argv - parser = argparse.ArgumentParser(description="Auto-merge a PR with task ID") - parser.add_argument("branch", help="Source branch name") - parser.add_argument("pr_title", help="Pull request title") - parser.add_argument("repo", help="Repository full name (owner/repo)") - parser.add_argument("pr_number", help="Pull request number") - parsed = parser.parse_args(argv[1:]) - +@click.command() +@click.argument("branch") +@click.argument("pr_title") +@click.argument("repo") +@click.argument("pr_number") +def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: token = os.environ.get("GITEA_TOKEN", "") if not token: - print("ERROR: GITEA_TOKEN is not set.", file=sys.stderr) - sys.exit(1) + raise click.ClickException(_("ERROR: GITEA_TOKEN is not set.")) - task_id = extract_task_id(parsed.branch) + task_id = extract_task_id(branch) if not task_id: - print(f"ERROR: No task ID (GRM-N) found in branch name '{parsed.branch}'") - sys.exit(1) + raise click.ClickException( + _( + "Oops! No task ID (GRM-N) found in branch name '{branch}'.", + branch=branch, + ) + ) - validate_pr_title(parsed.pr_title) + validate_pr_title(pr_title) - merge_title = f"{task_id}: {parsed.pr_title}" - merge_pr(token, parsed.repo, parsed.pr_number, merge_title) - print(f"PR #{parsed.pr_number} squash-merged with title: {merge_title}") + merge_title = f"{task_id}: {pr_title}" + try: + merge_pr(token, repo, pr_number, merge_title) + except requests.HTTPError as e: + response = e.response + status = response.status_code if response else 0 + try: + body = response.json() if response else {} + message = body.get("message", str(e)) + except Exception: + message = str(e) + raise click.ClickException( + _( + "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.", + status=status, + message=message, + ) + ) from None + + click.echo( + _( + "Nice! PR #{pr_number} squash-merged with title: {merge_title}", + pr_number=pr_number, + merge_title=merge_title, + ) + ) if __name__ == "__main__": # pragma: no cover diff --git a/scripts/configure_repo.py b/scripts/configure_repo.py index 8e2f7d8..3e0fc7d 100644 --- a/scripts/configure_repo.py +++ b/scripts/configure_repo.py @@ -4,6 +4,7 @@ Usage: GITEA_ADMIN_TOKEN= python3 scripts/configure_repo.py """ + import http import os @@ -12,7 +13,6 @@ import requests from gitea_runner_manager.i18n import _ - GITEA_API = "https://git.oblachno.oblachno.fyi/api/v1" OWNER = "oblachno-oss" REPO = "grm" @@ -44,10 +44,12 @@ class GiteaRepoConfig: self._owner = owner self._repo = repo self._session = requests.Session() - self._session.headers.update({ - "Authorization": f"token {token}", - "Content-Type": "application/json", - }) + self._session.headers.update( + { + "Authorization": f"token {token}", + "Content-Type": "application/json", + } + ) def _url(self, path: str) -> str: return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}" @@ -63,9 +65,7 @@ class GiteaRepoConfig: return r.json() def update_branch_protection(self, protection_id: int, config: dict) -> dict: - r = self._session.patch( - self._url(f"/branch_protections/{protection_id}"), json=config - ) + r = self._session.patch(self._url(f"/branch_protections/{protection_id}"), json=config) r.raise_for_status() return r.json() @@ -119,9 +119,7 @@ def _handle_http_error(e: requests.HTTPError) -> None: message = body.get("message", str(e)) except Exception: message = str(e) - raise click.ClickException( - _("HTTP error: {status} — {message}", status=status, message=message) - ) + raise click.ClickException(_("HTTP error: {status} — {message}", status=status, message=message)) def main() -> None: diff --git a/scripts/post_merge.py b/scripts/post_merge.py index 02bb996..099c746 100644 --- a/scripts/post_merge.py +++ b/scripts/post_merge.py @@ -2,16 +2,17 @@ """Update Vikunja task after a merge to master. Usage: - VIKUNJA_TOKEN= python3 scripts/post_merge.py + VIKUNJA_TOKEN= python3 scripts/post_merge.py [--commit-sha ] """ -import argparse -import json + import os import re -import sys +import click import requests +from gitea_runner_manager.i18n import _ + VIKUNJA_API = "https://work.oblachno.oblachno.fyi/api/v1" TASK_ID_RE = re.compile(r"GRM-\d+") PROJECT_ID = 6 @@ -30,6 +31,24 @@ def extract_conventional_msg(commit_msg: str) -> str: return re.sub(r"^GRM-\d+:\s*", "", first_line) +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 + try: + body = response.json() if response else {} + message = body.get("message", str(e)) + except Exception: + message = str(e) + raise click.ClickException( + _( + "Vikunja API error: HTTP {status} — {message}", + status=status, + message=message, + ) + ) + + def resolve_task_id(token: str, task_id: str) -> int: """Resolve GRM-N identifier to Vikunja numeric task ID.""" url = f"{VIKUNJA_API}/tasks/all" @@ -38,13 +57,15 @@ def resolve_task_id(token: str, task_id: str) -> int: response = requests.get(url, headers=headers, params=params, timeout=30) response.raise_for_status() tasks = response.json() - matches = [ - t for t in tasks - if t.get("project_id") == PROJECT_ID and t.get("identifier") == task_id - ] + matches = [t for t in tasks if t.get("project_id") == PROJECT_ID and t.get("identifier") == task_id] if not matches: - print(f"ERROR: Could not find Vikunja task for {task_id} in project {PROJECT_ID}") - sys.exit(1) + raise click.ClickException( + _( + "Could not find Vikunja task for {task_id} in project {project_id}.", + task_id=task_id, + project_id=PROJECT_ID, + ) + ) return int(matches[0]["id"]) @@ -74,37 +95,41 @@ def mark_task_done(token: str, task_id: int) -> None: def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str: """Build HTML comment body for Vikunja.""" - return ( - f"

{task_id}: {conv_msg}

" - f"

Commit: {commit_sha}

" - ) + return f"

{task_id}: {conv_msg}

Commit: {commit_sha}

" -def main(args: list[str] | None = None) -> None: # pragma: no cover - argv = args if args is not None else sys.argv - parser = argparse.ArgumentParser(description="Update Vikunja task after merge") - parser.add_argument("commit_msg", help="Full merge commit message") - parser.add_argument("--commit-sha", default="", help="Commit SHA") - parsed = parser.parse_args(argv[1:]) - +@click.command() +@click.argument("commit_msg") +@click.option("--commit-sha", default="", help="Commit SHA") +def main(commit_msg: str, commit_sha: str) -> None: token = os.environ.get("VIKUNJA_TOKEN", "") if not token: - print("ERROR: VIKUNJA_TOKEN is not set.", file=sys.stderr) - sys.exit(1) + raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) - task_id = extract_task_id(parsed.commit_msg) + task_id = extract_task_id(commit_msg) if not task_id: - print("No task ID in commit message, skipping Vikunja update.") + click.echo(_("No task ID in commit message, skipping Vikunja update. All good — nothing to do here!")) return - vikunja_task_id = resolve_task_id(token, task_id) - conv_msg = extract_conventional_msg(parsed.commit_msg) - commit_sha = parsed.commit_sha or "unknown" - html = build_comment(task_id, conv_msg, commit_sha) + vikunja_task_id = 0 + try: + vikunja_task_id = resolve_task_id(token, task_id) + conv_msg = extract_conventional_msg(commit_msg) + sha = commit_sha or "unknown" + html = build_comment(task_id, conv_msg, sha) - post_comment(token, vikunja_task_id, html) - mark_task_done(token, vikunja_task_id) - print(f"Vikunja task {task_id} (ID {vikunja_task_id}) updated and marked done.") + post_comment(token, vikunja_task_id, html) + mark_task_done(token, vikunja_task_id) + except requests.HTTPError as e: + _handle_http_error(e) + + click.echo( + _( + "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.", + task_id=task_id, + vikunja_id=vikunja_task_id, + ) + ) if __name__ == "__main__": # pragma: no cover diff --git a/scripts/publish.py b/scripts/publish.py index 5ccfb07..7f7c01d 100644 --- a/scripts/publish.py +++ b/scripts/publish.py @@ -4,13 +4,16 @@ Usage: GITEA_TOKEN= [PYPI_TOKEN=] python3 scripts/publish.py """ -import argparse + import os import subprocess import sys +import click import requests +from gitea_runner_manager.i18n import _ + GITEA_API = "https://git.oblachno.oblachno.fyi/api/v1" @@ -23,27 +26,40 @@ def build_package() -> None: check=False, ) if result.returncode != 0: - print("ERROR: Package build failed.") - print(result.stderr) - sys.exit(1) + raise click.ClickException( + _( + "Oops! Package build failed:\n{stderr}", + stderr=result.stderr.strip(), + ) + ) def publish_to_pypi(token: str) -> None: """Publish built packages to PyPI using twine.""" result = subprocess.run( [ - sys.executable, "-m", "twine", "upload", "dist/*", - "-u", "__token__", "-p", token, + sys.executable, + "-m", + "twine", + "upload", + "dist/*", + "-u", + "__token__", + "-p", + token, ], capture_output=True, text=True, check=False, ) if result.returncode != 0: - print("ERROR: PyPI publish failed.") - print(result.stderr) - sys.exit(1) - print("Published to PyPI.") + raise click.ClickException( + _( + "Oops! PyPI publish failed:\n{stderr}", + stderr=result.stderr.strip(), + ) + ) + click.echo(_("Published to PyPI.")) def create_gitea_release(token: str, repo: str, tag: str) -> None: @@ -64,17 +80,13 @@ def create_gitea_release(token: str, repo: str, tag: str) -> None: response.raise_for_status() -def main(args: list[str] | None = None) -> None: # pragma: no cover - argv = args if args is not None else sys.argv - parser = argparse.ArgumentParser(description="Build and publish release") - parser.add_argument("tag", help="Git tag (e.g. v1.0.0)") - parser.add_argument("repo", help="Repository full name (owner/repo)") - parsed = parser.parse_args(argv[1:]) - +@click.command() +@click.argument("tag") +@click.argument("repo") +def main(tag: str, repo: str) -> None: gitea_token = os.environ.get("GITEA_TOKEN", "") if not gitea_token: - print("ERROR: GITEA_TOKEN is not set.", file=sys.stderr) - sys.exit(1) + raise click.ClickException(_("ERROR: GITEA_TOKEN is not set.")) pypi_token = os.environ.get("PYPI_TOKEN", "") @@ -83,10 +95,32 @@ def main(args: list[str] | None = None) -> None: # pragma: no cover if pypi_token: publish_to_pypi(pypi_token) else: - print("PYPI_TOKEN not set — skipping PyPI publish.") + click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.")) - create_gitea_release(gitea_token, parsed.repo, parsed.tag) - print(f"Gitea release {parsed.tag} created.") + try: + create_gitea_release(gitea_token, repo, tag) + except requests.HTTPError as e: + response = e.response + status = response.status_code if response else 0 + try: + body = response.json() if response else {} + message = body.get("message", str(e)) + except Exception: + message = str(e) + raise click.ClickException( + _( + "Release creation failed with HTTP {status}: {message}", + status=status, + message=message, + ) + ) from None + + click.echo( + _( + "Nice! Gitea release {tag} created.", + tag=tag, + ) + ) if __name__ == "__main__": # pragma: no cover diff --git a/scripts/validate_commit_msg.py b/scripts/validate_commit_msg.py index 62c1d5d..9cf2396 100644 --- a/scripts/validate_commit_msg.py +++ b/scripts/validate_commit_msg.py @@ -6,9 +6,13 @@ Rules: - On master branch: must follow ': ' pattern, e.g. 'GRM-24: fix: resolve timeout'. """ + import re import subprocess -import sys + +import click + +from gitea_runner_manager.i18n import _ CONVENTIONAL_RE = re.compile( r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|BREAKING CHANGE)(\(.+\))?: .+" @@ -24,53 +28,65 @@ def get_branch() -> str: try: result = subprocess.run( ["git", "symbolic-ref", "--short", "HEAD"], - capture_output=True, text=True, check=True, + capture_output=True, + text=True, + check=True, ) return result.stdout.strip() except subprocess.CalledProcessError: return "" -def main(args=None) -> None: - argv = args if args is not None else sys.argv - if len(argv) < 2: - print("Usage: validate_commit_msg.py ") - sys.exit(1) - - with open(argv[1]) as f: +@click.command() +@click.argument("commit_msg_file") +def main(commit_msg_file: str) -> None: + with open(commit_msg_file) as f: msg = f.read().strip() branch = get_branch() - subject = first_line(msg) if branch == "master": if not TASK_ID_RE.match(subject): - print("ERROR: Master branch commits must start with a task ID.") - print(" Expected: GRM-N: ") - print(f" Got: {subject}") - sys.exit(1) - # Strip task-id prefix and validate the remainder as conventional + raise click.ClickException( + _( + "Oops! Master branch commits must start with a task ID.\n" + " Expected: GRM-N: \n" + " Got: {subject}", + subject=subject, + ) + ) remainder = TASK_ID_RE.sub("", subject).strip() if not CONVENTIONAL_RE.match(remainder): - print("ERROR: Master branch commit message must follow conventional format after task ID.") - print(" Expected: GRM-N: : ") - print(f" Got: {subject}") - sys.exit(1) + raise click.ClickException( + _( + "Oops! Master branch commit must follow conventional format after task ID.\n" + " Expected: GRM-N: : \n" + " Got: {subject}", + subject=subject, + ) + ) return if TASK_ID_RE.match(subject): - print("ERROR: Do not include task ID (GRM-N) in feature branch commits.") - print(" Task ID will be added automatically on merge via CI.") - sys.exit(1) + raise click.ClickException( + _( + "Oops! Do not include task ID (GRM-N) in feature branch commits.\n" + " The task ID will be added automatically on merge via CI." + ) + ) if not CONVENTIONAL_RE.match(subject): - print("ERROR: Commit message must follow conventional commit format.") - print(" Expected: : ") - print(f" Got: {subject}") - print(" Allowed types: feat, fix, chore, docs, style, refactor,") - print(" perf, test, ci, build, revert, BREAKING CHANGE") - sys.exit(1) + raise click.ClickException( + _( + "Oops! Commit message must follow conventional commit format.\n" + " Expected: : \n" + " Got: {subject}\n" + " Allowed types: feat, fix, chore, docs, style, refactor,\n" + " perf, test, ci, build, revert, BREAKING CHANGE", + subject=subject, + ) + ) if __name__ == "__main__": # pragma: no cover diff --git a/src/gitea_runner_manager/i18n.py b/src/gitea_runner_manager/i18n.py index ba442a3..8f5e717 100644 --- a/src/gitea_runner_manager/i18n.py +++ b/src/gitea_runner_manager/i18n.py @@ -600,6 +600,146 @@ TRANSLATIONS: dict[str, dict[str, str]] = { "ru": "Ошибка HTTP: {status} — {message}", "zh": "HTTP 错误: {status} — {message}", }, + "ERROR: GITEA_TOKEN is not set.": { + "en": "ERROR: GITEA_TOKEN is not set.", + "bg": "ГРЕШКА: GITEA_TOKEN не е зададен.", + "de": "FEHLER: GITEA_TOKEN ist nicht gesetzt.", + "ru": "ОШИБКА: GITEA_TOKEN не задан.", + "zh": "错误:未设置 GITEA_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。", + }, + "Oops! PR title must follow conventional commit format.\n Expected: : \n Got: {pr_title}": { # noqa: E501 + "en": "Oops! PR title must follow conventional commit format.\n Expected: : \n Got: {pr_title}", # noqa: E501 + "bg": "Опа! Заглавието на PR трябва да следва конвенционален формат.\n Очаква се: : \n Получено: {pr_title}", # noqa: E501 + "de": "Ups! PR-Titel muss dem konventionellen Commit-Format folgen.\n Erwartet: : \n Erhalten: {pr_title}", # noqa: E501 + "ru": "Ой! Заголовок PR должен соответствовать формату conventional commit.\n Ожидается: : \n Получено: {pr_title}", # noqa: E501 + "zh": "哎呀!PR 标题必须遵循 conventional commit 格式。\n 预期格式: : \n 实际: {pr_title}", + }, + "Oops! No task ID (GRM-N) found in branch name '{branch}'.": { + "en": "Oops! No task ID (GRM-N) found in branch name '{branch}'.", + "bg": "Опа! Не е намерен идентификатор на задача (GRM-N) в името на клона '{branch}'.", + "de": "Ups! Keine Task-ID (GRM-N) im Branch-Namen '{branch}' gefunden.", + "ru": "Ой! В названии ветки '{branch}' не найден идентификатор задачи (GRM-N).", + "zh": "哎呀!在分支名 '{branch}' 中未找到任务 ID (GRM-N)。", + }, + "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": { # noqa: E501 + "en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.", # noqa: E501 + "bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.", # noqa: E501 + "de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.", # noqa: E501 + "ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.", # noqa: E501 + "zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。", # noqa: E501 + }, + "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}", + }, + "No task ID in commit message, skipping Vikunja update. All good — nothing to do here!": { # noqa: E501 + "en": "No task ID in commit message, skipping Vikunja update. All good — nothing to do here!", # noqa: E501 + "bg": "Няма идентификатор на задача в съобщението за commit, пропускаме обновяването на Vikunja. Всичко е наред — няма какво да правим!", # noqa: E501 + "de": "Keine Task-ID in der Commit-Nachricht, Vikunja-Update wird übersprungen. Alles gut — nichts zu tun!", # noqa: E501 + "ru": "В сообщении коммита нет ID задачи, пропускаем обновление Vikunja. Всё в порядке — делать нечего!", # noqa: E501 + "zh": "提交消息中没有任务 ID,跳过 Vikunja 更新。一切正常 — 无需操作!", # noqa: E501 + }, + "Could not find Vikunja task for {task_id} in project {project_id}.": { + "en": "Could not find Vikunja task for {task_id} in project {project_id}.", + "bg": "Не е намерена задача Vikunja за {task_id} в проект {project_id}.", + "de": "Keine Vikunja-Aufgabe für {task_id} in Projekt {project_id} gefunden.", + "ru": "Не удалось найти задачу Vikunja для {task_id} в проекте {project_id}.", + "zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。", + }, + "Vikunja API error: HTTP {status} — {message}": { + "en": "Vikunja API error: HTTP {status} — {message}", + "bg": "Грешка в API на Vikunja: HTTP {status} — {message}", + "de": "Vikunja API-Fehler: HTTP {status} — {message}", + "ru": "Ошибка API Vikunja: HTTP {status} — {message}", + "zh": "Vikunja API 错误: HTTP {status} — {message}", + }, + "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}) 已更新并标记为完成。", + }, + "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}", + }, + "Published to PyPI.": { + "en": "Published to PyPI.", + "bg": "Публикувано в PyPI.", + "de": "In PyPI veröffentlicht.", + "ru": "Опубликовано в PyPI.", + "zh": "已发布到 PyPI。", + }, + "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.": { # noqa: E501 + "en": "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.", # noqa: E501 + "bg": "PYPI_TOKEN не е зададен — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.", # noqa: E501 + "de": "PYPI_TOKEN nicht gesetzt — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.", # noqa: E501 + "ru": "PYPI_TOKEN не задан — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.", # noqa: E501 + "zh": "未设置 PYPI_TOKEN — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。", # noqa: E501 + }, + "Release creation failed with HTTP {status}: {message}": { + "en": "Release creation failed with HTTP {status}: {message}", + "bg": "Създаването на release неуспешно с HTTP {status}: {message}", + "de": "Release-Erstellung fehlgeschlagen mit HTTP {status}: {message}", + "ru": "Создание release не удалось: HTTP {status}: {message}", + "zh": "Release 创建失败: HTTP {status}: {message}", + }, + "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} 已创建。", + }, + "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: \n Got: {subject}": { # noqa: E501 + "en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: \n Got: {subject}", # noqa: E501 + "bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: \n Получено: {subject}", # noqa: E501 + "de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: \n Erhalten: {subject}", # noqa: E501 + "ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: \n Получено: {subject}", # noqa: E501 + "zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: \n 实际: {subject}", # noqa: E501 + }, + "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: : \n Got: {subject}": { # noqa: E501 + "en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: : \n Got: {subject}", # noqa: E501 + "bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: : \n Получено: {subject}", # noqa: E501 + "de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: : \n Erhalten: {subject}", # noqa: E501 + "ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: : \n Получено: {subject}", # noqa: E501 + "zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: : \n 实际: {subject}", # noqa: E501 + }, + "Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": { # noqa: E501 + "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.", # noqa: E501 + "bg": "Опа! Не включвайте идентификатор на задача (GRM-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.", # noqa: E501 + "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.", # noqa: E501 + "ru": "Ой! Не включайте ID задачи (GRM-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.", # noqa: E501 + "zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (GRM-N)。\n 任务 ID 将在通过 CI 合并时自动添加。", # noqa: E501 + }, + "Oops! Commit message must follow conventional commit format.\n Expected: : \n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": { # noqa: E501 + "en": "Oops! Commit message must follow conventional commit format.\n Expected: : \n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", # noqa: E501 + "bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: : \n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", # noqa: E501 + "de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: : \n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", # noqa: E501 + "ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: : \n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", # noqa: E501 + "zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: : \n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", # noqa: E501 + }, "exit code {code}": { "en": "exit code {code}", "bg": "код за изход {code}", diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index 813eaac..d860cd4 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -2,8 +2,10 @@ from unittest.mock import MagicMock, patch +import click import pytest import requests +from click.testing import CliRunner from scripts.auto_merge import ( CONVENTIONAL_RE, @@ -51,10 +53,10 @@ class TestValidatePrTitle: def test_valid_title_with_scope_passes(self) -> None: validate_pr_title("feat(cli): add --url option") - def test_invalid_title_exits(self) -> None: - with pytest.raises(SystemExit) as exc: + def test_invalid_title_raises(self) -> None: + with pytest.raises(click.ClickException) as exc: validate_pr_title("random message") - assert exc.value.code == 1 + assert "conventional" in str(exc.value) class TestMergePr: @@ -82,33 +84,55 @@ class TestMergePr: class TestMain: @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) @patch("scripts.auto_merge.merge_pr") - def test_successful_flow(self, mock_merge: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: - main(["auto_merge.py", "GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7"]) + def test_successful_flow(self, mock_merge: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke( + main, + ["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7"], + ) + assert result.exit_code == 0 + assert "squash-merged" in result.output mock_merge.assert_called_once_with("tok", "owner/repo", "7", "GRM-19: fix: resolve timeout") - captured = capsys.readouterr() - assert "squash-merged" in captured.out @patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True) def test_missing_token_exits(self) -> None: - with pytest.raises(SystemExit) as exc: - main(["auto_merge.py", "branch", "title", "repo", "1"]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, ["branch", "title", "repo", "1"]) + assert result.exit_code == 1 + assert "GITEA_TOKEN" in result.output @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) def test_missing_task_id_exits(self) -> None: - with pytest.raises(SystemExit) as exc: - main(["auto_merge.py", "feature-no-id", "fix: bug", "repo", "1"]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, ["feature-no-id", "fix: bug", "repo", "1"]) + assert result.exit_code == 1 + assert "task ID" in result.output @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) def test_invalid_pr_title_exits(self) -> None: - with pytest.raises(SystemExit) as exc: - main(["auto_merge.py", "GRM-19-fix", "random title", "repo", "1"]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, ["GRM-19-fix", "random title", "repo", "1"]) + assert result.exit_code == 1 + assert "conventional" in result.output @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) @patch("scripts.auto_merge.merge_pr") - def test_merge_pr_failure_propagates(self, mock_merge: MagicMock) -> None: + def test_merge_pr_failure_raises_click(self, mock_merge: MagicMock) -> None: mock_merge.side_effect = requests.HTTPError("500") - with pytest.raises(requests.HTTPError): - main(["auto_merge.py", "GRM-19-fix", "fix: bug", "repo", "1"]) + runner = CliRunner() + result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "repo", "1"]) + assert result.exit_code == 1 + assert "HTTP" in result.output + + @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) + @patch("scripts.auto_merge.merge_pr") + def test_merge_pr_json_parse_failure(self, mock_merge: MagicMock) -> None: + mock_response = MagicMock() + mock_response.status_code = 502 + mock_response.json.side_effect = ValueError("not json") + err = requests.HTTPError("502", response=mock_response) + mock_merge.side_effect = err + runner = CliRunner() + result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "repo", "1"]) + assert result.exit_code == 1 + assert "502" in result.output diff --git a/tests/unit/test_post_merge.py b/tests/unit/test_post_merge.py index 589a787..8a95732 100644 --- a/tests/unit/test_post_merge.py +++ b/tests/unit/test_post_merge.py @@ -2,8 +2,10 @@ from unittest.mock import MagicMock, patch +import click import pytest import requests +from click.testing import CliRunner from scripts.post_merge import ( PROJECT_ID, @@ -54,13 +56,13 @@ class TestResolveTaskId: mock_get.assert_called_once() @patch("scripts.post_merge.requests.get") - def test_not_found_exits(self, mock_get: MagicMock) -> None: + def test_not_found_raises(self, mock_get: MagicMock) -> None: mock_response = MagicMock() mock_response.json.return_value = [] mock_get.return_value = mock_response - with pytest.raises(SystemExit) as exc: + with pytest.raises(click.ClickException) as exc: resolve_task_id("tok", "GRM-99") - assert exc.value.code == 1 + assert "Could not find" in str(exc.value) @patch("scripts.post_merge.requests.get") def test_wrong_project_filtered(self, mock_get: MagicMock) -> None: @@ -69,9 +71,9 @@ class TestResolveTaskId: {"id": 42, "project_id": 999, "identifier": "GRM-19"}, ] mock_get.return_value = mock_response - with pytest.raises(SystemExit) as exc: + with pytest.raises(click.ClickException) as exc: resolve_task_id("tok", "GRM-19") - assert exc.value.code == 1 + assert "Could not find" in str(exc.value) @patch("scripts.post_merge.requests.get") def test_http_error_propagates(self, mock_get: MagicMock) -> None: @@ -119,21 +121,36 @@ class TestMarkTaskDone: mark_task_done("tok", 42) +class TestHandleHttpError: + def test_json_parse_failure(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: + from scripts.post_merge import _handle_http_error + + _handle_http_error(err) + assert "502" in str(exc.value) + + class TestMain: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("scripts.post_merge.resolve_task_id") @patch("scripts.post_merge.post_comment") @patch("scripts.post_merge.mark_task_done") - def test_full_flow( - self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock, capsys: pytest.CaptureFixture[str] - ) -> None: + def test_full_flow(self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock) -> None: mock_resolve.return_value = 267 - main(["post_merge.py", "GRM-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"]) + runner = CliRunner() + result = runner.invoke( + main, + ["GRM-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"], + ) + assert result.exit_code == 0 + assert "updated and marked done" in result.output mock_resolve.assert_called_once_with("tok", "GRM-20") mock_post.assert_called_once() mock_mark.assert_called_once_with("tok", 267) - captured = capsys.readouterr() - assert "updated and marked done" in captured.out @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("scripts.post_merge.resolve_task_id") @@ -141,48 +158,57 @@ class TestMain: @patch("scripts.post_merge.mark_task_done") def test_no_commit_sha(self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock) -> None: mock_resolve.return_value = 267 - main(["post_merge.py", "GRM-20: fix: resolve bug"]) + runner = CliRunner() + result = runner.invoke(main, ["GRM-20: fix: resolve bug"]) + assert result.exit_code == 0 mock_post.assert_called_once() args, _ = mock_post.call_args assert "unknown" in args[2] @patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True) def test_missing_token_exits(self) -> None: - with pytest.raises(SystemExit) as exc: - main(["post_merge.py", "GRM-20: fix: bug"]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, ["GRM-20: fix: bug"]) + assert result.exit_code == 1 + assert "VIKUNJA_TOKEN" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - def test_no_task_id_skips(self, capsys: pytest.CaptureFixture[str]) -> None: - main(["post_merge.py", "fix: resolve bug"]) - captured = capsys.readouterr() - assert "skipping Vikunja update" in captured.out + def test_no_task_id_skips(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["fix: resolve bug"]) + assert result.exit_code == 0 + assert "skipping" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("scripts.post_merge.resolve_task_id") def test_resolve_failure_propagates(self, mock_resolve: MagicMock) -> None: - mock_resolve.side_effect = SystemExit(1) - with pytest.raises(SystemExit) as exc: - main(["post_merge.py", "GRM-20: fix: bug"]) - assert exc.value.code == 1 + mock_resolve.side_effect = click.ClickException("not found") + runner = CliRunner() + result = runner.invoke(main, ["GRM-20: fix: bug"]) + assert result.exit_code == 1 + assert "not found" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("scripts.post_merge.resolve_task_id") @patch("scripts.post_merge.post_comment") - def test_post_comment_failure_propagates(self, mock_post: MagicMock, mock_resolve: MagicMock) -> None: + def test_post_comment_failure_raises_click(self, mock_post: MagicMock, mock_resolve: MagicMock) -> None: mock_resolve.return_value = 267 mock_post.side_effect = requests.HTTPError("500") - with pytest.raises(requests.HTTPError): - main(["post_merge.py", "GRM-20: fix: bug"]) + runner = CliRunner() + result = runner.invoke(main, ["GRM-20: fix: bug"]) + assert result.exit_code == 1 + assert "HTTP" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("scripts.post_merge.resolve_task_id") @patch("scripts.post_merge.post_comment") @patch("scripts.post_merge.mark_task_done") - def test_mark_done_failure_propagates( + def test_mark_done_failure_raises_click( self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock ) -> None: mock_resolve.return_value = 267 mock_mark.side_effect = requests.HTTPError("500") - with pytest.raises(requests.HTTPError): - main(["post_merge.py", "GRM-20: fix: bug"]) + runner = CliRunner() + result = runner.invoke(main, ["GRM-20: fix: bug"]) + assert result.exit_code == 1 + assert "HTTP" in result.output diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index 7d9486b..15db66a 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -2,8 +2,10 @@ from unittest.mock import MagicMock, patch +import click import pytest import requests +from click.testing import CliRunner from scripts.publish import ( GITEA_API, @@ -24,30 +26,28 @@ class TestBuildPackage: assert args[0][2] == "build" @patch("scripts.publish.subprocess.run") - def test_failure_exits(self, mock_run: MagicMock) -> None: + def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="build error") - with pytest.raises(SystemExit) as exc: + with pytest.raises(click.ClickException) as exc: build_package() - assert exc.value.code == 1 + assert "build" in str(exc.value) class TestPublishToPypi: @patch("scripts.publish.subprocess.run") - def test_success(self, mock_run: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: + def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") publish_to_pypi("pypi-tok") args, _ = mock_run.call_args assert "twine" in args[0] assert "pypi-tok" in args[0] - captured = capsys.readouterr() - assert "Published to PyPI" in captured.out @patch("scripts.publish.subprocess.run") - def test_failure_exits(self, mock_run: MagicMock) -> None: + def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="upload failed") - with pytest.raises(SystemExit) as exc: + with pytest.raises(click.ClickException) as exc: publish_to_pypi("pypi-tok") - assert exc.value.code == 1 + assert "PyPI" in str(exc.value) class TestCreateGiteaRelease: @@ -81,14 +81,14 @@ class TestMain: mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock, - capsys: pytest.CaptureFixture[str], ) -> None: - main(["publish.py", "v1.0.0", "owner/repo"]) + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo"]) + assert result.exit_code == 0 + assert "Gitea release v1.0.0 created" in result.output mock_build.assert_called_once() mock_publish.assert_called_once_with("pypi-tok") mock_release.assert_called_once_with("gitea-tok", "owner/repo", "v1.0.0") - captured = capsys.readouterr() - assert "Gitea release v1.0.0 created" in captured.out @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok"}, clear=True) @patch("scripts.publish.create_gitea_release") @@ -97,45 +97,73 @@ class TestMain: self, mock_build: MagicMock, mock_release: MagicMock, - capsys: pytest.CaptureFixture[str], ) -> None: - main(["publish.py", "v1.0.0", "owner/repo"]) + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo"]) + assert result.exit_code == 0 mock_build.assert_called_once() mock_release.assert_called_once_with("gitea-tok", "owner/repo", "v1.0.0") - captured = capsys.readouterr() - assert "PYPI_TOKEN not set" in captured.out + assert "PYPI_TOKEN not set" in result.output @patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True) def test_missing_gitea_token_exits(self) -> None: - with pytest.raises(SystemExit) as exc: - main(["publish.py", "v1.0.0", "owner/repo"]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo"]) + assert result.exit_code == 1 + assert "GITEA_TOKEN" in result.output @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.create_gitea_release") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") - def test_build_failure_propagates(self, mock_build: MagicMock, *_: MagicMock) -> None: - mock_build.side_effect = SystemExit(1) - with pytest.raises(SystemExit) as exc: - main(["publish.py", "v1.0.0", "owner/repo"]) - assert exc.value.code == 1 + def test_build_failure_raises_click( + self, mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock + ) -> None: + mock_build.side_effect = click.ClickException("build failed") + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo"]) + assert result.exit_code == 1 + assert "build" in result.output @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.create_gitea_release") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") - def test_publish_failure_propagates(self, mock_publish: MagicMock, *_: MagicMock) -> None: - mock_publish.side_effect = SystemExit(1) - with pytest.raises(SystemExit) as exc: - main(["publish.py", "v1.0.0", "owner/repo"]) - assert exc.value.code == 1 + def test_publish_failure_raises_click( + self, mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock + ) -> None: + mock_publish.side_effect = click.ClickException("publish failed") + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo"]) + assert result.exit_code == 1 + assert "publish" in result.output @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.create_gitea_release") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") - def test_release_failure_propagates(self, mock_release: MagicMock, *_: MagicMock) -> None: + def test_release_failure_raises_click( + self, mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock + ) -> None: mock_release.side_effect = requests.HTTPError("500") - with pytest.raises(requests.HTTPError): - main(["publish.py", "v1.0.0", "owner/repo"]) + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo"]) + assert result.exit_code == 1 + assert "HTTP" in result.output + + @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch("scripts.publish.create_gitea_release") + @patch("scripts.publish.publish_to_pypi") + @patch("scripts.publish.build_package") + def test_release_json_parse_failure( + self, mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock + ) -> None: + mock_response = MagicMock() + mock_response.status_code = 502 + mock_response.json.side_effect = ValueError("not json") + err = requests.HTTPError("502", response=mock_response) + mock_release.side_effect = err + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo"]) + assert result.exit_code == 1 + assert "502" in result.output diff --git a/tests/unit/test_validate_commit_msg.py b/tests/unit/test_validate_commit_msg.py index 5a70ba4..3ac53c8 100644 --- a/tests/unit/test_validate_commit_msg.py +++ b/tests/unit/test_validate_commit_msg.py @@ -5,7 +5,7 @@ import subprocess import tempfile from unittest.mock import patch -import pytest +from click.testing import CliRunner from scripts.validate_commit_msg import CONVENTIONAL_RE, TASK_ID_RE, first_line, get_branch, main @@ -73,50 +73,60 @@ class TestMain: def test_rejects_task_id_on_feature_branch(self) -> None: msg_path = self._write_msg("GRM-19: feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"): - with pytest.raises(SystemExit) as exc: - main(["validate_commit_msg.py", msg_path]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, [msg_path]) + assert result.exit_code == 1 + assert "task ID" in result.output def test_accepts_conventional_on_feature_branch(self) -> None: msg_path = self._write_msg("feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"): - main(["validate_commit_msg.py", msg_path]) + runner = CliRunner() + result = runner.invoke(main, [msg_path]) + assert result.exit_code == 0 def test_accepts_valid_master_commit(self) -> None: msg_path = self._write_msg("GRM-19: feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="master"): - main(["validate_commit_msg.py", msg_path]) + runner = CliRunner() + result = runner.invoke(main, [msg_path]) + assert result.exit_code == 0 def test_rejects_master_without_task_id(self) -> None: msg_path = self._write_msg("feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="master"): - with pytest.raises(SystemExit) as exc: - main(["validate_commit_msg.py", msg_path]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, [msg_path]) + assert result.exit_code == 1 + assert "task ID" in result.output def test_rejects_master_with_non_conventional_after_task_id(self) -> None: msg_path = self._write_msg("GRM-19: random message") with patch("scripts.validate_commit_msg.get_branch", return_value="master"): - with pytest.raises(SystemExit) as exc: - main(["validate_commit_msg.py", msg_path]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, [msg_path]) + assert result.exit_code == 1 + assert "conventional" in result.output def test_rejects_non_conventional_on_feature_branch(self) -> None: msg_path = self._write_msg("random message") with patch("scripts.validate_commit_msg.get_branch", return_value="feature"): - with pytest.raises(SystemExit) as exc: - main(["validate_commit_msg.py", msg_path]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, [msg_path]) + assert result.exit_code == 1 + assert "conventional" in result.output def test_accepts_multiline_conventional(self) -> None: msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.") with patch("scripts.validate_commit_msg.get_branch", return_value="feature"): - main(["validate_commit_msg.py", msg_path]) + runner = CliRunner() + result = runner.invoke(main, [msg_path]) + assert result.exit_code == 0 def test_usage_message_without_args(self) -> None: - with pytest.raises(SystemExit) as exc: - main([]) - assert exc.value.code == 1 + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 2 def test_main_module_block() -> None: @@ -135,6 +145,6 @@ def test_main_module_block() -> None: source = source.replace('if __name__ == "__main__":\n main()\n', "") namespace = dict(vcm.__dict__) exec(compile(source, vcm.__file__, "exec"), namespace) - namespace["main"](["validate_commit_msg.py", msg_path]) + namespace["main"]([msg_path], standalone_mode=False) os.unlink(msg_path)