"""GitHub OAuth + repository browsing (mirrors /api/gitea shape for shared UI).""" from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse router = APIRouter(tags=["github"], prefix="/api/github") def _require_github(request: Request): """Return a per-user GitHubAdapter or raise 401 (same contract as Gitea).""" from app.auth.session import SessionManager from app.db import get_conn from app.services.github_adapter import GitHubAdapter user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: raise HTTPException(status_code=401, detail="Not authenticated") with get_conn() as conn: row = conn.execute( "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' " "AND access_token IS NOT NULL AND access_token != '' ORDER BY updated_at DESC LIMIT 1", (user["id"],), ).fetchone() if not row: raise HTTPException(status_code=401, detail="GitHub account not linked. Go to Settings → Integrations to connect.") return GitHubAdapter(row["access_token"]) @router.get("/status") def github_status(request: Request): """Check if the current user has GitHub linked.""" 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 {"linked": False} with get_conn() as conn: row = conn.execute( "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' AND access_token IS NOT NULL AND access_token != ''", (user["id"],) ).fetchone() return {"linked": row is not None} @router.delete("/disconnect") def disconnect_github(request: Request): """Remove all GitHub 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='github'", (user["id"],)) conn.commit() return {"status": "ok"} # ── Repo tree (same shape as /api/gitea/projects/{owner}/{repo}/tree) ── @router.get("/projects/{owner}/{repo}/tree") async def github_repo_tree(request: Request, owner: str, repo: str, path: str = ""): """List one level of a GitHub repo (folders first client-side).""" adapter = _require_github(request) try: tree = await adapter.get_repo_tree(owner, repo) prefix = path.strip("/").strip() items = [] seen_dirs: set[str] = set() for entry in tree: p = (entry.get("path") or "").strip("/") if not p: continue if prefix: if p != prefix and not p.startswith(prefix + "/"): continue rel = p[len(prefix) + 1:] if p != prefix else "" else: rel = p if not rel: continue if "/" in rel: top = rel.split("/")[0] dir_path = f"{prefix}/{top}".strip("/") if prefix else top if dir_path not in seen_dirs: seen_dirs.add(dir_path) items.append({"name": top, "path": dir_path, "type": "folder", "size": 0, "sha": ""}) else: is_dir = entry.get("type") == "tree" items.append({ "name": rel, "path": p, "type": "folder" if is_dir else "file", "size": entry.get("size", 0), "sha": entry.get("sha", ""), }) # De-duplicate (recursive tree can list both tree + blob entries) dedup: dict[str, dict] = {} for it in items: key = it["path"] if key not in dedup or (dedup[key]["type"] == "file" and it["type"] == "folder"): dedup[key] = it return {"tree": sorted(dedup.values(), key=lambda x: (x["type"] != "folder", x["name"].lower()))} except HTTPException: raise except Exception as e: return JSONResponse({"error": str(e)}, status_code=502) @router.get("/projects/{owner}/{repo}/file") async def github_get_file(request: Request, owner: str, repo: str, path: str = ""): """Get decoded file content (same shape as Gitea endpoint).""" adapter = _require_github(request) if not path: return JSONResponse({"error": "Path required"}, status_code=400) try: content = await adapter.get_file_content(owner, repo, path) return {"content": content, "path": path} except HTTPException: raise except Exception as e: return JSONResponse({"error": str(e)}, status_code=502)