""" Gitea API Client — typed, sovereign, zero-dependency. Connects Hermes to Timmy's sovereign Gitea instance for: - Issue tracking (create, list, comment, label) - Pull request management (create, list, review, merge) - File operations (read, create, update) - Branch management (create, delete) Design principles: - Zero pip dependencies — uses only urllib (stdlib) - Retry with random jitter on 429/5xx (same pattern as SessionDB) - Pagination-aware: all list methods return complete results - Defensive None handling on all response fields - Rate-limit aware: backs off on 429, never hammers the server This client is the foundation for: - graph_store.py (knowledge persistence) - knowledge_ingester.py (session ingestion) - tasks.py orchestration (timmy-home) - Playbook engine (dpo-trainer, pr-reviewer, etc.) Usage: client = GiteaClient() issues = client.list_issues("Timmy_Foundation/the-nexus", state="open") client.create_issue_comment("Timmy_Foundation/the-nexus", 42, "Fixed in PR #102") """ from __future__ import annotations import json import logging import os import random import time import urllib.request import urllib.error import urllib.parse from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) # ── Retry configuration ────────────────────────────────────────────── # Same jitter pattern as SessionDB._execute_write: random backoff # to avoid convoy effects when multiple agents hit the API. _MAX_RETRIES = 4 _RETRY_MIN_S = 0.5 _RETRY_MAX_S = 2.0 _RETRYABLE_CODES = frozenset({429, 500, 502, 503, 504}) _DEFAULT_TIMEOUT = 30 _DEFAULT_PAGE_LIMIT = 50 # Gitea's max per page class GiteaError(Exception): """Raised when the Gitea API returns an error.""" def __init__(self, status_code: int, message: str, url: str = ""): self.status_code = status_code self.url = url super().__init__(f"Gitea {status_code}: {message}") class GiteaClient: """Sovereign Gitea API client with retry, pagination, and defensive handling.""" def __init__( self, base_url: Optional[str] = None, token: Optional[str] = None, timeout: int = _DEFAULT_TIMEOUT, ): self.base_url = ( base_url or os.environ.get("GITEA_URL", "") or _load_token_config().get("url", "http://localhost:3000") ) self.token = ( token or os.environ.get("GITEA_TOKEN", "") or _load_token_config().get("token", "") ) self.api = f"{self.base_url.rstrip('/')}/api/v1" self.timeout = timeout # ── Core HTTP ──────────────────────────────────────────────────── def _request( self, method: str, path: str, data: Optional[dict] = None, params: Optional[dict] = None, ) -> Any: """Make an authenticated API request with retry on transient errors. Returns parsed JSON response. Raises GiteaError on non-retryable failures. """ url = f"{self.api}{path}" if params: query = urllib.parse.urlencode( {k: v for k, v in params.items() if v is not None} ) url = f"{url}?{query}" body = json.dumps(data).encode() if data else None last_err: Optional[Exception] = None for attempt in range(_MAX_RETRIES): req = urllib.request.Request(url, data=body, method=method) if self.token: req.add_header("Authorization", f"token {self.token}") req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json") try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: raw = resp.read().decode() return json.loads(raw) if raw.strip() else {} except urllib.error.HTTPError as e: status = e.code err_body = "" try: err_body = e.read().decode() except Exception: pass if status in _RETRYABLE_CODES and attempt < _MAX_RETRIES - 1: jitter = random.uniform(_RETRY_MIN_S, _RETRY_MAX_S) logger.debug( "Gitea %d on %s %s, retry %d/%d in %.1fs", status, method, path, attempt + 1, _MAX_RETRIES, jitter, ) last_err = GiteaError(status, err_body, url) time.sleep(jitter) continue raise GiteaError(status, err_body, url) from e except (urllib.error.URLError, TimeoutError, OSError) as e: if attempt < _MAX_RETRIES - 1: jitter = random.uniform(_RETRY_MIN_S, _RETRY_MAX_S) logger.debug( "Gitea connection error on %s %s: %s, retry %d/%d", method, path, e, attempt + 1, _MAX_RETRIES, ) last_err = e time.sleep(jitter) continue raise raise last_err or GiteaError(0, "Max retries exceeded") def _paginate( self, path: str, params: Optional[dict] = None, max_items: int = 200, ) -> List[dict]: """Fetch all pages of a paginated endpoint. Gitea uses `page` + `limit` query params. This method fetches pages until we get fewer items than the limit, or hit max_items. """ params = dict(params or {}) params.setdefault("limit", _DEFAULT_PAGE_LIMIT) page = 1 all_items: List[dict] = [] while len(all_items) < max_items: params["page"] = page items = self._request("GET", path, params=params) if not isinstance(items, list): break all_items.extend(items) if len(items) < params["limit"]: break # Last page page += 1 return all_items[:max_items] # ── File operations (existing API) ─────────────────────────────── def get_file( self, repo: str, path: str, ref: str = "main" ) -> Dict[str, Any]: """Get file content and metadata from a repository.""" return self._request( "GET", f"/repos/{repo}/contents/{path}", params={"ref": ref}, ) def create_file( self, repo: str, path: str, content: str, message: str, branch: str = "main", ) -> Dict[str, Any]: """Create a new file in a repository. Args: content: Base64-encoded file content message: Commit message """ return self._request( "POST", f"/repos/{repo}/contents/{path}", data={"branch": branch, "content": content, "message": message}, ) def update_file( self, repo: str, path: str, content: str, message: str, sha: str, branch: str = "main", ) -> Dict[str, Any]: """Update an existing file in a repository. Args: content: Base64-encoded file content sha: SHA of the file being replaced (for conflict detection) """ return self._request( "PUT", f"/repos/{repo}/contents/{path}", data={ "branch": branch, "content": content, "message": message, "sha": sha, }, ) # ── Issues ─────────────────────────────────────────────────────── def list_issues( self, repo: str, state: str = "open", labels: Optional[str] = None, sort: str = "updated", direction: str = "desc", limit: int = 50, ) -> List[dict]: """List issues in a repository. Args: state: "open", "closed", or "all" labels: Comma-separated label names sort: "created", "updated", "comments" direction: "asc" or "desc" """ params = { "state": state, "type": "issues", "sort": sort, "direction": direction, } if labels: params["labels"] = labels return self._paginate( f"/repos/{repo}/issues", params=params, max_items=limit, ) def get_issue(self, repo: str, number: int) -> Dict[str, Any]: """Get a single issue by number.""" return self._request("GET", f"/repos/{repo}/issues/{number}") def create_issue( self, repo: str, title: str, body: str = "", labels: Optional[List[int]] = None, assignees: Optional[List[str]] = None, ) -> Dict[str, Any]: """Create a new issue.""" data: Dict[str, Any] = {"title": title, "body": body} if labels: data["labels"] = labels if assignees: data["assignees"] = assignees return self._request("POST", f"/repos/{repo}/issues", data=data) def create_issue_comment( self, repo: str, number: int, body: str ) -> Dict[str, Any]: """Add a comment to an issue or pull request.""" return self._request( "POST", f"/repos/{repo}/issues/{number}/comments", data={"body": body}, ) def list_issue_comments( self, repo: str, number: int, limit: int = 50, ) -> List[dict]: """List comments on an issue or pull request.""" return self._paginate( f"/repos/{repo}/issues/{number}/comments", max_items=limit, ) def find_unassigned_issues( self, repo: str, state: str = "open", exclude_labels: Optional[List[str]] = None, ) -> List[dict]: """Find issues with no assignee. Defensively handles None assignees (Gitea sometimes returns null for the assignees list on issues that were created without one). """ issues = self.list_issues(repo, state=state, limit=100) unassigned = [] for issue in issues: assignees = issue.get("assignees") or [] # None → [] if not assignees: # Check exclude_labels if exclude_labels: issue_labels = { (lbl.get("name") or "").lower() for lbl in (issue.get("labels") or []) } if issue_labels & {l.lower() for l in exclude_labels}: continue unassigned.append(issue) return unassigned # ── Pull Requests ──────────────────────────────────────────────── def list_pulls( self, repo: str, state: str = "open", sort: str = "updated", direction: str = "desc", limit: int = 50, ) -> List[dict]: """List pull requests in a repository.""" return self._paginate( f"/repos/{repo}/pulls", params={"state": state, "sort": sort, "direction": direction}, max_items=limit, ) def get_pull(self, repo: str, number: int) -> Dict[str, Any]: """Get a single pull request by number.""" return self._request("GET", f"/repos/{repo}/pulls/{number}") def create_pull( self, repo: str, title: str, head: str, base: str = "main", body: str = "", ) -> Dict[str, Any]: """Create a new pull request.""" return self._request( "POST", f"/repos/{repo}/pulls", data={"title": title, "head": head, "base": base, "body": body}, ) def get_pull_diff(self, repo: str, number: int) -> str: """Get the diff for a pull request as plain text. Returns the raw diff string. Useful for code review and the destructive-PR detector in tasks.py. """ url = f"{self.api}/repos/{repo}/pulls/{number}.diff" req = urllib.request.Request(url, method="GET") if self.token: req.add_header("Authorization", f"token {self.token}") req.add_header("Accept", "text/plain") try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: return resp.read().decode() except urllib.error.HTTPError as e: raise GiteaError(e.code, e.read().decode(), url) from e def create_pull_review( self, repo: str, number: int, body: str, event: str = "COMMENT", ) -> Dict[str, Any]: """Submit a review on a pull request. Args: event: "APPROVE", "REQUEST_CHANGES", or "COMMENT" """ return self._request( "POST", f"/repos/{repo}/pulls/{number}/reviews", data={"body": body, "event": event}, ) def list_pull_reviews( self, repo: str, number: int ) -> List[dict]: """List reviews on a pull request.""" return self._paginate(f"/repos/{repo}/pulls/{number}/reviews") # ── Branches ───────────────────────────────────────────────────── def create_branch( self, repo: str, branch: str, old_branch: str = "main", ) -> Dict[str, Any]: """Create a new branch from an existing one.""" return self._request( "POST", f"/repos/{repo}/branches", data={ "new_branch_name": branch, "old_branch_name": old_branch, }, ) def delete_branch(self, repo: str, branch: str) -> Dict[str, Any]: """Delete a branch.""" return self._request( "DELETE", f"/repos/{repo}/branches/{branch}", ) # ── Labels ─────────────────────────────────────────────────────── def list_labels(self, repo: str) -> List[dict]: """List all labels in a repository.""" return self._paginate(f"/repos/{repo}/labels") def add_issue_labels( self, repo: str, number: int, label_ids: List[int] ) -> List[dict]: """Add labels to an issue.""" return self._request( "POST", f"/repos/{repo}/issues/{number}/labels", data={"labels": label_ids}, ) # ── Notifications ──────────────────────────────────────────────── def list_notifications( self, all_: bool = False, limit: int = 20, ) -> List[dict]: """List notifications for the authenticated user. Args: all_: Include read notifications """ params = {"limit": limit} if all_: params["all"] = "true" return self._request("GET", "/notifications", params=params) def mark_notifications_read(self) -> Dict[str, Any]: """Mark all notifications as read.""" return self._request("PUT", "/notifications") # ── Repository info ────────────────────────────────────────────── def get_repo(self, repo: str) -> Dict[str, Any]: """Get repository metadata.""" return self._request("GET", f"/repos/{repo}") def list_org_repos( self, org: str, limit: int = 50, ) -> List[dict]: """List all repositories for an organization.""" return self._paginate(f"/orgs/{org}/repos", max_items=limit) # ── Token loader ───────────────────────────────────────────────────── def _load_token_config() -> dict: """Load Gitea credentials from ~/.timmy/gemini_gitea_token or env. Returns dict with 'url' and 'token' keys. Falls back to empty strings if no config exists. """ token_file = Path.home() / ".timmy" / "gemini_gitea_token" if not token_file.exists(): return {"url": "", "token": ""} config: dict = {"url": "", "token": ""} try: for line in token_file.read_text().splitlines(): line = line.strip() if line.startswith("GITEA_URL="): config["url"] = line.split("=", 1)[1].strip().strip('"') elif line.startswith("GITEA_TOKEN="): config["token"] = line.split("=", 1)[1].strip().strip('"') except Exception: pass return config