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.db import init_db
|
||||||
from app.middleware.csrf import CSRFMiddleware
|
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 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.gitea_client import gitea
|
||||||
from app.services.webhook_outbound import init_webhook_tables
|
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(my_tasks.router)
|
||||||
app.include_router(workspace.router)
|
app.include_router(workspace.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
app.include_router(gitea_router)
|
||||||
app.include_router(public_api.router)
|
app.include_router(public_api.router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
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:
|
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._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._cache: dict[str, tuple[datetime, Any]] = {}
|
||||||
self._ttl = timedelta(seconds=settings.gitea_cache_ttl)
|
self._ttl = timedelta(seconds=settings.gitea_cache_ttl)
|
||||||
|
|
||||||
@@ -298,3 +303,115 @@ class GiteaClient:
|
|||||||
# ── singleton ──
|
# ── singleton ──
|
||||||
|
|
||||||
gitea = GiteaClient()
|
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