"""FlowDeck — Gitea API client with caching.""" from __future__ import annotations import logging from datetime import datetime, timedelta from typing import Any import httpx from app.config import settings logger = logging.getLogger(__name__) class GiteaClient: """Async Gitea API v1 client with simple TTL cache.""" def __init__(self) -> None: self._base = f"{settings.gitea_url}/api/v1" self._headers = {"Authorization": f"token {settings.gitea_token}"} self._cache: dict[str, tuple[datetime, Any]] = {} self._ttl = timedelta(seconds=settings.gitea_cache_ttl) # ── cache helper ── def _cached(self, key: str) -> Any | None: entry = self._cache.get(key) if entry and entry[0] > datetime.now(): return entry[1] return None def _set_cache(self, key: str, value: Any) -> None: self._cache[key] = (datetime.now() + self._ttl, value) # ── repos ── async def get_user_repos(self, page: int = 1, limit: int = 30) -> list[dict]: cache_key = f"repos:{page}:{limit}" cached = self._cached(cache_key) if cached: return cached async with httpx.AsyncClient(timeout=15) as client: resp = await client.get( f"{self._base}/user/repos", headers=self._headers, params={"page": page, "limit": limit, "sort": "updated", "order": "desc"}, ) resp.raise_for_status() data = resp.json() self._set_cache(cache_key, data) return data async def get_org_repos(self, org: str, page: int = 1, limit: int = 20) -> list[dict]: cache_key = f"org_repos:{org}:{page}:{limit}" cached = self._cached(cache_key) if cached: return cached async with httpx.AsyncClient(timeout=15) as client: resp = await client.get( f"{self._base}/orgs/{org}/repos", headers=self._headers, params={"page": page, "limit": limit}, ) resp.raise_for_status() data = resp.json() self._set_cache(cache_key, data) return data async def get_user_orgs(self) -> list[dict]: cache_key = "user_orgs" cached = self._cached(cache_key) if cached: return cached async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{self._base}/user/orgs", headers=self._headers ) resp.raise_for_status() data = resp.json() self._set_cache(cache_key, data) return data # ── issues ── async def get_issues( self, owner: str, repo: str, state: str = "all", page: int = 1, limit: int = 50 ) -> list[dict]: cache_key = f"issues:{owner}:{repo}:{state}:{page}:{limit}" cached = self._cached(cache_key) if cached: return cached async with httpx.AsyncClient(timeout=15) as client: resp = await client.get( f"{self._base}/repos/{owner}/{repo}/issues", headers=self._headers, params={"state": state, "page": page, "limit": limit}, ) resp.raise_for_status() data = resp.json() self._set_cache(cache_key, data) return data async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}", headers=self._headers, ) resp.raise_for_status() return resp.json() async def create_issue( self, owner: str, repo: str, title: str, body: str = "", labels: list[int] | None = None, milestone: int | None = None, assignee: str = "", ) -> dict: """Create a new issue in Gitea.""" payload: dict[str, Any] = {"title": title, "body": body} if labels: payload["labels"] = labels if milestone: payload["milestone"] = milestone if assignee: payload["assignees"] = [assignee] self._invalidate_issue_cache(owner, repo) async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( f"{self._base}/repos/{owner}/{repo}/issues", headers=self._headers, json=payload, ) resp.raise_for_status() return resp.json() async def update_issue( self, owner: str, repo: str, issue_id: int, **kwargs ) -> dict: """Update an issue (title, body, state, labels, milestone, assignees).""" self._invalidate_issue_cache(owner, repo) async with httpx.AsyncClient(timeout=10) as client: resp = await client.patch( f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}", headers=self._headers, json=kwargs, ) resp.raise_for_status() return resp.json() async def update_issue_labels( self, owner: str, repo: str, issue_id: int, labels: list[int] ) -> list[dict]: """Replace all labels on an issue (PUT endpoint).""" self._invalidate_issue_cache(owner, repo) async with httpx.AsyncClient(timeout=10) as client: resp = await client.put( f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/labels", headers=self._headers, json={"labels": labels}, ) resp.raise_for_status() return resp.json() async def close_issue(self, owner: str, repo: str, issue_id: int) -> dict: return await self.update_issue(owner, repo, issue_id, state="closed") async def reopen_issue(self, owner: str, repo: str, issue_id: int) -> dict: return await self.update_issue(owner, repo, issue_id, state="open") async def get_issue_comments( self, owner: str, repo: str, issue_id: int ) -> list[dict]: """Get comments for an issue.""" async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments", headers=self._headers, ) resp.raise_for_status() return resp.json() def _invalidate_issue_cache(self, owner: str, repo: str) -> None: prefix = f"issues:{owner}:{repo}:" to_delete = [k for k in self._cache if k.startswith(prefix)] for k in to_delete: del self._cache[k] # ── labels ── async def get_labels(self, owner: str, repo: str) -> list[dict]: cache_key = f"labels:{owner}:{repo}" cached = self._cached(cache_key) if cached: return cached async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{self._base}/repos/{owner}/{repo}/labels", headers=self._headers, ) resp.raise_for_status() data = resp.json() self._set_cache(cache_key, data) return data # ── milestones ── async def get_milestones( self, owner: str, repo: str, state: str = "open" ) -> list[dict]: cache_key = f"milestones:{owner}:{repo}:{state}" cached = self._cached(cache_key) if cached: return cached async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{self._base}/repos/{owner}/{repo}/milestones", headers=self._headers, params={"state": state}, ) resp.raise_for_status() data = resp.json() self._set_cache(cache_key, data) return data # ── webhooks ── async def list_webhooks(self, owner: str, repo: str) -> list[dict]: """List webhooks for a repo.""" async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{self._base}/repos/{owner}/{repo}/hooks", headers=self._headers, ) resp.raise_for_status() return resp.json() async def create_webhook( self, owner: str, repo: str, url: str, secret: str, events: list[str] | None = None, ) -> dict: """Create a webhook for a repo.""" if events is None: events = ["issues", "pull_request", "repository", "issue_comment"] payload = { "type": "gitea", "config": { "url": url, "content_type": "json", "secret": secret, }, "events": events, "active": True, } async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( f"{self._base}/repos/{owner}/{repo}/hooks", headers=self._headers, json=payload, ) resp.raise_for_status() return resp.json() async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool: """Delete a webhook.""" async with httpx.AsyncClient(timeout=10) as client: resp = await client.delete( f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}", headers=self._headers, ) return resp.status_code == 204 # ── assignees/collaborators ── async def get_collaborators(self, owner: str, repo: str) -> list[dict]: """List repo collaborators.""" cache_key = f"collaborators:{owner}:{repo}" cached = self._cached(cache_key) if cached: return cached async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"{self._base}/repos/{owner}/{repo}/collaborators", headers=self._headers, ) resp.raise_for_status() data = resp.json() self._set_cache(cache_key, data) return data # ── singleton ── gitea = GiteaClient()