feat(gitea): API routes + per-user client + repo contents
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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user