""" 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""" # Encode content to base64 if not already encoded if "content" in data: content = data["content"] # Check if content is already base64 encoded is_already_base64 = data.get("content_is_base64", False) if not is_already_base64: # Plain text content - encode to base64 try: data["content"] = base64.b64encode(content.encode()).decode() except (AttributeError, UnicodeDecodeError): # If content is already bytes or has encoding issues, try direct encoding if isinstance(content, bytes): data["content"] = base64.b64encode(content).decode() else: raise ValueError("Content must be a string or bytes") # Remove the flag before sending to API 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""" # Encode content to base64 if not already encoded if "content" in data: content = data["content"] # Check if content is already base64 encoded is_already_base64 = data.get("content_is_base64", False) if not is_already_base64: # Plain text content - encode to base64 try: data["content"] = base64.b64encode(content.encode()).decode() except (AttributeError, UnicodeDecodeError): # If content is already bytes or has encoding issues, try direct encoding if isinstance(content, bytes): data["content"] = base64.b64encode(content).decode() else: raise ValueError("Content must be a string or bytes") # Remove the flag before sending to API data.pop("content_is_base64", None) return await self.request( "PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data ) 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 ) # 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")