""" Gitea REST API Client Handles all HTTP communication with Gitea REST API. Separates API communication from business logic. """ import base64 import logging from typing import Any import aiohttp class GiteaClient: """ Gitea REST API client for HTTP communication. Handles authentication, request formatting, and error handling for all Gitea API endpoints. """ def __init__(self, site_url: str, token: str | None = None, oauth_enabled: bool = False): """ Initialize Gitea API client. Args: site_url: Gitea instance URL (e.g., https://gitea.example.com) token: Personal access token for authentication oauth_enabled: Whether OAuth is enabled for this site """ self.site_url = site_url.rstrip("/") self.api_base = f"{self.site_url}/api/v1" self.token = token self.oauth_enabled = oauth_enabled # Initialize logger self.logger = logging.getLogger(f"GiteaClient.{site_url}") def _get_headers(self, additional_headers: dict | None = None) -> dict[str, str]: """ Get request headers with authentication. Args: additional_headers: Additional headers to include Returns: Dict: Headers with authentication """ headers = {"Content-Type": "application/json", "accept": "application/json"} # Add token authentication if available if self.token: headers["Authorization"] = f"token {self.token}" # Merge additional headers if additional_headers: headers.update(additional_headers) return headers async def request( self, method: str, endpoint: str, params: dict | None = None, json_data: dict | None = None, headers_override: dict | None = None, ) -> Any: """ Make authenticated request to Gitea REST API. Args: method: HTTP method (GET, POST, PUT, DELETE, PATCH) endpoint: API endpoint (without base URL) params: Query parameters json_data: JSON body data headers_override: Override default headers Returns: API response (dict, list, or None) Raises: Exception: On API errors with status code and message """ # Build full URL url = f"{self.api_base}/{endpoint.lstrip('/')}" # Setup headers headers = self._get_headers(headers_override) # Filter out None values from params if params: params = {k: v for k, v in params.items() if v is not None} # Filter None values from JSON data if json_data: json_data = {k: v for k, v in json_data.items() if v is not None} # Make request self.logger.debug(f"{method} {url}") self.logger.debug(f"Params: {params}") self.logger.debug(f"Data: {json_data}") async with ( aiohttp.ClientSession() as session, session.request( method=method, url=url, params=params, json=json_data, headers=headers ) as response, ): # Log response self.logger.debug(f"Response status: {response.status}") # Handle empty responses (e.g., 204 No Content) if response.status == 204: return {"success": True, "message": "Operation completed successfully"} # Try to parse JSON response try: response_data = await response.json() except Exception: response_text = await response.text() if response.status >= 400: raise Exception(f"Gitea API error (status {response.status}): {response_text}") return {"success": True, "message": response_text} # Check for errors if response.status >= 400: error_msg = response_data.get("message", "Unknown error") raise Exception(f"Gitea API error (status {response.status}): {error_msg}") return response_data # Repository endpoints async def list_repositories( self, owner: str | None = None, page: int = 1, limit: int = 30 ) -> list[dict]: """List repositories for a user/org or current user""" if owner: endpoint = f"users/{owner}/repos" else: endpoint = "user/repos" params = {"page": page, "limit": limit} return await self.request("GET", endpoint, params=params) async def get_repository(self, owner: str, repo: str) -> dict: """Get repository details""" return await self.request("GET", f"repos/{owner}/{repo}") async def create_repository(self, data: dict, org: str | None = None) -> dict: """Create a new repository""" if org: endpoint = f"orgs/{org}/repos" else: endpoint = "user/repos" return await self.request("POST", endpoint, json_data=data) async def update_repository(self, owner: str, repo: str, data: dict) -> dict: """Update repository settings""" return await self.request("PATCH", f"repos/{owner}/{repo}", json_data=data) async def delete_repository(self, owner: str, repo: str) -> dict: """Delete a repository""" return await self.request("DELETE", f"repos/{owner}/{repo}") # Branch endpoints async def list_branches( self, owner: str, repo: str, page: int = 1, limit: int = 30 ) -> list[dict]: """List repository branches""" params = {"page": page, "limit": limit} return await self.request("GET", f"repos/{owner}/{repo}/branches", params=params) async def get_branch(self, owner: str, repo: str, branch: str) -> dict: """Get branch details""" return await self.request("GET", f"repos/{owner}/{repo}/branches/{branch}") async def create_branch(self, owner: str, repo: str, data: dict) -> dict: """Create a new branch""" return await self.request("POST", f"repos/{owner}/{repo}/branches", json_data=data) async def delete_branch(self, owner: str, repo: str, branch: str) -> dict: """Delete a branch""" return await self.request("DELETE", f"repos/{owner}/{repo}/branches/{branch}") # Tag endpoints async def list_tags(self, owner: str, repo: str, page: int = 1, limit: int = 30) -> list[dict]: """List repository tags""" params = {"page": page, "limit": limit} return await self.request("GET", f"repos/{owner}/{repo}/tags", params=params) async def create_tag(self, owner: str, repo: str, data: dict) -> dict: """Create a new tag""" return await self.request("POST", f"repos/{owner}/{repo}/tags", json_data=data) async def delete_tag(self, owner: str, repo: str, tag: str) -> dict: """Delete a tag""" return await self.request("DELETE", f"repos/{owner}/{repo}/tags/{tag}") # File endpoints async def get_file(self, owner: str, repo: str, filepath: str, ref: str | None = None) -> dict: """Get file contents""" params = {"ref": ref} if ref else {} return await self.request("GET", f"repos/{owner}/{repo}/contents/{filepath}", params=params) async def create_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict: """Create a file""" if "content" in data: data["content"] = self._normalise_file_content( data["content"], data.get("content_is_base64", False) ) data.pop("content_is_base64", None) return await self.request( "POST", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data ) async def update_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict: """Update a file""" if "content" in data: data["content"] = self._normalise_file_content( data["content"], data.get("content_is_base64", False) ) data.pop("content_is_base64", None) return await self.request( "PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data ) @staticmethod def _normalise_file_content(content: Any, is_already_base64: bool) -> str: """F.17 ergonomics: turn ``content`` into a base64 string suitable for Gitea's contents endpoints, with actionable error messages. ``is_already_base64=True`` validates that ``content`` decodes cleanly and re-emits it stripped of whitespace; if the decode fails, raise a message that tells the caller exactly how to recover (drop ``data:`` prefix, use ``content_is_base64=False`` for raw text). ``is_already_base64=False`` UTF-8 + base64 encodes the string. """ if is_already_base64: if not isinstance(content, str): raise ValueError( "content_is_base64=True but content is not a string. " "Pass the raw base64 text; do not wrap it in bytes / dicts." ) stripped = content.strip() if stripped.lower().startswith("data:"): raise ValueError( "content looks like a data: URL (starts with 'data:'). " "Strip the 'data:;base64,' prefix before sending — " "Gitea expects the base64 payload only." ) try: # Validate round-trip and normalise whitespace. raw = base64.b64decode(stripped, validate=True) return base64.b64encode(raw).decode() except Exception as exc: # noqa: BLE001 raise ValueError( "content_is_base64=True but content is not valid base64. " "If you meant to send raw text, set content_is_base64=False " "(the client will base64-encode it for you). Original " f"decoder error: {exc}" ) from exc # Plain text / bytes → base64-encode for the caller. if isinstance(content, bytes): return base64.b64encode(content).decode() if isinstance(content, str): return base64.b64encode(content.encode("utf-8")).decode() raise ValueError( "content must be a string or bytes when content_is_base64=False. " f"Got {type(content).__name__}." ) async def delete_file( self, owner: str, repo: str, filepath: str, sha: str, message: str, branch: str | None = None, ) -> dict: """Delete a file""" data = {"sha": sha, "message": message} if branch: data["branch"] = branch return await self.request( "DELETE", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data ) # ------------------------------------------------------------------ # F.17 — ergonomics: batch file write, tree listing, search, compare, # releases, fork. # ------------------------------------------------------------------ async def change_files( self, owner: str, repo: str, data: dict, ) -> dict: """POST /repos/{owner}/{repo}/contents — apply a batch of file create/update/delete operations in a single commit. Gitea's ``files`` endpoint takes a list of ``{operation, path, content, sha}`` entries. Operations: ``create`` | ``update`` | ``delete``. ``content`` must be base64 for create/update. """ return await self.request("POST", f"repos/{owner}/{repo}/contents", json_data=data) async def get_tree( self, owner: str, repo: str, sha: str = "HEAD", *, recursive: bool = False, page: int = 1, per_page: int = 100, ) -> dict: """GET /repos/{owner}/{repo}/git/trees/{sha} Returns ``{sha, url, tree: [{path, mode, type, size, sha, url}], truncated}``. Set ``recursive=True`` to get the entire tree. """ params: dict[str, Any] = {"page": page, "per_page": per_page} if recursive: params["recursive"] = "true" return await self.request("GET", f"repos/{owner}/{repo}/git/trees/{sha}", params=params) async def search_code( self, *, keyword: str, owner: str | None = None, repo: str | None = None, page: int = 1, per_page: int = 30, ) -> dict: """Search code either across all repos (``/repos/search/code``) or within a specific repo (``/repos/{owner}/{repo}/search/code``). Returns the raw Gitea payload: ``{ok, data: [...]}``. """ params: dict[str, Any] = {"q": keyword, "page": page, "per_page": per_page} if owner and repo: path = f"repos/{owner}/{repo}/search/code" else: path = "repos/search/code" return await self.request("GET", path, params=params) async def compare( self, owner: str, repo: str, base: str, head: str, ) -> dict: """GET /repos/{owner}/{repo}/compare/{base}...{head}""" # Gitea's compare endpoint uses ``...`` as the separator. The HTTP # client URL-encodes path params, so we pre-join instead of # passing them as separate path parameters. spec = f"{base}...{head}" return await self.request("GET", f"repos/{owner}/{repo}/compare/{spec}") async def list_releases( self, owner: str, repo: str, *, page: int = 1, per_page: int = 30, ) -> list[dict]: """GET /repos/{owner}/{repo}/releases""" return await self.request( "GET", f"repos/{owner}/{repo}/releases", params={"page": page, "per_page": per_page}, ) async def create_release( self, owner: str, repo: str, data: dict, ) -> dict: """POST /repos/{owner}/{repo}/releases""" return await self.request("POST", f"repos/{owner}/{repo}/releases", json_data=data) async def get_release(self, owner: str, repo: str, release_id: int) -> dict: return await self.request("GET", f"repos/{owner}/{repo}/releases/{release_id}") async def delete_release(self, owner: str, repo: str, release_id: int) -> dict: return await self.request("DELETE", f"repos/{owner}/{repo}/releases/{release_id}") async def upload_release_asset( self, owner: str, repo: str, release_id: int, *, filename: str, content_b64: str, ) -> dict: """Upload a release asset. Gitea's API accepts multipart/form-data on ``POST /repos/{owner}/{repo}/releases/{id}/assets?name=FILE``. We accept base64 content so the tool schema stays JSON-friendly; callers supply ``content_b64``. Decoded bytes go into the multipart body. This bypasses ``self.request`` because that helper only understands JSON bodies. """ import aiohttp try: raw = base64.b64decode(content_b64, validate=True) except Exception as exc: # noqa: BLE001 raise ValueError( "content_b64 must be a valid base64 string. " "For small text files, base64-encode the UTF-8 bytes; " "do not include a 'data:' URL prefix." ) from exc url = f"{self.api_base}/repos/{owner}/{repo}/releases/{release_id}/assets" headers = self._get_headers() # Requests library-style: multipart/form-data with attachment field. form = aiohttp.FormData() form.add_field( "attachment", raw, filename=filename, content_type="application/octet-stream", ) async with aiohttp.ClientSession() as session: async with session.post( url, params={"name": filename}, data=form, headers=headers ) as resp: text = await resp.text() if resp.status >= 400: raise Exception( f"Gitea upload_release_asset failed ({resp.status}): {text[:500]}" ) try: import json as _json return _json.loads(text) if text else {"success": True} except Exception: return {"success": True, "raw": text[:500]} async def fork_repository( self, owner: str, repo: str, *, organization: str | None = None, name: str | None = None, ) -> dict: """POST /repos/{owner}/{repo}/forks ``organization``: target org (omit to fork under the caller). ``name``: custom name for the fork. """ payload: dict[str, Any] = {} if organization: payload["organization"] = organization if name: payload["name"] = name return await self.request("POST", f"repos/{owner}/{repo}/forks", json_data=payload) # Issue endpoints async def list_issues(self, owner: str, repo: str, params: dict) -> list[dict]: """List repository issues""" return await self.request("GET", f"repos/{owner}/{repo}/issues", params=params) async def get_issue(self, owner: str, repo: str, index: int) -> dict: """Get issue details""" return await self.request("GET", f"repos/{owner}/{repo}/issues/{index}") async def create_issue(self, owner: str, repo: str, data: dict) -> dict: """Create a new issue""" return await self.request("POST", f"repos/{owner}/{repo}/issues", json_data=data) async def update_issue(self, owner: str, repo: str, index: int, data: dict) -> dict: """Update an issue""" return await self.request("PATCH", f"repos/{owner}/{repo}/issues/{index}", json_data=data) async def list_issue_comments(self, owner: str, repo: str, index: int) -> list[dict]: """List issue comments""" return await self.request("GET", f"repos/{owner}/{repo}/issues/{index}/comments") async def create_issue_comment(self, owner: str, repo: str, index: int, data: dict) -> dict: """Create issue comment""" return await self.request( "POST", f"repos/{owner}/{repo}/issues/{index}/comments", json_data=data ) # Label endpoints async def list_labels(self, owner: str, repo: str) -> list[dict]: """List repository labels""" return await self.request("GET", f"repos/{owner}/{repo}/labels") async def create_label(self, owner: str, repo: str, data: dict) -> dict: """Create a label""" return await self.request("POST", f"repos/{owner}/{repo}/labels", json_data=data) async def delete_label(self, owner: str, repo: str, label_id: int) -> dict: """Delete a label""" return await self.request("DELETE", f"repos/{owner}/{repo}/labels/{label_id}") # Milestone endpoints async def list_milestones(self, owner: str, repo: str, state: str | None = None) -> list[dict]: """List repository milestones""" params = {"state": state} if state else {} return await self.request("GET", f"repos/{owner}/{repo}/milestones", params=params) async def create_milestone(self, owner: str, repo: str, data: dict) -> dict: """Create a milestone""" return await self.request("POST", f"repos/{owner}/{repo}/milestones", json_data=data) # Pull Request endpoints async def list_pull_requests(self, owner: str, repo: str, params: dict) -> list[dict]: """List repository pull requests""" return await self.request("GET", f"repos/{owner}/{repo}/pulls", params=params) async def get_pull_request(self, owner: str, repo: str, index: int) -> dict: """Get pull request details""" return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}") async def create_pull_request(self, owner: str, repo: str, data: dict) -> dict: """Create a new pull request""" return await self.request("POST", f"repos/{owner}/{repo}/pulls", json_data=data) async def update_pull_request(self, owner: str, repo: str, index: int, data: dict) -> dict: """Update a pull request""" return await self.request("PATCH", f"repos/{owner}/{repo}/pulls/{index}", json_data=data) async def merge_pull_request(self, owner: str, repo: str, index: int, data: dict) -> dict: """Merge a pull request""" return await self.request( "POST", f"repos/{owner}/{repo}/pulls/{index}/merge", json_data=data ) async def list_pr_commits(self, owner: str, repo: str, index: int) -> list[dict]: """List pull request commits""" return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/commits") async def list_pr_files(self, owner: str, repo: str, index: int) -> list[dict]: """List pull request files""" return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/files") async def get_pr_diff(self, owner: str, repo: str, index: int) -> str: """Get pull request diff""" # Override accept header for diff headers = {"accept": "text/plain"} response = await self.request( "GET", f"repos/{owner}/{repo}/pulls/{index}.diff", headers_override=headers ) return response async def list_pr_reviews(self, owner: str, repo: str, index: int) -> list[dict]: """List pull request reviews""" return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/reviews") async def create_pr_review(self, owner: str, repo: str, index: int, data: dict) -> dict: """Create pull request review""" return await self.request( "POST", f"repos/{owner}/{repo}/pulls/{index}/reviews", json_data=data ) async def request_pr_reviewers(self, owner: str, repo: str, index: int, data: dict) -> dict: """Request pull request reviewers""" return await self.request( "POST", f"repos/{owner}/{repo}/pulls/{index}/requested_reviewers", json_data=data ) # User endpoints async def get_user(self, username: str) -> dict: """Get user information""" return await self.request("GET", f"users/{username}") async def list_user_repos(self, username: str, page: int = 1, limit: int = 30) -> list[dict]: """List user repositories""" params = {"page": page, "limit": limit} return await self.request("GET", f"users/{username}/repos", params=params) async def search_users(self, query: str | None = None, uid: int | None = None) -> list[dict]: """Search users""" params = {} if query: params["q"] = query if uid: params["uid"] = uid response = await self.request("GET", "users/search", params=params) return response.get("data", []) # Organization endpoints async def list_organizations(self, page: int = 1, limit: int = 30) -> list[dict]: """List current user's organizations""" params = {"page": page, "limit": limit} return await self.request("GET", "user/orgs", params=params) async def get_organization(self, org: str) -> dict: """Get organization information""" return await self.request("GET", f"orgs/{org}") async def list_org_repos(self, org: str, page: int = 1, limit: int = 30) -> list[dict]: """List organization repositories""" params = {"page": page, "limit": limit} return await self.request("GET", f"orgs/{org}/repos", params=params) async def list_org_teams(self, org: str, page: int = 1, limit: int = 30) -> list[dict]: """List organization teams""" params = {"page": page, "limit": limit} return await self.request("GET", f"orgs/{org}/teams", params=params) async def list_team_members(self, team_id: int, page: int = 1, limit: int = 30) -> list[dict]: """List team members""" params = {"page": page, "limit": limit} return await self.request("GET", f"teams/{team_id}/members", params=params) # Webhook endpoints async def list_webhooks(self, owner: str, repo: str) -> list[dict]: """List repository webhooks""" return await self.request("GET", f"repos/{owner}/{repo}/hooks") async def create_webhook(self, owner: str, repo: str, data: dict) -> dict: """Create a webhook""" return await self.request("POST", f"repos/{owner}/{repo}/hooks", json_data=data) async def get_webhook(self, owner: str, repo: str, hook_id: int) -> dict: """Get webhook details""" return await self.request("GET", f"repos/{owner}/{repo}/hooks/{hook_id}") async def update_webhook(self, owner: str, repo: str, hook_id: int, data: dict) -> dict: """Update a webhook""" return await self.request("PATCH", f"repos/{owner}/{repo}/hooks/{hook_id}", json_data=data) async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> dict: """Delete a webhook""" return await self.request("DELETE", f"repos/{owner}/{repo}/hooks/{hook_id}") async def test_webhook(self, owner: str, repo: str, hook_id: int) -> dict: """Test a webhook""" return await self.request("POST", f"repos/{owner}/{repo}/hooks/{hook_id}/tests")