GRM-20: refactor: rework all scripts to use click and i18n

- Replace argparse/print/sys.exit with click commands and ClickException
- Translate all user-facing messages via _()
- Add friendly Oops! / Nice! prompts
- Wrap HTTP errors in all scripts with user-friendly translated messages
- Update all unit tests to use CliRunner and expect ClickException
- Add 100% branch coverage for new HTTP error handling branches
- Add missing translation keys to i18n.py
- Fix pre-commit hook to use venv Python for validate_commit_msg.py
This commit is contained in:
Emil Simeonov
2026-06-19 20:11:20 +02:00
parent b2acaefaf9
commit 790b5c3769
11 changed files with 550 additions and 220 deletions
+1 -1
View File
@@ -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
+54 -25
View File
@@ -4,13 +4,15 @@
Usage:
GITEA_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number>
"""
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: <type>: <description>")
print(f" Got: {pr_title}")
sys.exit(1)
raise click.ClickException(
_(
"Oops! PR title must follow conventional commit format.\n"
" Expected: <type>: <description>\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
+7 -9
View File
@@ -4,6 +4,7 @@
Usage:
GITEA_ADMIN_TOKEN=<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({
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:
+54 -29
View File
@@ -2,16 +2,17 @@
"""Update Vikunja task after a merge to master.
Usage:
VIKUNJA_TOKEN=<token> python3 scripts/post_merge.py <commit_msg>
VIKUNJA_TOKEN=<token> python3 scripts/post_merge.py <commit_msg> [--commit-sha <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"<p><strong>{task_id}</strong>: {conv_msg}</p>"
f"<p>Commit: <code>{commit_sha}</code></p>"
)
return f"<p><strong>{task_id}</strong>: {conv_msg}</p><p>Commit: <code>{commit_sha}</code></p>"
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 = 0
try:
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)
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.")
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
+56 -22
View File
@@ -4,13 +4,16 @@
Usage:
GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
"""
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
+44 -28
View File
@@ -6,9 +6,13 @@ Rules:
- On master branch: must follow '<task-id>: <conventional commit>' 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 <commit-msg-file>")
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: <conventional commit message>")
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: <conventional commit message>\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: <type>: <description>")
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: <type>: <description>\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: <type>: <description>")
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: <type>: <description>\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
+140
View File
@@ -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: <type>: <description>\n Got: {pr_title}": { # noqa: E501
"en": "Oops! PR title must follow conventional commit format.\n Expected: <type>: <description>\n Got: {pr_title}", # noqa: E501
"bg": "Опа! Заглавието на PR трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {pr_title}", # noqa: E501
"de": "Ups! PR-Titel muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {pr_title}", # noqa: E501
"ru": "Ой! Заголовок PR должен соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {pr_title}", # noqa: E501
"zh": "哎呀!PR 标题必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\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: <conventional commit message>\n Got: {subject}": { # noqa: E501
"en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}", # noqa: E501
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: <conventional commit message>\n Получено: {subject}", # noqa: E501
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: <conventional commit message>\n Erhalten: {subject}", # noqa: E501
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: <conventional commit message>\n Получено: {subject}", # noqa: E501
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: <conventional commit message>\n 实际: {subject}", # noqa: E501
},
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}": { # noqa: E501
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}", # noqa: E501
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: <type>: <description>\n Получено: {subject}", # noqa: E501
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: <type>: <description>\n Erhalten: {subject}", # noqa: E501
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: <type>: <description>\n Получено: {subject}", # noqa: E501
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: <type>: <description>\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: <type>: <description>\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: <type>: <description>\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 Очаква се: <type>: <description>\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: <type>: <description>\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 Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", # noqa: E501
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\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}",
+43 -19
View File
@@ -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
+55 -29
View File
@@ -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
+61 -33
View File
@@ -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
+30 -20
View File
@@ -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)