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
+2
View File
@@ -13,6 +13,7 @@ from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api, admin
from app.routers.gitea import router as gitea_router
from app.services.gitea_client import gitea
from app.services.webhook_outbound import init_webhook_tables
@@ -59,6 +60,7 @@ app.include_router(collections.router)
app.include_router(my_tasks.router)
app.include_router(workspace.router)
app.include_router(admin.router)
app.include_router(gitea_router)
app.include_router(public_api.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
+130
View File
@@ -0,0 +1,130 @@
"""FlowDeck — Gitea integration API routes."""
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
if not client:
raise HTTPException(status_code=401, detail="Gitea account not linked. Login with Gitea or connect in Settings.")
return client
# ── Orgs ──
@router.get("/orgs")
async def list_orgs(request: Request):
"""List organizations the user belongs to."""
gitea = _require_gitea(request)
try:
orgs = await gitea.get_user_orgs()
return {"orgs": orgs}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Projects (repos) ──
@router.get("/projects")
async def list_projects(request: Request, org: str = ""):
"""List Gitea repos: user repos (org='') or org repos."""
gitea = _require_gitea(request)
try:
if org:
repos = await gitea.get_org_repos(org)
else:
repos = await gitea.get_user_repos()
return {"projects": repos}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Repo tree ──
@router.get("/projects/{owner}/{repo}/tree")
async def repo_tree(owner: str, repo: str, path: str = "", request: Request = None):
"""Get contents of a repo directory (one level)."""
gitea = _require_gitea(request)
try:
items = await gitea.get_repo_contents(owner, repo, path)
tree = []
for item in items:
tree.append({
"name": item.get("name"),
"path": item.get("path"),
"type": "folder" if item.get("type") == "dir" else "file",
"size": item.get("size", 0),
"sha": item.get("sha"),
})
return {"tree": tree}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── File content ──
@router.get("/projects/{owner}/{repo}/file")
async def get_file(owner: str, repo: str, path: str, request: Request = None):
"""Get file content (decoded)."""
gitea = _require_gitea(request)
try:
content = await gitea.get_file_content(owner, repo, path)
return {"content": content, "path": path}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Save file (create or update) ──
@router.put("/projects/{owner}/{repo}/file")
async def save_file(owner: str, repo: str, request: Request):
"""Create or update a file in the repo."""
import json
gitea = _require_gitea(request)
try:
body = await request.json()
except Exception:
body = {}
path = body.get("path", "").strip("/")
content = body.get("content", "")
message = body.get("message", "Update via FlowDeck")
sha = body.get("sha")
if not path:
return JSONResponse({"error": "Path required"}, status_code=400)
try:
result = await gitea.create_or_update_file(owner, repo, path, content, message, sha)
return {"status": "ok", "commit": result.get("commit", {})}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Delete file ──
@router.delete("/projects/{owner}/{repo}/file")
async def delete_file(owner: str, repo: str, request: Request):
"""Delete a file from the repo."""
import json
gitea = _require_gitea(request)
path = request.query_params.get("path", "")
sha = request.query_params.get("sha", "")
message = request.query_params.get("message", "Delete via FlowDeck")
if not path or not sha:
return JSONResponse({"error": "Path and SHA required"}, status_code=400)
try:
await gitea.delete_file(owner, repo, path, sha, message)
return {"status": "ok"}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
# ── Labels ──
@router.get("/projects/{owner}/{repo}/labels")
async def get_labels(owner: str, repo: str, request: Request = None):
"""Get labels for a repo (mapped to tags)."""
gitea = _require_gitea(request)
try:
labels = await gitea.get_labels(owner, repo)
return {"labels": [
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
for l in labels
]}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
+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"])