"""Gitea API client and forge adapter (PRD prd-new: fold orchestrator). `GiteaClient` is a thin HTTP wrapper (stdlib `urllib.request` only — no new runtime dependencies). `GiteaForge` composes a client and exposes the forge protocol used by the orchestrator's sidecar and lifecycle. Required Gitea token scopes: - Repository: Read & Write (issues, comments, PR descriptions) - Organization: Read (org membership check) """ from __future__ import annotations import json import urllib.error import urllib.request from typing import Any _TIMEOUT_SECS = 30 class GiteaClient: """Low-level HTTP wrapper for the Gitea REST API.""" def __init__( self, *, api_url: str, owner: str, repo: str, token: str ) -> None: self._base = api_url.rstrip("/") self._owner = owner self._repo = repo self._headers = { "Authorization": f"token {token}", "Content-Type": "application/json", "Accept": "application/json", } def _request( self, method: str, path: str, body: dict[str, Any] | None = None, ) -> Any: url = f"{self._base}{path}" data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request( url, data=data, headers=self._headers, method=method ) with urllib.request.urlopen(req, timeout=_TIMEOUT_SECS) as resp: raw = resp.read() return json.loads(raw) if raw else None def is_org_member(self, org: str, username: str) -> bool: url = f"{self._base}/orgs/{org}/members/{username}" req = urllib.request.Request(url, headers=self._headers, method="GET") try: urllib.request.urlopen(req, timeout=_TIMEOUT_SECS).close() return True except urllib.error.HTTPError: return False def get_issue(self, number: int) -> dict[str, Any]: return self._request("GET", f"/repos/{self._owner}/{self._repo}/issues/{number}") def get_pull(self, number: int) -> dict[str, Any]: return self._request("GET", f"/repos/{self._owner}/{self._repo}/pulls/{number}") def list_comments(self, number: int) -> list[dict[str, Any]]: return self._request("GET", f"/repos/{self._owner}/{self._repo}/issues/{number}/comments") def create_comment(self, number: int, body: str) -> None: self._request( "POST", f"/repos/{self._owner}/{self._repo}/issues/{number}/comments", {"body": body}, ) def update_issue(self, number: int, body: str) -> None: self._request( "PATCH", f"/repos/{self._owner}/{self._repo}/issues/{number}", {"body": body}, ) class GiteaForge: """Adapts `GiteaClient` to the forge protocol expected by the orchestrator. The forge protocol is duck-typed: any object with `is_org_member`, `read_issue`, `read_pr`, `read_comments`, `post_comment`, and `update_description` methods satisfies it. """ def __init__(self, client: GiteaClient) -> None: self._client = client def is_org_member(self, org: str, username: str) -> bool: return self._client.is_org_member(org, username) def read_issue(self, number: int) -> dict[str, Any]: return self._client.get_issue(number) def read_pr(self, number: int) -> dict[str, Any]: return self._client.get_pull(number) def read_comments(self, number: int) -> list[dict[str, Any]]: return self._client.list_comments(number) def post_comment(self, number: int, body: str) -> None: self._client.create_comment(number, body) def update_description(self, number: int, body: str) -> None: self._client.update_issue(number, body)