Files
flowdeck/app/routers/gitea.py
T
bruno ba363eaee9
FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / lint (pull_request) Successful in 42s
FlowDeck CI / test (pull_request) Successful in 4m3s
FlowDeck CI / docker (push) Successful in 1m2s
FlowDeck CI / docker (pull_request) Successful in 35s
feat(v5.2.0): finalize Infrastructure & Polish (tests isolation, xdist, lint, CI)
tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe.
Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips.
init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan).
ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config).
CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push.
VERSION 5.11.1.
2026-09-11 23:36:53 -04:00

343 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
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."""
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")
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
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
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)}