- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, sans changer une ligne de logique. - Répartition : api_v2 60, dashboard 40, collections 25, board 23, workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers. - Les 4 routers prioritaires de l'audit sont couverts par ce lot : api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`). - Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré (rien ne l'appellerait — YAGNI jusqu'au premier usage). suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""FlowDeck — Gitea integration API routes."""
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
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.
|
|
|
|
Only returns a client if the user has personally connected their Gitea
|
|
account (OAuth token). No fallback to admin token — each user must link
|
|
their own Gitea account to see Gitea projects.
|
|
"""
|
|
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. Go to Settings → Integrations to connect.")
|
|
return client
|
|
|
|
|
|
|
|
|
|
@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(limit=100)
|
|
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(request: Request, owner: str, repo: str, path: str = ""):
|
|
"""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(request: Request, owner: str, repo: str, path: str):
|
|
"""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(request: Request, owner: str, repo: str):
|
|
"""Create or update a file in the repo."""
|
|
gitea = _require_gitea(request) # admin token can write too
|
|
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)
|
|
|
|
|
|
# ── Upload file (binary) ──
|
|
@router.post("/projects/{owner}/{repo}/upload")
|
|
async def upload_file(request: Request, owner: str, repo: str):
|
|
"""Upload a binary file to the repo."""
|
|
import base64
|
|
gitea = _require_gitea(request)
|
|
try:
|
|
form = await request.form()
|
|
except Exception:
|
|
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
|
file = form.get("file")
|
|
folder = form.get("folder", "")
|
|
message = form.get("message", "Upload via FlowDeck")
|
|
if not file:
|
|
return JSONResponse({"error": "No file provided"}, status_code=400)
|
|
try:
|
|
content = await file.read()
|
|
encoded = base64.b64encode(content).decode("utf-8")
|
|
path = f"{folder.strip('/')}/{file.filename}".strip("/") if folder.strip("/") else file.filename
|
|
result = await gitea.create_or_update_file(owner, repo, path, encoded, message, sha=None)
|
|
return {"status": "ok", "path": path, "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(request: Request, owner: str, repo: str):
|
|
"""Delete a file from the repo."""
|
|
gitea = _require_gitea(request) # admin token can write too
|
|
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(request: Request, owner: str, repo: str):
|
|
"""Get labels for a repo (mapped to tags)."""
|
|
gitea = _require_gitea(request)
|
|
try:
|
|
labels = await gitea.get_labels(owner, repo)
|
|
return {"labels": [
|
|
{"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
|
|
for lbl in labels
|
|
]}
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=502)
|
|
|
|
|
|
# ── Account linking ──
|
|
|
|
@router.get("/status")
|
|
def gitea_status(request: Request):
|
|
"""Check if the current user has Gitea linked."""
|
|
from app.services.gitea_client import get_user_gitea_client
|
|
client = get_user_gitea_client(request)
|
|
return {"linked": client is not None}
|
|
|
|
|
|
@router.delete("/disconnect")
|
|
def disconnect_gitea(request: Request):
|
|
"""Remove all Gitea OAuth tokens for the current user."""
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'", (user["id"],))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
|
|
|
@router.get("/projects/{owner}/{repo}/private-pages")
|
|
def list_private_pages(owner: str, repo: str, request: Request):
|
|
"""List private pages for this Gitea project."""
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"pages": []})
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, title, updated_at FROM gitea_private_pages WHERE user_id=? AND gitea_owner=? AND gitea_repo=? ORDER BY updated_at DESC",
|
|
(user["id"], owner, repo),
|
|
).fetchall()
|
|
return {"pages": [{"id": r["id"], "title": r["title"], "updated_at": r["updated_at"]} for r in rows]}
|
|
|
|
|
|
@router.post("/projects/{owner}/{repo}/private-pages")
|
|
async def create_private_page(owner: str, repo: str, request: Request):
|
|
"""Create a new private page for this Gitea project."""
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
title = body.get("title", "Untitled").strip() or "Untitled"
|
|
with get_conn() as conn:
|
|
cursor = conn.execute(
|
|
"INSERT INTO gitea_private_pages (user_id, gitea_owner, gitea_repo, title) VALUES (?,?,?,?)",
|
|
(user["id"], owner, repo, title),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok", "page": {"id": cursor.lastrowid, "title": title}}
|
|
|
|
|
|
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
|
|
def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
|
"""Get a single private page."""
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
|
(page_id, user["id"], owner, repo),
|
|
).fetchone()
|
|
if not row:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
return {"page": {"id": row["id"], "title": row["title"], "content": row["content"], "updated_at": row["updated_at"]}}
|
|
|
|
|
|
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
|
|
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
|
|
"""Update a private page."""
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
title = body.get("title", "").strip()
|
|
content = body.get("content", "")
|
|
with get_conn() as conn:
|
|
if title:
|
|
conn.execute(
|
|
"UPDATE gitea_private_pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
|
(title, page_id, user["id"], owner, repo),
|
|
)
|
|
conn.execute(
|
|
"UPDATE gitea_private_pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
|
(content, page_id, user["id"], owner, repo),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
|
|
def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
|
"""Delete a private page."""
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM gitea_private_pages WHERE id=? AND user_id=? AND gitea_owner=? AND gitea_repo=?",
|
|
(page_id, user["id"], owner, repo),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ── Commit history for a file ──
|
|
@router.get("/projects/{owner}/{repo}/commits")
|
|
async def file_commits(request: Request, owner: str, repo: str, path: str = ""):
|
|
"""Get recent commits for a specific file."""
|
|
gitea = _require_gitea(request)
|
|
try:
|
|
commits = await gitea.get_file_commits(owner, repo, path)
|
|
return {"commits": commits}
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=502)
|
|
|
|
|
|
# ── Sync labels to tags ──
|
|
@router.post("/projects/{owner}/{repo}/sync-labels")
|
|
async def sync_labels(request: Request, owner: str, repo: str):
|
|
"""Sync Gitea labels to FlowDeck tags for the current user."""
|
|
from app.auth.session import get_current_user as gcu
|
|
from app.db import get_conn
|
|
user = await gcu(request)
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
gitea = _require_gitea(request)
|
|
try:
|
|
labels = await gitea.get_labels(owner, repo)
|
|
except Exception:
|
|
labels = []
|
|
imported = 0
|
|
with get_conn() as conn:
|
|
for label in labels:
|
|
name = label.get("name", "")
|
|
color = label.get("color", "#787774")
|
|
if not name:
|
|
continue
|
|
existing = conn.execute(
|
|
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
|
|
).fetchone()
|
|
if not existing:
|
|
conn.execute(
|
|
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)",
|
|
(name, f"#{color}", user["id"]),
|
|
)
|
|
imported += 1
|
|
conn.commit()
|
|
return {"status": "ok", "imported": imported, "total": len(labels)}
|