GRM-20: refactor(scripts): centralize constants, API clients, and HTTP status codes

- Add shared config.py with API URLs, regexes, timeouts, pagination
- Add GiteaClient and VikunjaClient in api_clients.py with pooled sessions
- Add APIError exception for unified HTTP error handling
- Refactor all scripts to use shared modules and http.HTTPStatus
- Rewrite unit tests to mock clients and use HTTPStatus constants
- Add tests for api_clients and config modules
- Achieve 100% test coverage
This commit is contained in:
Emil Simeonov
2026-06-19 21:00:21 +02:00
parent 790b5c3769
commit fefddda715
15 changed files with 751 additions and 682 deletions
+12 -32
View File
@@ -6,19 +6,14 @@ Usage:
""" """
import os import os
import re
import click import click
import requests
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import CONVENTIONAL_RE, GITEA_API_URL, TASK_ID_RE
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _ 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(
r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|BREAKING CHANGE)(\(.+\))?: .+"
)
def extract_task_id(branch: str) -> str: def extract_task_id(branch: str) -> str:
"""Extract GRM-N task identifier from branch name.""" """Extract GRM-N task identifier from branch name."""
@@ -39,18 +34,6 @@ def validate_pr_title(pr_title: str) -> None:
) )
def merge_pr(token: str, repo: str, pr_number: str, merge_title: str) -> None:
"""Call Gitea API to squash-merge the PR."""
url = f"{GITEA_API}/repos/{repo}/pulls/{pr_number}/merge"
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
payload = {"Do": "squash", "MergeTitleField": merge_title}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
@click.command() @click.command()
@click.argument("branch") @click.argument("branch")
@click.argument("pr_title") @click.argument("pr_title")
@@ -73,21 +56,18 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
validate_pr_title(pr_title) validate_pr_title(pr_title)
merge_title = f"{task_id}: {pr_title}" merge_title = f"{task_id}: {pr_title}"
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
try: try:
merge_pr(token, repo, pr_number, merge_title) client.merge_pr(pr_number, merge_title)
except requests.HTTPError as e: except APIError 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( raise click.ClickException(
_( _(
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.", "Merge failed with HTTP {status}: {message}\n"
status=status, "Please check the PR is ready and you have merge rights.",
message=message, status=e.status,
message=e.message,
) )
) from None ) from None
+26 -105
View File
@@ -7,119 +7,36 @@ Usage:
import http import http
import os import os
from typing import cast
import click import click
import requests
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
GITEA_API_URL,
LABEL_CONFIG,
REPO_NAME,
REPO_OWNER,
)
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _ from gitea_runner_manager.i18n import _
GITEA_API = "https://git.oblachno.oblachno.fyi/api/v1"
OWNER = "oblachno-oss"
REPO = "grm"
BRANCH_PROTECTION_CONFIG = { def _handle_http_error(e: APIError) -> None:
"branch_name": "master",
"enable_push": False,
"enable_status_check": True,
"status_check_contexts": ["lint", "unit-tests", "molecule-tests"],
"required_approvals": 1,
"dismiss_stale_approvals": True,
"block_on_outdated_branch": True,
"block_on_rejected_reviews": True,
"block_on_official_review_requests": True,
}
LABEL_CONFIG = {
"name": "ready-to-merge",
"color": "2ecc71",
"description": "Auto-merge PR when all CI checks pass",
}
class GiteaRepoConfig:
"""Configure a Gitea repository: branch protection and labels."""
def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None:
self._base_url = base_url.rstrip("/")
self._owner = owner
self._repo = repo
self._session = requests.Session()
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}"
def list_branch_protections(self) -> list[dict]:
r = self._session.get(self._url("/branch_protections"))
r.raise_for_status()
return r.json()
def create_branch_protection(self, config: dict) -> dict:
r = self._session.post(self._url("/branch_protections"), json=config)
r.raise_for_status()
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.raise_for_status()
return r.json()
def ensure_branch_protection(self, branch: str, config: dict) -> dict:
"""Idempotent: create or update branch protection for the given branch."""
existing = self.list_branch_protections()
for p in existing:
if p.get("branch_name") == branch:
protection_id = p["id"]
update_config = {k: v for k, v in config.items() if k != "branch_name"}
return self.update_branch_protection(protection_id, update_config)
return self.create_branch_protection(config)
def list_labels(self) -> list[dict]:
r = self._session.get(self._url("/labels"))
r.raise_for_status()
return r.json()
def create_label(self, name: str, color: str, description: str = "") -> dict:
r = self._session.post(
self._url("/labels"),
json={"name": name, "color": color, "description": description},
)
r.raise_for_status()
return r.json()
def ensure_label(self, name: str, color: str, description: str = "") -> dict | None:
"""Idempotent: create label if it doesn't already exist."""
labels = self.list_labels()
for label in labels:
if label["name"] == name:
return None # already exists
return self.create_label(name, color, description)
def _handle_http_error(e: requests.HTTPError) -> None:
"""Raise a user-friendly Click exception for HTTP errors.""" """Raise a user-friendly Click exception for HTTP errors."""
response = e.response if e.status == http.HTTPStatus.FORBIDDEN:
status = response.status_code if response else 0
if status == http.HTTPStatus.FORBIDDEN:
raise click.ClickException( raise click.ClickException(
_( _(
"HTTP {status} Forbidden — your token lacks admin rights.\n" "HTTP {status} Forbidden — your token lacks admin rights.\n"
"Make sure the token belongs to a repo owner or organisation admin.\n" "Make sure the token belongs to a repo owner or organisation admin.\n"
"Alternatively, configure branch protection manually in Settings → Branches.", "Alternatively, configure branch protection manually in Settings → Branches.",
status=status, status=e.status,
) )
) )
try: raise click.ClickException(
body = response.json() if response else {} _("HTTP error: {status} — {message}", status=e.status, message=e.message)
message = body.get("message", str(e)) )
except Exception:
message = str(e)
raise click.ClickException(_("HTTP error: {status} — {message}", status=status, message=message))
def main() -> None: def main() -> None:
@@ -127,11 +44,11 @@ def main() -> None:
if not token: if not token:
raise click.ClickException(_("ERROR: GITEA_ADMIN_TOKEN is not set.")) raise click.ClickException(_("ERROR: GITEA_ADMIN_TOKEN is not set."))
cfg = GiteaRepoConfig(GITEA_API, token, OWNER, REPO) client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
try: try:
click.echo(_("Configuring branch protection for {branch}...", branch="master")) click.echo(_("Configuring branch protection for {branch}...", branch="master"))
cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG) client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
click.echo(_(" - Direct pushes: BLOCKED (require PR)")) click.echo(_(" - Direct pushes: BLOCKED (require PR)"))
click.echo( click.echo(
_( _(
@@ -142,13 +59,17 @@ def main() -> None:
click.echo(_(" - Dismiss stale approvals: yes")) click.echo(_(" - Dismiss stale approvals: yes"))
click.echo(_(" - Block outdated branches: yes")) click.echo(_(" - Block outdated branches: yes"))
click.echo(_(" - Block rejected reviews: yes")) click.echo(_(" - Block rejected reviews: yes"))
checks = ", ".join(BRANCH_PROTECTION_CONFIG["status_check_contexts"]) checks = ", ".join(cast(list[str], BRANCH_PROTECTION_CONFIG["status_check_contexts"]))
click.echo(_(" - Required status checks: {checks}", checks=checks)) click.echo(_(" - Required status checks: {checks}", checks=checks))
click.echo("") click.echo("")
label_name = LABEL_CONFIG["name"] label_name = cast(str, LABEL_CONFIG["name"])
click.echo(_("Creating {label} label...", label=label_name)) click.echo(_("Creating {label} label...", label=label_name))
result = cfg.ensure_label(**LABEL_CONFIG) result = client.ensure_label(
name=cast(str, LABEL_CONFIG["name"]),
color=cast(str, LABEL_CONFIG["color"]),
description=cast(str, LABEL_CONFIG["description"]),
)
if result is None: if result is None:
click.echo(_(" Label '{label}' already exists.", label=label_name)) click.echo(_(" Label '{label}' already exists.", label=label_name))
else: else:
@@ -156,7 +77,7 @@ def main() -> None:
click.echo("") click.echo("")
click.echo(_("Repository configuration complete.")) click.echo(_("Repository configuration complete."))
except requests.HTTPError as e: except APIError as e:
_handle_http_error(e) _handle_http_error(e)
+22 -61
View File
@@ -9,14 +9,12 @@ import os
import re import re
import click import click
import requests
from gitea_runner_manager.api_clients import VikunjaClient
from gitea_runner_manager.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _ 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
def extract_task_id(commit_msg: str) -> str: def extract_task_id(commit_msg: str) -> str:
"""Extract GRM-N task identifier from the first line of commit message.""" """Extract GRM-N task identifier from the first line of commit message."""
@@ -31,68 +29,24 @@ def extract_conventional_msg(commit_msg: str) -> str:
return re.sub(r"^GRM-\d+:\s*", "", first_line) return re.sub(r"^GRM-\d+:\s*", "", first_line)
def _handle_http_error(e: requests.HTTPError) -> None: def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
"""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.""" """Resolve GRM-N identifier to Vikunja numeric task ID."""
url = f"{VIKUNJA_API}/tasks/all" tasks = client.list_tasks(per_page=DEFAULT_PER_PAGE)
headers = {"Authorization": f"Bearer {token}"} matches = [
params = {"per_page": 50} t for t in tasks
response = requests.get(url, headers=headers, params=params, timeout=30) if t.get("project_id") == VIKUNJA_PROJECT_ID and t.get("identifier") == task_id
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]
if not matches: if not matches:
raise click.ClickException( raise click.ClickException(
_( _(
"Could not find Vikunja task for {task_id} in project {project_id}.", "Could not find Vikunja task for {task_id} in project {project_id}.",
task_id=task_id, task_id=task_id,
project_id=PROJECT_ID, project_id=VIKUNJA_PROJECT_ID,
) )
) )
return int(matches[0]["id"]) return int(matches[0]["id"])
def post_comment(token: str, task_id: int, html: str) -> None:
"""Post an HTML comment to a Vikunja task."""
url = f"{VIKUNJA_API}/tasks/{task_id}/comments"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
payload = {"comment": html}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
def mark_task_done(token: str, task_id: int) -> None:
"""Mark a Vikunja task as done."""
url = f"{VIKUNJA_API}/tasks/{task_id}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
payload = {"done": True}
response = requests.put(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str: def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
"""Build HTML comment body for Vikunja.""" """Build HTML comment body for Vikunja."""
return f"<p><strong>{task_id}</strong>: {conv_msg}</p><p>Commit: <code>{commit_sha}</code></p>" return f"<p><strong>{task_id}</strong>: {conv_msg}</p><p>Commit: <code>{commit_sha}</code></p>"
@@ -111,17 +65,24 @@ def main(commit_msg: str, commit_sha: str) -> None:
click.echo(_("No task ID in commit message, skipping Vikunja update. All good — nothing to do here!")) click.echo(_("No task ID in commit message, skipping Vikunja update. All good — nothing to do here!"))
return return
client = VikunjaClient(VIKUNJA_API_URL, token)
vikunja_task_id = 0 vikunja_task_id = 0
try: try:
vikunja_task_id = resolve_task_id(token, task_id) vikunja_task_id = resolve_task_id(client, task_id)
conv_msg = extract_conventional_msg(commit_msg) conv_msg = extract_conventional_msg(commit_msg)
sha = commit_sha or "unknown" sha = commit_sha or "unknown"
html = build_comment(task_id, conv_msg, sha) html = build_comment(task_id, conv_msg, sha)
post_comment(token, vikunja_task_id, html) client.post_comment(vikunja_task_id, html)
mark_task_done(token, vikunja_task_id) client.update_task(vikunja_task_id, done=True)
except requests.HTTPError as e: except APIError as e:
_handle_http_error(e) raise click.ClickException(
_(
"Vikunja API error: HTTP {status} — {message}",
status=e.status,
message=e.message,
)
) from None
click.echo( click.echo(
_( _(
+12 -32
View File
@@ -10,12 +10,12 @@ import subprocess
import sys import sys
import click import click
import requests
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _ from gitea_runner_manager.i18n import _
GITEA_API = "https://git.oblachno.oblachno.fyi/api/v1"
def build_package() -> None: def build_package() -> None:
"""Build the Python package using python -m build.""" """Build the Python package using python -m build."""
@@ -62,24 +62,6 @@ def publish_to_pypi(token: str) -> None:
click.echo(_("Published to PyPI.")) click.echo(_("Published to PyPI."))
def create_gitea_release(token: str, repo: str, tag: str) -> None:
"""Create a Gitea release for the given tag."""
url = f"{GITEA_API}/repos/{repo}/releases"
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
payload = {
"tag_name": tag,
"name": tag,
"body": f"Release {tag}\n\nSee CHANGELOG.md for details.",
"draft": False,
"prerelease": False,
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
@click.command() @click.command()
@click.argument("tag") @click.argument("tag")
@click.argument("repo") @click.argument("repo")
@@ -97,21 +79,19 @@ def main(tag: str, repo: str) -> None:
else: else:
click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.")) click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, gitea_token, owner, repo_name)
try: try:
create_gitea_release(gitea_token, repo, tag) client.create_release(
except requests.HTTPError as e: tag=tag,
response = e.response body=f"Release {tag}\n\nSee CHANGELOG.md for details.",
status = response.status_code if response else 0 )
try: except APIError as e:
body = response.json() if response else {}
message = body.get("message", str(e))
except Exception:
message = str(e)
raise click.ClickException( raise click.ClickException(
_( _(
"Release creation failed with HTTP {status}: {message}", "Release creation failed with HTTP {status}: {message}",
status=status, status=e.status,
message=message, message=e.message,
) )
) from None ) from None
+5 -7
View File
@@ -12,12 +12,10 @@ import subprocess
import click import click
from gitea_runner_manager.config import CONVENTIONAL_RE
from gitea_runner_manager.i18n import _ from gitea_runner_manager.i18n import _
CONVENTIONAL_RE = re.compile( MASTER_TASK_ID_RE = re.compile(r"^GRM-\d+:")
r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|BREAKING CHANGE)(\(.+\))?: .+"
)
TASK_ID_RE = re.compile(r"^GRM-\d+:")
def first_line(text: str) -> str: def first_line(text: str) -> str:
@@ -47,7 +45,7 @@ def main(commit_msg_file: str) -> None:
subject = first_line(msg) subject = first_line(msg)
if branch == "master": if branch == "master":
if not TASK_ID_RE.match(subject): if not MASTER_TASK_ID_RE.match(subject):
raise click.ClickException( raise click.ClickException(
_( _(
"Oops! Master branch commits must start with a task ID.\n" "Oops! Master branch commits must start with a task ID.\n"
@@ -56,7 +54,7 @@ def main(commit_msg_file: str) -> None:
subject=subject, subject=subject,
) )
) )
remainder = TASK_ID_RE.sub("", subject).strip() remainder = MASTER_TASK_ID_RE.sub("", subject).strip()
if not CONVENTIONAL_RE.match(remainder): if not CONVENTIONAL_RE.match(remainder):
raise click.ClickException( raise click.ClickException(
_( _(
@@ -68,7 +66,7 @@ def main(commit_msg_file: str) -> None:
) )
return return
if TASK_ID_RE.match(subject): if MASTER_TASK_ID_RE.match(subject):
raise click.ClickException( raise click.ClickException(
_( _(
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n" "Oops! Do not include task ID (GRM-N) in feature branch commits.\n"
+153
View File
@@ -0,0 +1,153 @@
"""Reusable HTTP API clients for Gitea and Vikunja."""
from __future__ import annotations
import logging
from typing import Any
import requests
from .config import DEFAULT_TIMEOUT
from .exceptions import APIError
logger = logging.getLogger("grm")
def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
"""Extract status code and message from an HTTPError response."""
response = e.response
status = response.status_code if response else 0
try:
body: dict[str, Any] = response.json() if response else {}
message: str = body.get("message", str(e))
except Exception:
message = str(e)
return status, message
class GiteaClient:
"""Low-level Gitea REST API client with connection pooling."""
def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None:
self._base_url = base_url.rstrip("/")
self._owner = owner
self._repo = repo
self._session = requests.Session()
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}"
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = self._url(path)
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
except requests.HTTPError as e:
status, message = _parse_error(e)
raise APIError(status, message) from e
return response
# -- branch protection --
def list_branch_protections(self) -> list[dict[str, Any]]:
r = self._request("GET", "/branch_protections")
return r.json()
def create_branch_protection(self, config: dict[str, Any]) -> dict[str, Any]:
r = self._request("POST", "/branch_protections", json=config)
return r.json()
def update_branch_protection(self, protection_id: int, config: dict[str, Any]) -> dict[str, Any]:
r = self._request("PATCH", f"/branch_protections/{protection_id}", json=config)
return r.json()
def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
"""Idempotent: create or update branch protection for the given branch."""
existing = self.list_branch_protections()
for p in existing:
if p.get("branch_name") == branch:
protection_id = p["id"]
update_config = {k: v for k, v in config.items() if k != "branch_name"}
return self.update_branch_protection(protection_id, update_config)
return self.create_branch_protection(config)
# -- labels --
def list_labels(self) -> list[dict[str, Any]]:
r = self._request("GET", "/labels")
return r.json()
def create_label(self, name: str, color: str, description: str = "") -> dict[str, Any]:
r = self._request(
"POST",
"/labels",
json={"name": name, "color": color, "description": description},
)
return r.json()
def ensure_label(self, name: str, color: str, description: str = "") -> dict[str, Any] | None:
"""Idempotent: create label if it doesn't already exist."""
labels = self.list_labels()
for label in labels:
if label["name"] == name:
return None
return self.create_label(name, color, description)
# -- pulls / releases --
def merge_pr(self, pr_number: str | int, merge_title: str) -> None:
payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
def create_release(
self,
tag: str,
name: str = "",
body: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
payload = {
"tag_name": tag,
"name": name or tag,
"body": body,
"draft": draft,
"prerelease": prerelease,
}
r = self._request("POST", "/releases", json=payload)
return r.json()
class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling."""
def __init__(self, base_url: str, token: str) -> None:
self._base_url = base_url.rstrip("/")
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Bearer {token}"})
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self._base_url}{path}"
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
except requests.HTTPError as e:
status, message = _parse_error(e)
raise APIError(status, message) from e
return response
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
r = self._request("GET", "/tasks", params=params)
return r.json()
def post_comment(self, task_id: int, comment: str) -> None:
self._request("POST", f"/tasks/{task_id}/comments", json={"comment": comment})
def update_task(self, task_id: int, **fields: Any) -> None:
self._request("POST", f"/tasks/{task_id}", json=fields)
+39
View File
@@ -0,0 +1,39 @@
"""Shared configuration constants for GRM scripts and API clients."""
from __future__ import annotations
import re
GITEA_API_URL = "https://git.oblachno.oblachno.fyi/api/v1"
VIKUNJA_API_URL = "https://work.oblachno.oblachno.fyi/api/v1"
REPO_OWNER = "oblachno-oss"
REPO_NAME = "grm"
VIKUNJA_PROJECT_ID = 6
TASK_ID_RE = re.compile(r"GRM-\d+")
CONVENTIONAL_RE = re.compile(
r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|BREAKING CHANGE)(\(.+\))?: .+"
)
DEFAULT_TIMEOUT = 30
DEFAULT_PER_PAGE = 50
BRANCH_PROTECTION_CONFIG: dict[str, object] = {
"branch_name": "master",
"enable_push": False,
"enable_status_check": True,
"status_check_contexts": ["lint", "unit-tests", "molecule-tests"],
"required_approvals": 1,
"dismiss_stale_approvals": True,
"block_on_outdated_branch": True,
"block_on_rejected_reviews": True,
"block_on_official_review_requests": True,
}
LABEL_CONFIG: dict[str, object] = {
"name": "ready-to-merge",
"color": "2ecc71",
"description": "Auto-merge PR when all CI checks pass",
}
+9
View File
@@ -11,3 +11,12 @@ class AnsibleError(GRMError):
"""Raised when an Ansible command fails.""" """Raised when an Ansible command fails."""
pass pass
class APIError(GRMError):
"""Raised when a REST API call returns an HTTP error."""
def __init__(self, status: int, message: str) -> None:
self.status = status
self.message = message
super().__init__(f"HTTP {status}: {message}")
+264
View File
@@ -0,0 +1,264 @@
"""Unit tests for api_clients module."""
import http
from unittest.mock import MagicMock
import pytest
import requests
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _parse_error
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
VIKUNJA_PROJECT_ID,
)
from gitea_runner_manager.exceptions import APIError
def _mock_response(json_data: object | None = None, raise_on_status: bool = False) -> MagicMock:
mock = MagicMock()
if json_data is not None:
mock.json.return_value = json_data
if raise_on_status:
mock.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.INTERNAL_SERVER_ERROR))
return mock
class TestParseError:
def test_json_parse_fallback(self) -> None:
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
mock_response.json = MagicMock(side_effect=ValueError("not json"))
err = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY), response=mock_response)
status, message = _parse_error(err)
assert status == http.HTTPStatus.BAD_GATEWAY
assert str(http.HTTPStatus.BAD_GATEWAY) in message
def test_no_response(self) -> None:
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
status, message = _parse_error(err)
assert status == 0
assert "connection failed" in message
class TestGiteaClient:
def test_init_sets_headers(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._base_url == "https://git.example.com"
assert client._session.headers["Authorization"] == "token tok"
assert client._session.headers["Content-Type"] == "application/json"
def test_url_constructs_path(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_url_strips_trailing_slash(self) -> None:
client = GiteaClient("https://git.example.com/", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_list_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug", "color": "ff0000"}]))
result = client.list_labels()
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_list_labels_raises_api_error(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
with pytest.raises(APIError):
client.list_labels()
def test_http_error_json_parse_fallback(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
# Make json() itself raise so the except block in _parse_error is hit
mock_response.json = MagicMock(side_effect=ValueError("not json"))
mock_response.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY))
client._session.request = MagicMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
client.list_labels()
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc_info.value)
def test_create_label(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"name": "ready-to-merge", "color": "2ecc71"}))
result = client.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
assert result["name"] == "ready-to-merge"
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
)
def test_ensure_label_creates_when_not_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[])
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_ensure_label_returns_none_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
client.create_label = MagicMock()
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is None
client.create_label.assert_not_called()
def test_list_branch_protections(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response(
[
{"id": 1, "branch_name": "master"},
{"id": 2, "branch_name": "develop"},
]
)
)
result = client.list_branch_protections()
assert len(result) == 2
assert result[0]["branch_name"] == "master"
def test_create_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 3, "branch_name": "master"}))
result = client.create_branch_protection(BRANCH_PROTECTION_CONFIG)
assert result["id"] == 3
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/branch_protections",
timeout=DEFAULT_TIMEOUT,
json=BRANCH_PROTECTION_CONFIG,
)
def test_update_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "required_approvals": 2}))
update = {"required_approvals": 2}
result = client.update_branch_protection(1, update)
assert result["required_approvals"] == 2
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo/branch_protections/1",
timeout=DEFAULT_TIMEOUT,
json=update,
)
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[])
client.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["id"] == 1
client.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG)
def test_ensure_branch_protection_updates_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[{"id": 5, "branch_name": "master"}])
client.update_branch_protection = MagicMock(return_value={"id": 5, "required_approvals": 1})
result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["id"] == 5
expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"}
client.update_branch_protection.assert_called_once_with(5, expected_update)
def test_merge_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
client.merge_pr(1, "fix: bug")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/1/merge",
timeout=DEFAULT_TIMEOUT,
json={"Do": "squash", "MergeTitleField": "fix: bug"},
)
def test_create_release(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1}))
client.create_release("v1.0.0")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/releases",
timeout=DEFAULT_TIMEOUT,
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
)
class TestVikunjaClient:
def test_init_sets_headers(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
assert client._base_url == "https://work.example.com"
assert client._session.headers["Authorization"] == "Bearer tok"
def test_list_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"id": 1, "identifier": "GRM-19", "project_id": VIKUNJA_PROJECT_ID}])
)
result = client.list_tasks(per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks",
timeout=DEFAULT_TIMEOUT,
params={"per_page": DEFAULT_PER_PAGE},
)
def test_post_comment(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.post_comment(42, "<p>hi</p>")
client._session.request.assert_called_once_with(
"POST",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
json={"comment": "<p>hi</p>"},
)
def test_update_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.update_task(42, done=True)
client._session.request.assert_called_once_with(
"POST",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
json={"done": True},
)
def test_http_error_raises_api_error(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
with pytest.raises(APIError):
client.list_tasks()
def test_http_error_no_response(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
client._session.request = MagicMock(side_effect=err)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert "connection failed" in str(exc_info.value)
+23 -48
View File
@@ -1,21 +1,15 @@
"""Unit tests for scripts/auto_merge.py.""" """Unit tests for scripts/auto_merge.py."""
import http
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click import click
import pytest import pytest
import requests
from click.testing import CliRunner from click.testing import CliRunner
from scripts.auto_merge import ( from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
CONVENTIONAL_RE, from gitea_runner_manager.exceptions import APIError
GITEA_API, from scripts.auto_merge import extract_task_id, main, validate_pr_title
TASK_ID_RE,
extract_task_id,
main,
merge_pr,
validate_pr_title,
)
class TestRegexes: class TestRegexes:
@@ -59,32 +53,12 @@ class TestValidatePrTitle:
assert "conventional" in str(exc.value) assert "conventional" in str(exc.value)
class TestMergePr:
@patch("scripts.auto_merge.requests.post")
def test_successful_merge(self, mock_post: MagicMock) -> None:
mock_response = MagicMock()
mock_post.return_value = mock_response
merge_pr("tok", "owner/repo", "7", "GRM-19: fix: bug")
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert kwargs["headers"]["Authorization"] == "token tok"
assert kwargs["json"]["Do"] == "squash"
assert kwargs["json"]["MergeTitleField"] == "GRM-19: fix: bug"
assert GITEA_API in args[0]
@patch("scripts.auto_merge.requests.post")
def test_merge_raises_on_http_error(self, mock_post: MagicMock) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
mock_post.return_value = mock_response
with pytest.raises(requests.HTTPError):
merge_pr("tok", "owner/repo", "7", "title")
class TestMain: class TestMain:
@patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) @patch.dict("os.environ", {"GITEA_TOKEN": "tok"})
@patch("scripts.auto_merge.merge_pr") @patch("scripts.auto_merge.GiteaClient")
def test_successful_flow(self, mock_merge: MagicMock) -> None: def test_successful_flow(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
main, main,
@@ -92,7 +66,8 @@ class TestMain:
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "squash-merged" in result.output assert "squash-merged" in result.output
mock_merge.assert_called_once_with("tok", "owner/repo", "7", "GRM-19: fix: resolve timeout") mock_client_cls.assert_called_once()
mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout")
@patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None: def test_missing_token_exits(self) -> None:
@@ -116,23 +91,23 @@ class TestMain:
assert "conventional" in result.output assert "conventional" in result.output
@patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) @patch.dict("os.environ", {"GITEA_TOKEN": "tok"})
@patch("scripts.auto_merge.merge_pr") @patch("scripts.auto_merge.GiteaClient")
def test_merge_pr_failure_raises_click(self, mock_merge: MagicMock) -> None: def test_merge_pr_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
mock_merge.side_effect = requests.HTTPError("500") mock_client = MagicMock()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "repo", "1"]) result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "HTTP" in result.output assert "HTTP" in result.output
@patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) @patch.dict("os.environ", {"GITEA_TOKEN": "tok"})
@patch("scripts.auto_merge.merge_pr") @patch("scripts.auto_merge.GiteaClient")
def test_merge_pr_json_parse_failure(self, mock_merge: MagicMock) -> None: def test_merge_pr_json_parse_failure(self, mock_client_cls: MagicMock) -> None:
mock_response = MagicMock() mock_client = MagicMock()
mock_response.status_code = 502 mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
mock_response.json.side_effect = ValueError("not json") mock_client_cls.return_value = mock_client
err = requests.HTTPError("502", response=mock_response)
mock_merge.side_effect = err
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "repo", "1"]) result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "502" in result.output assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
+54
View File
@@ -0,0 +1,54 @@
"""Unit tests for config module constants."""
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
GITEA_API_URL,
LABEL_CONFIG,
REPO_NAME,
REPO_OWNER,
TASK_ID_RE,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
class TestConfigConstants:
def test_api_urls(self) -> None:
assert GITEA_API_URL == "https://git.oblachno.oblachno.fyi/api/v1"
assert VIKUNJA_API_URL == "https://work.oblachno.oblachno.fyi/api/v1"
def test_project_ids(self) -> None:
assert VIKUNJA_PROJECT_ID == 6
def test_timeouts(self) -> None:
assert DEFAULT_TIMEOUT == 30
assert DEFAULT_PER_PAGE == 50
def test_owner_and_repo(self) -> None:
assert REPO_OWNER == "oblachno-oss"
assert REPO_NAME == "grm"
def test_task_id_re(self) -> None:
assert TASK_ID_RE.search("GRM-1")
assert TASK_ID_RE.search("GRM-123")
assert not TASK_ID_RE.search("GRM-")
assert not TASK_ID_RE.search("other text")
def test_conventional_re(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
def test_branch_protection_config(self) -> None:
assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master"
assert BRANCH_PROTECTION_CONFIG["enable_push"] is False
assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 1
assert BRANCH_PROTECTION_CONFIG["status_check_contexts"] == ["lint", "unit-tests", "molecule-tests"]
def test_label_config(self) -> None:
assert LABEL_CONFIG["name"] == "ready-to-merge"
assert LABEL_CONFIG["color"] == "2ecc71"
+40 -236
View File
@@ -5,202 +5,35 @@ from unittest.mock import MagicMock, patch
import click import click
import pytest import pytest
import requests
from gitea_runner_manager.config import BRANCH_PROTECTION_CONFIG
from gitea_runner_manager.exceptions import APIError
from scripts.configure_repo import ( from scripts.configure_repo import (
BRANCH_PROTECTION_CONFIG,
LABEL_CONFIG,
GiteaRepoConfig,
_handle_http_error, _handle_http_error,
main, main,
) )
class TestGiteaRepoConfig: class TestHandleHttpError:
def test_init_sets_headers(self) -> None: def test_handle_http_error_403(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") err = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
assert cfg._base_url == "https://git.example.com" with pytest.raises(click.ClickException) as exc:
assert cfg._owner == "owner" _handle_http_error(err)
assert cfg._repo == "repo" msg = str(exc.value)
assert cfg._session.headers["Authorization"] == "token tok" assert "admin rights" in msg
assert cfg._session.headers["Content-Type"] == "application/json" assert "Settings → Branches" in msg
def test_url_constructs_path(self) -> None: def test_handle_http_error_other(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") err = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "Internal Server Error")
assert cfg._url("/branch_protections") == ("https://git.example.com/repos/owner/repo/branch_protections") with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
assert str(http.HTTPStatus.INTERNAL_SERVER_ERROR) in str(exc.value)
def test_url_strips_trailing_slash(self) -> None: def test_handle_http_error_json_parse_fails(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com/", "tok", "owner", "repo") err = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
assert cfg._url("/labels") == ("https://git.example.com/repos/owner/repo/labels") with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
def test_list_branch_protections(self) -> None: assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc.value)
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.json.return_value = [
{"id": 1, "branch_name": "master"},
{"id": 2, "branch_name": "develop"},
]
cfg._session.get = MagicMock(return_value=mock_response)
result = cfg.list_branch_protections()
assert len(result) == 2
assert result[0]["branch_name"] == "master"
cfg._session.get.assert_called_once_with("https://git.example.com/repos/owner/repo/branch_protections")
def test_list_branch_protections_raises_on_error(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
cfg._session.get = MagicMock(return_value=mock_response)
with pytest.raises(requests.HTTPError):
cfg.list_branch_protections()
def test_create_branch_protection(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.json.return_value = {"id": 3, "branch_name": "master"}
cfg._session.post = MagicMock(return_value=mock_response)
result = cfg.create_branch_protection(BRANCH_PROTECTION_CONFIG)
assert result["id"] == 3
cfg._session.post.assert_called_once_with(
"https://git.example.com/repos/owner/repo/branch_protections",
json=BRANCH_PROTECTION_CONFIG,
)
def test_create_branch_protection_raises_on_error(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("403")
cfg._session.post = MagicMock(return_value=mock_response)
with pytest.raises(requests.HTTPError):
cfg.create_branch_protection(BRANCH_PROTECTION_CONFIG)
def test_update_branch_protection(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.json.return_value = {"id": 1, "required_approvals": 2}
cfg._session.patch = MagicMock(return_value=mock_response)
update = {"required_approvals": 2}
result = cfg.update_branch_protection(1, update)
assert result["required_approvals"] == 2
cfg._session.patch.assert_called_once_with(
"https://git.example.com/repos/owner/repo/branch_protections/1",
json=update,
)
def test_update_branch_protection_raises_on_error(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("404")
cfg._session.patch = MagicMock(return_value=mock_response)
with pytest.raises(requests.HTTPError):
cfg.update_branch_protection(999, {})
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
cfg.list_branch_protections = MagicMock(return_value=[])
cfg.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["id"] == 1
cfg.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG)
def test_ensure_branch_protection_updates_when_exists(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
cfg.list_branch_protections = MagicMock(return_value=[{"id": 5, "branch_name": "master"}])
cfg.update_branch_protection = MagicMock(return_value={"id": 5, "required_approvals": 1})
result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["id"] == 5
# update config should exclude branch_name
expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"}
cfg.update_branch_protection.assert_called_once_with(5, expected_update)
def test_ensure_branch_protection_creates_when_other_branches_exist(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
cfg.list_branch_protections = MagicMock(return_value=[{"id": 1, "branch_name": "develop"}])
cfg.create_branch_protection = MagicMock(return_value={"id": 2, "branch_name": "master"})
result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["id"] == 2
cfg.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG)
def test_list_labels(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.json.return_value = [
{"name": "bug", "color": "ff0000"},
{"name": "enhancement", "color": "00ff00"},
]
cfg._session.get = MagicMock(return_value=mock_response)
result = cfg.list_labels()
assert len(result) == 2
cfg._session.get.assert_called_once_with("https://git.example.com/repos/owner/repo/labels")
def test_list_labels_raises_on_error(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
cfg._session.get = MagicMock(return_value=mock_response)
with pytest.raises(requests.HTTPError):
cfg.list_labels()
def test_create_label(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.json.return_value = {"name": "ready-to-merge", "color": "2ecc71"}
cfg._session.post = MagicMock(return_value=mock_response)
result = cfg.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
assert result["name"] == "ready-to-merge"
cfg._session.post.assert_called_once_with(
"https://git.example.com/repos/owner/repo/labels",
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
)
def test_create_label_raises_on_error(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("422")
cfg._session.post = MagicMock(return_value=mock_response)
with pytest.raises(requests.HTTPError):
cfg.create_label("dup", "ffffff")
def test_ensure_label_creates_when_not_exists(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
cfg.list_labels = MagicMock(return_value=[])
cfg.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
cfg.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_ensure_label_returns_none_when_exists(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
cfg.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
cfg.create_label = MagicMock()
result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is None
cfg.create_label.assert_not_called()
def test_ensure_label_creates_when_other_labels_exist(self) -> None:
cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo")
cfg.list_labels = MagicMock(return_value=[{"name": "bug", "color": "ff0000"}])
cfg.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
cfg.create_label.assert_called_once()
class TestMain: class TestMain:
@@ -212,73 +45,44 @@ class TestMain:
def test_main_success(self) -> None: def test_main_success(self) -> None:
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class: with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
mock_cfg = MagicMock() mock_client = MagicMock()
mock_cfg_class.return_value = mock_cfg mock_client_cls.return_value = mock_client
main() main()
mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG) mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
mock_cfg.ensure_label.assert_called_once_with(**LABEL_CONFIG) mock_client.ensure_label.assert_called_once()
def test_main_label_already_exists(self) -> None: def test_main_label_already_exists(self) -> None:
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class: with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
mock_cfg = MagicMock() mock_client = MagicMock()
mock_cfg.ensure_label.return_value = None mock_client.ensure_label.return_value = None
mock_cfg_class.return_value = mock_cfg mock_client_cls.return_value = mock_client
main() main()
mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG) mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
mock_cfg.ensure_label.assert_called_once_with(**LABEL_CONFIG) mock_client.ensure_label.assert_called_once()
def test_main_api_error(self) -> None: def test_main_api_error(self) -> None:
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class: with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
mock_cfg = MagicMock() mock_client = MagicMock()
mock_cfg.ensure_branch_protection.side_effect = requests.HTTPError("403") mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
mock_cfg_class.return_value = mock_cfg mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException) as exc: with pytest.raises(click.ClickException) as exc:
main() main()
assert "HTTP" in str(exc.value) assert "HTTP" in str(exc.value)
def test_handle_http_error_403(self) -> None:
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.FORBIDDEN
mock_response.json.return_value = {"message": "Forbidden"}
err = requests.HTTPError("403", response=mock_response)
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
msg = str(exc.value)
assert "admin rights" in msg
assert "Settings → Branches" in msg
def test_handle_http_error_other(self) -> None:
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.INTERNAL_SERVER_ERROR
mock_response.json.return_value = {"message": "Internal Server Error"}
err = requests.HTTPError("500", response=mock_response)
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
assert "500" in str(exc.value)
def test_handle_http_error_json_parse_fails(self) -> None:
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
mock_response.json.side_effect = ValueError("not json")
err = requests.HTTPError("502", response=mock_response)
with pytest.raises(click.ClickException) as exc:
_handle_http_error(err)
assert "502" in str(exc.value)
def test_main_module_block() -> None: def test_main_module_block() -> None:
with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True):
with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class: with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
mock_cfg = MagicMock() mock_client = MagicMock()
mock_cfg_class.return_value = mock_cfg mock_client_cls.return_value = mock_client
import scripts.configure_repo as cr import scripts.configure_repo as cr
with open(cr.__file__) as f: with open(cr.__file__) as f:
@@ -287,6 +91,6 @@ def test_main_module_block() -> None:
source = source.replace('if __name__ == "__main__":\n main()\n', "") source = source.replace('if __name__ == "__main__":\n main()\n', "")
namespace = dict(cr.__dict__) namespace = dict(cr.__dict__)
exec(compile(source, cr.__file__, "exec"), namespace) exec(compile(source, cr.__file__, "exec"), namespace)
namespace["GiteaRepoConfig"] = mock_cfg_class namespace["GiteaClient"] = mock_client_cls
namespace["main"]() namespace["main"]()
mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG) mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
+64 -115
View File
@@ -1,21 +1,19 @@
"""Unit tests for scripts/post_merge.py.""" """Unit tests for scripts/post_merge.py."""
import http
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click import click
import pytest import pytest
import requests
from click.testing import CliRunner from click.testing import CliRunner
from gitea_runner_manager.config import VIKUNJA_PROJECT_ID
from gitea_runner_manager.exceptions import APIError
from scripts.post_merge import ( from scripts.post_merge import (
PROJECT_ID,
VIKUNJA_API,
build_comment, build_comment,
extract_conventional_msg, extract_conventional_msg,
extract_task_id, extract_task_id,
main, main,
mark_task_done,
post_comment,
resolve_task_id, resolve_task_id,
) )
@@ -45,102 +43,46 @@ class TestBuildComment:
class TestResolveTaskId: class TestResolveTaskId:
@patch("scripts.post_merge.requests.get") def test_found(self) -> None:
def test_found(self, mock_get: MagicMock) -> None: mock_client = MagicMock()
mock_response = MagicMock() mock_client.list_tasks.return_value = [
mock_response.json.return_value = [ {"id": 42, "project_id": VIKUNJA_PROJECT_ID, "identifier": "GRM-19"},
{"id": 42, "project_id": PROJECT_ID, "identifier": "GRM-19"},
] ]
mock_get.return_value = mock_response assert resolve_task_id(mock_client, "GRM-19") == 42
assert resolve_task_id("tok", "GRM-19") == 42 mock_client.list_tasks.assert_called_once()
mock_get.assert_called_once()
@patch("scripts.post_merge.requests.get") def test_not_found_raises(self) -> None:
def test_not_found_raises(self, mock_get: MagicMock) -> None: mock_client = MagicMock()
mock_response = MagicMock() mock_client.list_tasks.return_value = []
mock_response.json.return_value = []
mock_get.return_value = mock_response
with pytest.raises(click.ClickException) as exc: with pytest.raises(click.ClickException) as exc:
resolve_task_id("tok", "GRM-99") resolve_task_id(mock_client, "GRM-99")
assert "Could not find" in str(exc.value) assert "Could not find" in str(exc.value)
@patch("scripts.post_merge.requests.get") def test_wrong_project_filtered(self) -> None:
def test_wrong_project_filtered(self, mock_get: MagicMock) -> None: mock_client = MagicMock()
mock_response = MagicMock() mock_client.list_tasks.return_value = [
mock_response.json.return_value = [
{"id": 42, "project_id": 999, "identifier": "GRM-19"}, {"id": 42, "project_id": 999, "identifier": "GRM-19"},
] ]
mock_get.return_value = mock_response
with pytest.raises(click.ClickException) as exc: with pytest.raises(click.ClickException) as exc:
resolve_task_id("tok", "GRM-19") resolve_task_id(mock_client, "GRM-19")
assert "Could not find" in str(exc.value) assert "Could not find" in str(exc.value)
@patch("scripts.post_merge.requests.get") def test_http_error_propagates(self) -> None:
def test_http_error_propagates(self, mock_get: MagicMock) -> None: mock_client = MagicMock()
mock_response = MagicMock() mock_client.list_tasks.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_response.raise_for_status.side_effect = requests.HTTPError("500") with pytest.raises(APIError):
mock_get.return_value = mock_response resolve_task_id(mock_client, "GRM-19")
with pytest.raises(requests.HTTPError):
resolve_task_id("tok", "GRM-19")
class TestPostComment:
@patch("scripts.post_merge.requests.post")
def test_success(self, mock_post: MagicMock) -> None:
mock_response = MagicMock()
mock_post.return_value = mock_response
post_comment("tok", 42, "<p>hi</p>")
args, kwargs = mock_post.call_args
assert VIKUNJA_API in args[0]
assert kwargs["json"]["comment"] == "<p>hi</p>"
@patch("scripts.post_merge.requests.post")
def test_http_error_propagates(self, mock_post: MagicMock) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
mock_post.return_value = mock_response
with pytest.raises(requests.HTTPError):
post_comment("tok", 42, "html")
class TestMarkTaskDone:
@patch("scripts.post_merge.requests.put")
def test_success(self, mock_put: MagicMock) -> None:
mock_response = MagicMock()
mock_put.return_value = mock_response
mark_task_done("tok", 42)
args, kwargs = mock_put.call_args
assert kwargs["json"]["done"] is True
@patch("scripts.post_merge.requests.put")
def test_http_error_propagates(self, mock_put: MagicMock) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
mock_put.return_value = mock_response
with pytest.raises(requests.HTTPError):
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: class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id") @patch("scripts.post_merge.VikunjaClient")
@patch("scripts.post_merge.post_comment") def test_full_flow(self, mock_client_cls: MagicMock) -> None:
@patch("scripts.post_merge.mark_task_done") mock_client = MagicMock()
def test_full_flow(self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock) -> None: mock_client.list_tasks.return_value = [
mock_resolve.return_value = 267 {"id": 267, "project_id": VIKUNJA_PROJECT_ID, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
main, main,
@@ -148,22 +90,23 @@ class TestMain:
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "updated and marked done" in result.output assert "updated and marked done" in result.output
mock_resolve.assert_called_once_with("tok", "GRM-20") mock_client.post_comment.assert_called_once()
mock_post.assert_called_once() mock_client.update_task.assert_called_once_with(267, done=True)
mock_mark.assert_called_once_with("tok", 267)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id") @patch("scripts.post_merge.VikunjaClient")
@patch("scripts.post_merge.post_comment") def test_no_commit_sha(self, mock_client_cls: MagicMock) -> None:
@patch("scripts.post_merge.mark_task_done") mock_client = MagicMock()
def test_no_commit_sha(self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock) -> None: mock_client.list_tasks.return_value = [
mock_resolve.return_value = 267 {"id": 267, "project_id": VIKUNJA_PROJECT_ID, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: resolve bug"]) result = runner.invoke(main, ["GRM-20: fix: resolve bug"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_post.assert_called_once() mock_client.post_comment.assert_called_once()
args, _ = mock_post.call_args args, _ = mock_client.post_comment.call_args
assert "unknown" in args[2] assert "unknown" in args[1]
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None: def test_missing_token_exits(self) -> None:
@@ -180,34 +123,40 @@ class TestMain:
assert "skipping" in result.output assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id") @patch("scripts.post_merge.VikunjaClient")
def test_resolve_failure_propagates(self, mock_resolve: MagicMock) -> None: def test_resolve_failure_propagates(self, mock_client_cls: MagicMock) -> None:
mock_resolve.side_effect = click.ClickException("not found") mock_client = MagicMock()
mock_client.list_tasks.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"]) result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "not found" in result.output assert "Could not find" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id") @patch("scripts.post_merge.VikunjaClient")
@patch("scripts.post_merge.post_comment") def test_post_comment_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
def test_post_comment_failure_raises_click(self, mock_post: MagicMock, mock_resolve: MagicMock) -> None: mock_client = MagicMock()
mock_resolve.return_value = 267 mock_client.list_tasks.return_value = [
mock_post.side_effect = requests.HTTPError("500") {"id": 267, "project_id": VIKUNJA_PROJECT_ID, "identifier": "GRM-20"},
]
mock_client.post_comment.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"]) result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "HTTP" in result.output assert "HTTP" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.resolve_task_id") @patch("scripts.post_merge.VikunjaClient")
@patch("scripts.post_merge.post_comment") def test_mark_done_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
@patch("scripts.post_merge.mark_task_done") mock_client = MagicMock()
def test_mark_done_failure_raises_click( mock_client.list_tasks.return_value = [
self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock {"id": 267, "project_id": VIKUNJA_PROJECT_ID, "identifier": "GRM-20"},
) -> None: ]
mock_resolve.return_value = 267 mock_client.post_comment.return_value = None
mock_mark.side_effect = requests.HTTPError("500") mock_client.update_task.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"]) result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1 assert result.exit_code == 1
+26 -45
View File
@@ -1,16 +1,14 @@
"""Unit tests for scripts/publish.py.""" """Unit tests for scripts/publish.py."""
import http
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click import click
import pytest import pytest
import requests
from click.testing import CliRunner from click.testing import CliRunner
from scripts.publish import ( from scripts.publish import (
GITEA_API,
build_package, build_package,
create_gitea_release,
main, main,
publish_to_pypi, publish_to_pypi,
) )
@@ -50,37 +48,16 @@ class TestPublishToPypi:
assert "PyPI" in str(exc.value) assert "PyPI" in str(exc.value)
class TestCreateGiteaRelease:
@patch("scripts.publish.requests.post")
def test_success(self, mock_post: MagicMock) -> None:
mock_response = MagicMock()
mock_post.return_value = mock_response
create_gitea_release("tok", "owner/repo", "v1.0.0")
args, kwargs = mock_post.call_args
assert GITEA_API in args[0]
assert kwargs["json"]["tag_name"] == "v1.0.0"
assert kwargs["json"]["draft"] is False
assert kwargs["json"]["prerelease"] is False
@patch("scripts.publish.requests.post")
def test_http_error_propagates(self, mock_post: MagicMock) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
mock_post.return_value = mock_response
with pytest.raises(requests.HTTPError):
create_gitea_release("tok", "owner/repo", "v1.0.0")
class TestMain: class TestMain:
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.create_gitea_release") @patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package") @patch("scripts.publish.build_package")
def test_full_flow_with_pypi( def test_full_flow_with_pypi(
self, self,
mock_build: MagicMock, mock_build: MagicMock,
mock_publish: MagicMock, mock_publish: MagicMock,
mock_release: MagicMock, mock_client_cls: MagicMock,
) -> None: ) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -88,21 +65,21 @@ class TestMain:
assert "Gitea release v1.0.0 created" in result.output assert "Gitea release v1.0.0 created" in result.output
mock_build.assert_called_once() mock_build.assert_called_once()
mock_publish.assert_called_once_with("pypi-tok") mock_publish.assert_called_once_with("pypi-tok")
mock_release.assert_called_once_with("gitea-tok", "owner/repo", "v1.0.0") mock_client_cls.return_value.create_release.assert_called_once()
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok"}, clear=True) @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok"}, clear=True)
@patch("scripts.publish.create_gitea_release") @patch("scripts.publish.GiteaClient")
@patch("scripts.publish.build_package") @patch("scripts.publish.build_package")
def test_without_pypi( def test_without_pypi(
self, self,
mock_build: MagicMock, mock_build: MagicMock,
mock_release: MagicMock, mock_client_cls: MagicMock,
) -> None: ) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_build.assert_called_once() mock_build.assert_called_once()
mock_release.assert_called_once_with("gitea-tok", "owner/repo", "v1.0.0") mock_client_cls.return_value.create_release.assert_called_once()
assert "PYPI_TOKEN not set" in result.output assert "PYPI_TOKEN not set" in result.output
@patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True)
@@ -113,11 +90,11 @@ class TestMain:
assert "GITEA_TOKEN" in result.output assert "GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.create_gitea_release") @patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package") @patch("scripts.publish.build_package")
def test_build_failure_raises_click( def test_build_failure_raises_click(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock
) -> None: ) -> None:
mock_build.side_effect = click.ClickException("build failed") mock_build.side_effect = click.ClickException("build failed")
runner = CliRunner() runner = CliRunner()
@@ -126,11 +103,11 @@ class TestMain:
assert "build" in result.output assert "build" in result.output
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.create_gitea_release") @patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package") @patch("scripts.publish.build_package")
def test_publish_failure_raises_click( def test_publish_failure_raises_click(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock
) -> None: ) -> None:
mock_publish.side_effect = click.ClickException("publish failed") mock_publish.side_effect = click.ClickException("publish failed")
runner = CliRunner() runner = CliRunner()
@@ -139,31 +116,35 @@ class TestMain:
assert "publish" in result.output assert "publish" in result.output
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.create_gitea_release") @patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package") @patch("scripts.publish.build_package")
def test_release_failure_raises_click( def test_release_failure_raises_click(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock
) -> None: ) -> None:
mock_release.side_effect = requests.HTTPError("500") mock_client = MagicMock()
from gitea_runner_manager.exceptions import APIError
mock_client.create_release.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "HTTP" in result.output assert "HTTP" in result.output
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.create_gitea_release") @patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package") @patch("scripts.publish.build_package")
def test_release_json_parse_failure( def test_release_json_parse_failure(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_release: MagicMock self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock
) -> None: ) -> None:
mock_response = MagicMock() mock_client = MagicMock()
mock_response.status_code = 502 from gitea_runner_manager.exceptions import APIError
mock_response.json.side_effect = ValueError("not json")
err = requests.HTTPError("502", response=mock_response) mock_client.create_release.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
mock_release.side_effect = err mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "502" in result.output assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
+2 -1
View File
@@ -7,7 +7,8 @@ from unittest.mock import patch
from click.testing import CliRunner from click.testing import CliRunner
from scripts.validate_commit_msg import CONVENTIONAL_RE, TASK_ID_RE, first_line, get_branch, main from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from scripts.validate_commit_msg import first_line, get_branch, main
class TestHelpers: class TestHelpers: