"""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. 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 def _require_user_gitea(request: Request): """Return a per-user GiteaClient or raise 401 (required for write ops).""" 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 not connected. Go to Settings → Integrations or register with Gitea.") 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(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.""" import json 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.""" import json 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": 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) # ── Account linking ── @router.get("/status") async 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") async 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") async 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 import json 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}") async 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 import json 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}") async 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)}