from __future__ import annotations import base64 from collections.abc import Mapping from typing import Any import httpx from settings import Settings class ForgejoClientError(RuntimeError): pass class ForgejoClient: def __init__(self, settings: Settings, forgejo_token: str | None = None) -> None: self._settings = settings self._forgejo_token = forgejo_token or settings.forgejo_token self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds) async def __aenter__(self) -> ForgejoClient: return self async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None: await self._client.aclose() async def fetch_openid_configuration(self) -> dict[str, Any]: return await self._get_json( f"{self._settings.forgejo_base_url}/.well-known/openid-configuration", absolute_url=True, ) async def exchange_oauth_code( self, *, token_endpoint: str, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str, ) -> dict[str, Any]: response = await self._client.post( token_endpoint, data={ "grant_type": "authorization_code", "client_id": client_id, "client_secret": client_secret, "code": code, "redirect_uri": redirect_uri, "code_verifier": code_verifier, }, headers={"Accept": "application/json"}, ) if response.is_success: payload = response.json() if isinstance(payload, dict): return payload raise ForgejoClientError("Unexpected Forgejo OAuth token payload.") message = self._extract_error_message(response) raise ForgejoClientError(f"{response.status_code} from Forgejo OAuth: {message}") async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, Any]: response = await self._client.get( userinfo_endpoint, headers={ "Accept": "application/json", "Authorization": f"Bearer {access_token}", }, ) if response.is_success: payload = response.json() if isinstance(payload, dict): return payload raise ForgejoClientError("Unexpected Forgejo UserInfo payload.") message = self._extract_error_message(response) raise ForgejoClientError(f"{response.status_code} from Forgejo UserInfo: {message}") async def fetch_current_user(self) -> dict[str, Any]: return await self._get_json("/api/v1/user", auth_required=True) async def search_repositories(self) -> list[dict[str, Any]]: payload = await self._get_json( "/api/v1/repos/search", params={ "limit": self._settings.forgejo_repo_scan_limit, "page": 1, "private": "false", "is_private": "false", }, ) data = payload.get("data", []) return [repo for repo in data if isinstance(repo, dict)] async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]: payload = await self._get_json( f"/api/v1/repos/{owner}/{repo}", ) if isinstance(payload, dict): return payload raise ForgejoClientError(f"Unexpected repository payload for {owner}/{repo}") async def list_repo_issues( self, owner: str, repo: str, *, limit: int, ) -> list[dict[str, Any]]: payload = await self._get_json( f"/api/v1/repos/{owner}/{repo}/issues", params={ "state": "open", "page": 1, "limit": limit, "type": "issues", "sort": "recentupdate", }, ) if isinstance(payload, list): return [issue for issue in payload if isinstance(issue, dict)] return [] async def list_directory(self, owner: str, repo: str, path: str = "") -> list[dict[str, Any]]: endpoint = f"/api/v1/repos/{owner}/{repo}/contents" if path: endpoint = f"{endpoint}/{path.strip('/')}" payload = await self._get_json(endpoint) if isinstance(payload, list): return [entry for entry in payload if isinstance(entry, dict)] if isinstance(payload, dict): return [payload] return [] async def list_issue_comments( self, owner: str, repo: str, issue_number: int, ) -> list[dict[str, Any]]: payload = await self._get_json( f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments", ) if isinstance(payload, list): return [comment for comment in payload if isinstance(comment, dict)] return [] async def create_issue_comment( self, owner: str, repo: str, issue_number: int, body: str, ) -> dict[str, Any]: payload = await self._request_json( "POST", f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments", json_payload={"body": body}, auth_required=True, ) if isinstance(payload, dict): return payload raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}") async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]: payload = await self._get_json( f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}", ) if not isinstance(payload, dict): raise ForgejoClientError(f"Unexpected file payload for {owner}/{repo}:{path}") raw_content = payload.get("content", "") encoding = payload.get("encoding", "") decoded_content = "" if isinstance(raw_content, str): if encoding == "base64": decoded_content = base64.b64decode(raw_content).decode("utf-8") else: decoded_content = raw_content return { "name": str(payload.get("name", "")), "path": str(payload.get("path", path)), "html_url": str(payload.get("html_url", "")), "content": decoded_content, } async def _get_json( self, path: str, *, absolute_url: bool = False, params: Mapping[str, str | int] | None = None, auth_required: bool = False, ) -> dict[str, Any] | list[Any]: return await self._request_json( "GET", path, absolute_url=absolute_url, params=params, auth_required=auth_required, ) async def _request_json( self, method: str, path: str, *, absolute_url: bool = False, params: Mapping[str, str | int] | None = None, json_payload: Mapping[str, object] | None = None, auth_required: bool = False, ) -> dict[str, Any] | list[Any]: if auth_required and not self._forgejo_token: raise ForgejoClientError( "This Forgejo instance requires an authenticated API token for repo and issue access.", ) url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}" headers = {} if self._forgejo_token: headers["Authorization"] = f"token {self._forgejo_token}" response = await self._client.request( method, url, params=params, headers=headers, json=json_payload, ) if response.is_success: return response.json() message = self._extract_error_message(response) raise ForgejoClientError(f"{response.status_code} from Forgejo: {message}") @staticmethod def _extract_error_message(response: httpx.Response) -> str: try: payload = response.json() except ValueError: return response.text or response.reason_phrase if isinstance(payload, dict): raw_message = payload.get("message") if isinstance(raw_message, str) and raw_message: return raw_message return response.reason_phrase