""" Gitea provider implementation. Implements the BaseProvider interface for Gitea API operations. """ import logging from datetime import UTC, datetime from typing import Any import httpx from config import Settings, get_settings from exceptions import ( APIError, AuthenticationError, PRNotFoundError, ) from models import ( CreatePRResult, GetPRResult, ListPRsResult, MergePRResult, MergeStrategy, PRInfo, PRState, UpdatePRResult, ) from .base import BaseProvider logger = logging.getLogger(__name__) class GiteaProvider(BaseProvider): """ Gitea API provider implementation. Supports all PR operations, branch operations, and repository queries. """ def __init__( self, base_url: str | None = None, token: str | None = None, settings: Settings | None = None, ) -> None: """ Initialize Gitea provider. Args: base_url: Gitea server URL (e.g., https://gitea.example.com) token: API token settings: Optional settings override """ self.settings = settings or get_settings() self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/") self.token = token or self.settings.gitea_token self._client: httpx.AsyncClient | None = None self._user: str | None = None @property def name(self) -> str: """Return the provider name.""" return "gitea" async def _get_client(self) -> httpx.AsyncClient: """Get or create HTTP client.""" if self._client is None: headers = { "Accept": "application/json", "Content-Type": "application/json", } if self.token: headers["Authorization"] = f"token {self.token}" self._client = httpx.AsyncClient( base_url=f"{self.base_url}/api/v1", headers=headers, timeout=30.0, ) return self._client async def close(self) -> None: """Close the HTTP client.""" if self._client: await self._client.aclose() self._client = None async def _request( self, method: str, path: str, **kwargs: Any, ) -> Any: """ Make an API request. Args: method: HTTP method path: API path **kwargs: Additional request arguments Returns: Parsed JSON response Raises: APIError: On API errors AuthenticationError: On auth failures """ client = await self._get_client() try: response = await client.request(method, path, **kwargs) if response.status_code == 401: raise AuthenticationError("gitea", "Invalid or expired token") if response.status_code == 403: raise AuthenticationError( "gitea", "Insufficient permissions for this operation" ) if response.status_code == 404: return None if response.status_code >= 400: error_msg = response.text try: error_data = response.json() error_msg = error_data.get("message", error_msg) except Exception: pass raise APIError("gitea", response.status_code, error_msg) if response.status_code == 204: return None return response.json() except httpx.RequestError as e: raise APIError("gitea", 0, f"Request failed: {e}") async def is_connected(self) -> bool: """Check if connected to Gitea.""" if not self.base_url or not self.token: return False try: result = await self._request("GET", "/user") return result is not None except Exception: return False async def get_authenticated_user(self) -> str | None: """Get the authenticated user's username.""" if self._user: return self._user try: result = await self._request("GET", "/user") if result: self._user = result.get("login") or result.get("username") return self._user except Exception: pass return None # Repository operations async def get_repo_info(self, owner: str, repo: str) -> dict[str, Any]: """Get repository information.""" result = await self._request("GET", f"/repos/{owner}/{repo}") if result is None: raise APIError("gitea", 404, f"Repository not found: {owner}/{repo}") return result async def get_default_branch(self, owner: str, repo: str) -> str: """Get the default branch for a repository.""" repo_info = await self.get_repo_info(owner, repo) return repo_info.get("default_branch", "main") # Pull Request operations async def create_pr( self, owner: str, repo: str, title: str, body: str, source_branch: str, target_branch: str, draft: bool = False, labels: list[str] | None = None, assignees: list[str] | None = None, reviewers: list[str] | None = None, ) -> CreatePRResult: """Create a pull request.""" try: data: dict[str, Any] = { "title": title, "body": body, "head": source_branch, "base": target_branch, } # Note: Gitea doesn't have draft PR support in all versions # Draft support was added in Gitea 1.14+ result = await self._request( "POST", f"/repos/{owner}/{repo}/pulls", json=data, ) if result is None: return CreatePRResult( success=False, error="Failed to create pull request", ) pr_number = result["number"] # Add labels if specified if labels: await self.add_labels(owner, repo, pr_number, labels) # Add assignees if specified (via issue update) if assignees: await self._request( "PATCH", f"/repos/{owner}/{repo}/issues/{pr_number}", json={"assignees": assignees}, ) # Request reviewers if specified if reviewers: await self.request_review(owner, repo, pr_number, reviewers) return CreatePRResult( success=True, pr_number=pr_number, pr_url=result.get("html_url"), ) except APIError as e: return CreatePRResult( success=False, error=str(e), ) async def get_pr(self, owner: str, repo: str, pr_number: int) -> GetPRResult: """Get a pull request by number.""" try: result = await self._request( "GET", f"/repos/{owner}/{repo}/pulls/{pr_number}", ) if result is None: raise PRNotFoundError(pr_number, f"{owner}/{repo}") pr_info = self._parse_pr(result) return GetPRResult( success=True, pr=pr_info.to_dict(), ) except PRNotFoundError: return GetPRResult( success=False, error=f"Pull request #{pr_number} not found", ) except APIError as e: return GetPRResult( success=False, error=str(e), ) async def list_prs( self, owner: str, repo: str, state: PRState | None = None, author: str | None = None, limit: int = 20, ) -> ListPRsResult: """List pull requests.""" try: params: dict[str, Any] = { "limit": limit, } if state: # Gitea uses different state names if state == PRState.OPEN: params["state"] = "open" elif state == PRState.CLOSED or state == PRState.MERGED: params["state"] = "closed" else: params["state"] = "all" result = await self._request( "GET", f"/repos/{owner}/{repo}/pulls", params=params, ) if result is None: return ListPRsResult( success=True, pull_requests=[], total_count=0, ) prs = [] for pr_data in result: # Filter by author if specified if author: pr_author = pr_data.get("user", {}).get("login", "") if pr_author.lower() != author.lower(): continue # Filter merged PRs if looking specifically for merged if state == PRState.MERGED: if not pr_data.get("merged"): continue pr_info = self._parse_pr(pr_data) prs.append(pr_info.to_dict()) return ListPRsResult( success=True, pull_requests=prs, total_count=len(prs), ) except APIError as e: return ListPRsResult( success=False, error=str(e), ) async def merge_pr( self, owner: str, repo: str, pr_number: int, merge_strategy: MergeStrategy = MergeStrategy.MERGE, commit_message: str | None = None, delete_branch: bool = True, ) -> MergePRResult: """Merge a pull request.""" try: # Map merge strategy to Gitea's "Do" values do_map = { MergeStrategy.MERGE: "merge", MergeStrategy.SQUASH: "squash", MergeStrategy.REBASE: "rebase", } data: dict[str, Any] = { "Do": do_map[merge_strategy], "delete_branch_after_merge": delete_branch, } if commit_message: data["MergeTitleField"] = commit_message.split("\n")[0] if "\n" in commit_message: data["MergeMessageField"] = "\n".join( commit_message.split("\n")[1:] ) result = await self._request( "POST", f"/repos/{owner}/{repo}/pulls/{pr_number}/merge", json=data, ) if result is None: # Check if PR was actually merged pr_result = await self.get_pr(owner, repo, pr_number) if pr_result.success and pr_result.pr: if pr_result.pr.get("state") == "merged": return MergePRResult( success=True, branch_deleted=delete_branch, ) return MergePRResult( success=False, error="Failed to merge pull request", ) return MergePRResult( success=True, merge_commit_sha=result.get("sha"), branch_deleted=delete_branch, ) except APIError as e: return MergePRResult( success=False, error=str(e), ) async def update_pr( self, owner: str, repo: str, pr_number: int, title: str | None = None, body: str | None = None, state: PRState | None = None, labels: list[str] | None = None, assignees: list[str] | None = None, ) -> UpdatePRResult: """Update a pull request.""" try: data: dict[str, Any] = {} if title is not None: data["title"] = title if body is not None: data["body"] = body if state is not None: if state == PRState.OPEN: data["state"] = "open" elif state == PRState.CLOSED: data["state"] = "closed" # Update PR if there's data if data: await self._request( "PATCH", f"/repos/{owner}/{repo}/pulls/{pr_number}", json=data, ) # Update labels via issue endpoint if labels is not None: # First clear existing labels await self._request( "DELETE", f"/repos/{owner}/{repo}/issues/{pr_number}/labels", ) # Then add new labels if labels: await self.add_labels(owner, repo, pr_number, labels) # Update assignees via issue endpoint if assignees is not None: await self._request( "PATCH", f"/repos/{owner}/{repo}/issues/{pr_number}", json={"assignees": assignees}, ) # Fetch updated PR result = await self.get_pr(owner, repo, pr_number) return UpdatePRResult( success=result.success, pr=result.pr, error=result.error, ) except APIError as e: return UpdatePRResult( success=False, error=str(e), ) async def close_pr( self, owner: str, repo: str, pr_number: int, ) -> UpdatePRResult: """Close a pull request without merging.""" return await self.update_pr( owner, repo, pr_number, state=PRState.CLOSED, ) # Branch operations async def delete_remote_branch( self, owner: str, repo: str, branch: str, ) -> bool: """Delete a remote branch.""" try: await self._request( "DELETE", f"/repos/{owner}/{repo}/branches/{branch}", ) return True except APIError: return False async def get_branch( self, owner: str, repo: str, branch: str, ) -> dict[str, Any] | None: """Get branch information.""" return await self._request( "GET", f"/repos/{owner}/{repo}/branches/{branch}", ) # Comment operations async def add_pr_comment( self, owner: str, repo: str, pr_number: int, body: str, ) -> dict[str, Any]: """Add a comment to a pull request.""" result = await self._request( "POST", f"/repos/{owner}/{repo}/issues/{pr_number}/comments", json={"body": body}, ) return result or {} async def list_pr_comments( self, owner: str, repo: str, pr_number: int, ) -> list[dict[str, Any]]: """List comments on a pull request.""" result = await self._request( "GET", f"/repos/{owner}/{repo}/issues/{pr_number}/comments", ) return result or [] # Label operations async def add_labels( self, owner: str, repo: str, pr_number: int, labels: list[str], ) -> list[str]: """Add labels to a pull request.""" # First, get or create label IDs label_ids = [] for label_name in labels: label_id = await self._get_or_create_label(owner, repo, label_name) if label_id: label_ids.append(label_id) if label_ids: await self._request( "POST", f"/repos/{owner}/{repo}/issues/{pr_number}/labels", json={"labels": label_ids}, ) # Return current labels issue = await self._request( "GET", f"/repos/{owner}/{repo}/issues/{pr_number}", ) if issue: return [lbl["name"] for lbl in issue.get("labels", [])] return labels async def remove_label( self, owner: str, repo: str, pr_number: int, label: str, ) -> list[str]: """Remove a label from a pull request.""" # Get label ID label_info = await self._request( "GET", f"/repos/{owner}/{repo}/labels?name={label}", ) if label_info and len(label_info) > 0: label_id = label_info[0]["id"] await self._request( "DELETE", f"/repos/{owner}/{repo}/issues/{pr_number}/labels/{label_id}", ) # Return remaining labels issue = await self._request( "GET", f"/repos/{owner}/{repo}/issues/{pr_number}", ) if issue: return [lbl["name"] for lbl in issue.get("labels", [])] return [] async def _get_or_create_label( self, owner: str, repo: str, label_name: str, ) -> int | None: """Get or create a label and return its ID.""" # Try to find existing label labels = await self._request( "GET", f"/repos/{owner}/{repo}/labels", ) if labels: for label in labels: if label["name"].lower() == label_name.lower(): return label["id"] # Create new label with default color try: result = await self._request( "POST", f"/repos/{owner}/{repo}/labels", json={ "name": label_name, "color": "#3B82F6", # Default blue }, ) if result: return result["id"] except APIError: pass return None # Reviewer operations async def request_review( self, owner: str, repo: str, pr_number: int, reviewers: list[str], ) -> list[str]: """Request review from users.""" await self._request( "POST", f"/repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers", json={"reviewers": reviewers}, ) return reviewers # Helper methods def _parse_pr(self, data: dict[str, Any]) -> PRInfo: """Parse PR API response into PRInfo.""" # Parse dates created_at = self._parse_datetime(data.get("created_at")) updated_at = self._parse_datetime(data.get("updated_at")) merged_at = self._parse_datetime(data.get("merged_at")) closed_at = self._parse_datetime(data.get("closed_at")) # Determine state if data.get("merged"): state = PRState.MERGED elif data.get("state") == "closed": state = PRState.CLOSED else: state = PRState.OPEN # Extract labels labels = [lbl["name"] for lbl in data.get("labels", [])] # Extract assignees assignees = [a["login"] for a in data.get("assignees", [])] # Extract reviewers reviewers = [] if "requested_reviewers" in data: reviewers = [r["login"] for r in data["requested_reviewers"]] return PRInfo( number=data["number"], title=data["title"], body=data.get("body", ""), state=state, source_branch=data.get("head", {}).get("ref", ""), target_branch=data.get("base", {}).get("ref", ""), author=data.get("user", {}).get("login", ""), created_at=created_at, updated_at=updated_at, merged_at=merged_at, closed_at=closed_at, url=data.get("html_url"), labels=labels, assignees=assignees, reviewers=reviewers, mergeable=data.get("mergeable"), draft=data.get("draft", False), ) def _parse_datetime(self, value: str | None) -> datetime: """Parse datetime string from API.""" if not value: return datetime.now(UTC) try: # Handle Gitea's datetime format if value.endswith("Z"): value = value[:-1] + "+00:00" return datetime.fromisoformat(value) except ValueError: return datetime.now(UTC)