diff --git a/app/main.py b/app/main.py index 7a1a1dc..f7203bf 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/routers/gitea.py b/app/routers/gitea.py new file mode 100644 index 0000000..cde68e2 --- /dev/null +++ b/app/routers/gitea.py @@ -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) diff --git a/app/services/gitea_client.py b/app/services/gitea_client.py index 649435a..add4e5e 100644 --- a/app/services/gitea_client.py +++ b/app/services/gitea_client.py @@ -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"])