import httpx import json import base64 from typing import Any, Optional from .config import GITEA_URL, GITEA_TOKEN from .models import ( UserModel, LabelModel, RepositoryModel, IssueModel, PullRequestModel, CommentModel, PullRequestFileModel, ) from core.interfaces import ( IssuesClient, PullRequestsClient, FilesClient, RefsClient, ReposClient, ) class GiteaClient(IssuesClient, PullRequestsClient, FilesClient, RefsClient, ReposClient): """HTTP client for Gitea API v1.""" def __init__(self) -> None: self.base_url: str = GITEA_URL.rstrip("/") self.headers: dict[str, str] = { "Authorization": f"token {GITEA_TOKEN}", "Accept": "application/json", } def get_authenticated_user(self) -> UserModel | None: try: with httpx.Client() as client: response = client.get(f"{self.base_url}/api/v1/user", headers=self.headers) response.raise_for_status() return UserModel(**response.json()) except Exception as e: print(f"Error getting authenticated user: {e}") return None def list_all_user_repos(self) -> list[RepositoryModel]: try: with httpx.Client() as client: url = f"{self.base_url}/api/v1/user/repos" response = client.get(url, headers=self.headers) response.raise_for_status() repos: list[dict[str, Any]] = response.json() # Filter to ONLY meeks organization repos, include mirrors seen: set[str] = set() result: list[RepositoryModel] = [] for r in repos: full_name = r.get("full_name", "") if full_name and full_name not in seen and (r.get("owner") or {}).get("login") == "meeks": seen.add(full_name) result.append(RepositoryModel(**r)) return result except Exception as e: print(f"Error listing user repos: {e}") return [] def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}" response = client.get(url, headers=self.headers) response.raise_for_status() return [IssueModel(**item) for item in response.json()] def list_repo_pull_requests(self, owner: str, repo: str, state: str = "open") -> list[PullRequestModel]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}" response = client.get(url, headers=self.headers) if response.status_code == 404: return [] response.raise_for_status() return [PullRequestModel(**item) for item in response.json()] def get_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}" response = client.get(url, headers=self.headers) response.raise_for_status() return PullRequestModel(**response.json()) def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}" response = client.get(url, headers=self.headers) response.raise_for_status() return IssueModel(**response.json()) def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}" data: dict[str, str] = {"state": "closed"} response = client.patch(url, headers=self.headers, json=data) response.raise_for_status() return IssueModel(**response.json()) def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}" data: dict[str, str] = {"state": "closed"} response = client.patch(url, headers=self.headers, json=data) response.raise_for_status() return PullRequestModel(**response.json()) def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments" response = client.get(url, headers=self.headers) response.raise_for_status() return [CommentModel(**item) for item in response.json()] def get_pull_request_comments(self, owner: str, repo: str, pull_number: int) -> list[CommentModel]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pull_number}/comments" response = client.get(url, headers=self.headers) response.raise_for_status() return [CommentModel(**item) for item in response.json()] def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/diff" response = client.get(url, headers=self.headers) response.raise_for_status() return response.text def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/patch" response = client.get(url, headers=self.headers) response.raise_for_status() return response.text def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/files" response = client.get(url, headers=self.headers) response.raise_for_status() return [PullRequestFileModel(**item) for item in response.json()] def list_assigned_issues(self, owner: str = "", repo: str = "") -> list[IssueModel]: try: user = self.get_authenticated_user() if not user: return [] username: str = user.login if owner and repo: response = httpx.get( f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?assignee={username}&state=open&type=issues", headers=self.headers, ) response.raise_for_status() return [IssueModel(**item) for item in response.json()] all_issues: list[IssueModel] = [] repos = self.list_all_user_repos() for r in repos: repo_owner = r.owner if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "") repo_name = r.name if hasattr(r, 'name') else r.get("name", "") resp = httpx.get( f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/issues?assignee={username}&state=open&type=issues", headers=self.headers, ) if resp.status_code == 200: for item in resp.json(): issue = IssueModel(**item) # Backfill repository if Gitea omitted it if issue.repository is None: issue = issue.model_copy(update={"repository": r}) all_issues.append(issue) return all_issues except Exception as e: print(f"DEBUG: list_assigned_issues error: {e}") return [] def list_assigned_pull_requests(self, owner: str = "", repo: str = "") -> list[PullRequestModel]: """List all pull requests assigned to or authored by the authenticated user.""" try: user = self.get_authenticated_user() if not user: return [] username: str = user.login if owner and repo: response = httpx.get( f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state=open", headers=self.headers, ) if response.status_code == 404: return [] response.raise_for_status() all_prs: list[PullRequestModel] = [PullRequestModel(**pr) for pr in response.json()] return [ pr for pr in all_prs if (pr.assignee and pr.assignee.login == username) or (pr.user and pr.user.login == username) ] all_prs: list[PullRequestModel] = [] repos = self.list_all_user_repos() for r in repos: repo_owner = r.owner if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "") repo_name = r.name if hasattr(r, 'name') else r.get("name", "") resp = httpx.get( f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/pulls?state=open", headers=self.headers, ) if resp.status_code == 200: for pr_data in resp.json(): pr = PullRequestModel(**pr_data) if (pr.assignee and pr.assignee.login == username) or (pr.user and pr.user.login == username): # Backfill repository if Gitea omitted it if pr.repository is None: pr = pr.model_copy(update={"repository": r}) all_prs.append(pr) return all_prs except Exception as e: print(f"DEBUG: list_assigned_pull_requests error: {e}") return [] def create_pull_request( self, owner: str, repo: str, head: str, base: str, title: str, description: str = "" ) -> PullRequestModel: try: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls" data: dict[str, str] = { "title": title, "body": description, "head": head, "base": base, } response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return PullRequestModel(**response.json()) except Exception as e: print(f"Error creating pull request: {e}") raise def update_pull_request( self, owner: str, repo: str, pull_number: int, title: str | None = None, body: str | None = None, state: str | None = None, ) -> PullRequestModel: try: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}" 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: data["state"] = state response = client.patch(url, headers=self.headers, json=data) response.raise_for_status() return PullRequestModel(**response.json()) except Exception as e: print(f"Error updating pull request: {e}") raise def create_pr_via_tea( self, owner: str, repo: str, title: str, description: str, head: str, base: str ) -> PullRequestModel: return self.create_pull_request(owner, repo, head, base, title, description) def approve_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews" data: dict[str, Any] = {"event": "APPROVED", "body": comment} response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return response.json() def request_changes_pr(self, owner: str, repo: str, pr_number: int, comment: str) -> dict[str, Any]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews" data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment} response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return response.json() def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews" response = client.get(url, headers=self.headers) if response.status_code == 404: return [] response.raise_for_status() return response.json() def dismiss_review_pr( self, owner: str, repo: str, pr_number: int, review_id: int, message: str ) -> dict[str, Any]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/dismissals" data: dict[str, str] = {"message": message} response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return response.json() def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}" data: dict[str, list[str]] = {"assignees": [username]} response = client.patch(url, headers=self.headers, json=data) response.raise_for_status() return IssueModel(**response.json()) def create_issue( self, owner: str, repo: str, title: str, body: str, labels: list[str] | None = None, assignees: list[str] | None = None, ) -> IssueModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues" data: dict[str, Any] = {"title": title, "body": body} if labels: data["labels"] = labels if assignees: data["assignees"] = assignees response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return IssueModel(**response.json()) def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments" data: dict[str, str] = {"body": body} response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return CommentModel(**response.json()) def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/labels" data: list[str] = [label] response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return LabelModel(**response.json()) def add_label_pr(self, owner: str, repo: str, pr_number: int, label: str) -> LabelModel: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pr_number}/labels" data: list[str] = [label] response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return LabelModel(**response.json()) def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/ref/{ref}" data: dict[str, str] = {"sha": sha} response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return response.json() def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/refs" data: dict[str, str] = {"ref": ref, "sha": sha} response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return response.json() def update_file( self, owner: str, repo: str, path: str, message: str, content: str, branch: str ) -> dict[str, Any]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}" data: dict[str, str] = { "message": message, "content": base64.b64encode(content.encode()).decode(), "branch": branch, "new_branch": f"{branch}-update-{path}", } response = client.put(url, headers=self.headers, json=data) response.raise_for_status() return response.json() def get_file_content(self, owner: str, repo: str, path: str, ref: str = "master") -> str | list[str]: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}" params: dict[str, str] = {"ref": ref} response = client.get(url, headers=self.headers, params=params) response.raise_for_status() data = response.json() if isinstance(data, list): return [item.get("content", "") for item in data if item.get("type") == "file"] return base64.b64decode(data.get("content", "")).decode() if data.get("content") else "" def list_unread_notifications(self, since: Optional[str] = None) -> list[dict[str, Any]]: try: with httpx.Client() as client: url = f"{self.base_url}/api/v1/notifications?all=false" if since: url += f"&since={since}" response = client.get(url, headers=self.headers) response.raise_for_status() notifications: list[dict[str, Any]] = response.json() result: list[dict[str, Any]] = [] for n in notifications: repo_info = n.get("repository") or {} owner_info = repo_info.get("owner") or {} owner_login = owner_info.get("login", "") if owner_login == "meeks": result.append(n) return result except Exception as e: print(f"Error listing unread notifications: {e}") return [] def mark_notification_as_read(self, thread_id: int) -> bool: try: with httpx.Client() as client: url = f"{self.base_url}/api/v1/notifications/threads/{thread_id}" response = client.patch(url, headers=self.headers) response.raise_for_status() return True except Exception as e: print(f"Error marking notification thread {thread_id} as read: {e}") return False def merge_pull_request( self, owner: str, repo: str, pull_number: int, style: str = "squash", title: str = "", message: str = "" ) -> bool: try: with httpx.Client() as client: url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/merge" data: dict[str, Any] = { "Do": style, "MergeTitleField": title, "MergeMessageField": message, } response = client.post(url, headers=self.headers, json=data) response.raise_for_status() return True except Exception as e: print(f"Error merging pull request {pull_number}: {e}") raise