feat(gitea): API routes + per-user client + repo contents
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

GiteaClient:
- __init__(user_token=None) — per-user OAuth token ou admin token
- get_repo_contents(owner, repo, path) — lazy: un niveau à la fois
- get_file_content(owner, repo, path) — base64 décodé
- create_or_update_file(...) — PUT avec sha pour update
- delete_file(owner, repo, path, sha, message)
- _invalidate_tree_cache() — invalidation après write

Routes (/api/gitea):
- GET /orgs — liste des organisations
- GET /projects?org=x — repos user ou org
- GET /projects/{owner}/{repo}/tree?path=x — arborescence lazy
- GET /projects/{owner}/{repo}/file?path=x — contenu fichier
- PUT /projects/{owner}/{repo}/file — save + commit
- DELETE /projects/{owner}/{repo}/file?path=x&sha=x — delete
- GET /projects/{owner}/{repo}/labels — labels → tags

Helper: get_user_gitea_client(request) → GiteaClient ou None
This commit is contained in:
2026-07-13 14:50:46 -04:00
parent 5d1857941e
commit 9f667ae9e0
3 changed files with 252 additions and 3 deletions
+120 -3
View File
@@ -13,11 +13,16 @@ logger = logging.getLogger(__name__)
class GiteaClient:
"""Async Gitea API v1 client with simple TTL cache."""
"""Async Gitea API v1 client with simple TTL cache.
def __init__(self) -> None:
Can be instantiated with a per-user token (OAuth) or falls back
to the server-wide admin token.
"""
def __init__(self, user_token: str | None = None) -> None:
self._base = f"{settings.gitea_url}/api/v1"
self._headers = {"Authorization": f"token {settings.gitea_token}"}
token = user_token or settings.gitea_token
self._headers = {"Authorization": f"token {token}"}
self._cache: dict[str, tuple[datetime, Any]] = {}
self._ttl = timedelta(seconds=settings.gitea_cache_ttl)
@@ -298,3 +303,115 @@ class GiteaClient:
# ── singleton ──
gitea = GiteaClient()
# ── repo contents ──
async def get_repo_contents(self, owner: str, repo: str, path: str = "") -> list[dict]:
"""Get contents of a repo directory (lazy: one level)."""
url = f"{self._base}/repos/{owner}/{repo}/contents"
if path:
url += f"/{path}"
cache_key = f"contents:{owner}:{repo}:{path}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, headers=self._headers)
resp.raise_for_status()
data = resp.json()
# Ensure data is a list (single file returns dict)
if isinstance(data, dict):
data = [data]
self._set_cache(cache_key, data)
return data
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
"""Get raw file content (decoded from base64)."""
import base64
cache_key = f"file:{owner}:{repo}:{path}"
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}/contents/{path}",
headers=self._headers,
)
resp.raise_for_status()
item = resp.json()
content = ""
if item.get("encoding") == "base64" and item.get("content"):
try:
content = base64.b64decode(item["content"]).decode("utf-8")
except Exception:
content = "[binary file]"
self._set_cache(cache_key, content)
return content
async def create_or_update_file(
self, owner: str, repo: str, path: str, content: str, message: str, sha: str | None = None
) -> dict:
"""Create or update a file in the repo. Requires `sha` for updates."""
import base64
body = {
"content": base64.b64encode(content.encode("utf-8")).decode("utf-8"),
"message": message,
}
if sha:
body["sha"] = sha
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.put(
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers,
json=body,
)
resp.raise_for_status()
data = resp.json()
# Invalidate caches
parent = "/".join(path.split("/")[:-1])
for p in (parent, path.rsplit("/", 1)[0] if "/" in path else ""):
self._cache.pop(f"contents:{owner}:{repo}:{p}", None)
self._cache.pop(f"file:{owner}:{repo}:{path}", None)
return data
async def delete_file(self, owner: str, repo: str, path: str, sha: str, message: str) -> dict:
"""Delete a file from the repo."""
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.delete(
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers,
json={"message": message, "sha": sha},
)
resp.raise_for_status()
parent = "/".join(path.split("/")[:-1])
for p in (parent, path.rsplit("/", 1)[0] if "/" in path else ""):
self._cache.pop(f"contents:{owner}:{repo}:{p}", None)
self._cache.pop(f"file:{owner}:{repo}:{path}", None)
return {}
def _invalidate_tree_cache(self, owner: str, repo: str) -> None:
"""Clear all cached contents for a repo."""
keys = [k for k in self._cache if k.startswith(f"contents:{owner}:{repo}:") or k.startswith(f"file:{owner}:{repo}:")]
for k in keys:
del self._cache[k]
# ── Helper: get per-user GiteaClient ──
def get_user_gitea_client(request) -> GiteaClient | None:
"""Return a GiteaClient authenticated with the user's OAuth token, or None."""
from app.auth.session import SessionManager
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return None
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? ORDER BY updated_at DESC LIMIT 1",
(user["id"],),
).fetchone()
if not row:
return None
return GiteaClient(user_token=row["access_token"])