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:
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -11,3 +11,12 @@ class AnsibleError(GRMError):
|
||||
"""Raised when an Ansible command fails."""
|
||||
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user