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:
+54
-25
@@ -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
|
||||
|
||||
@@ -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({
|
||||
"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:
|
||||
|
||||
+57
-32
@@ -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 = 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
|
||||
|
||||
+56
-22
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user