From 90bed6ab3106b87be19d9a81fff5ae9c9058ed51 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 29 Apr 2026 16:44:13 -0700 Subject: [PATCH] feat(github): OAuth integration + repo picker for Import flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User can now click "Connect GitHub" inside the Import-existing-code flow, sign in via GitHub, and pick a repo from a searchable list of their own + collaborator + org repos. Both public and private repos work — the encrypted access token on the user's account is auto- attached when the create endpoint runs the agent-runner mirror. OAuth flow: - GET /api/integrations/github/connect — generates state, sets a 10-min httpOnly cookie, 302s to GitHub authorize. - GET /api/integrations/github/callback — verifies state, exchanges code for token, fetches /user, encrypts the token with secret-box (AES-256-GCM, VIBN_SECRETS_KEY) and persists it on fs_users.data.integrations.github. Bounces back to ?gh_connected=login or ?gh_error=msg. - GET /api/integrations/github/repos — server-side fetches the connected user's repos (per_page=100, sort=pushed, affiliation=owner+collaborator+org_member). Returns the GitHub login + a stripped repo summary; never the token. - POST /api/integrations/github/disconnect — drops the integration from fs_users (does NOT revoke on github.com). Scopes requested: repo, read:user. Token storage: - Encrypted at rest with secret-box (lib/auth/secret-box.ts) using VIBN_SECRETS_KEY. Tokens never leave the server. - One token per fs_users row, keyed by email. ImportSetup UI: - On mount, fires /repos to detect connection state. - If connected: shows a connected-as-@login chip with disconnect link, a search-as-you-type repo picker (max 220px scroll, badges for Private / language), and a "paste a different URL instead" escape hatch. - If not connected: shows a Connect GitHub card with a public-URL fallback inline. - On return from OAuth (?gh_connected=… or ?gh_error=…), surfaces a toast and silently refreshes the repo list. - Selected repo carries default_branch + repo id into the create payload so we can store them on the project for later UI hints. /api/projects/create: - When a githubRepoUrl is mirrored, falls back to the user's OAuth-linked token if no PAT is explicitly passed. Means the flow "just works" for private repos once GitHub is connected. Required env (already set in production): - GITHUB_CLIENT_ID - GITHUB_CLIENT_SECRET Made-with: Cursor --- app/api/integrations/github/callback/route.ts | 78 ++++ app/api/integrations/github/connect/route.ts | 52 +++ .../integrations/github/disconnect/route.ts | 20 + app/api/integrations/github/repos/route.ts | 53 +++ app/api/projects/create/route.ts | 11 +- components/project-creation/ImportSetup.tsx | 392 +++++++++++++++++- lib/integrations/github.ts | 189 +++++++++ 7 files changed, 773 insertions(+), 22 deletions(-) create mode 100644 app/api/integrations/github/callback/route.ts create mode 100644 app/api/integrations/github/connect/route.ts create mode 100644 app/api/integrations/github/disconnect/route.ts create mode 100644 app/api/integrations/github/repos/route.ts create mode 100644 lib/integrations/github.ts diff --git a/app/api/integrations/github/callback/route.ts b/app/api/integrations/github/callback/route.ts new file mode 100644 index 00000000..cbb56ebc --- /dev/null +++ b/app/api/integrations/github/callback/route.ts @@ -0,0 +1,78 @@ +/** + * GET /api/integrations/github/callback?code=…&state=… + * + * Final step of the OAuth dance: + * 1. Validate the `state` matches what we stored in the cookie. + * 2. Exchange `code` for an access token. + * 3. Read the GitHub /user endpoint to confirm + capture login. + * 4. Encrypt the token and persist on fs_users.data.integrations.github. + * 5. Redirect back to the `returnTo` URL we stashed in the state cookie. + * + * On any failure we redirect back to /projects with ?gh_error=… so the + * UI can surface the message in a toast. + */ + +import { NextResponse } from "next/server"; +import { authSession } from "@/lib/auth/session-server"; +import { + exchangeCodeForToken, getAuthenticatedUser, persistGithubIntegration, + isGithubOauthConfigured, +} from "@/lib/integrations/github"; + +const STATE_COOKIE = "gh_oauth_state"; + +function bounce(origin: string, returnTo: string, params: Record): NextResponse { + const dest = new URL(returnTo.startsWith("/") ? returnTo : "/", origin); + for (const [k, v] of Object.entries(params)) dest.searchParams.set(k, v); + const res = NextResponse.redirect(dest); + // Clear the one-shot state cookie so it can't be replayed. + res.cookies.set(STATE_COOKIE, "", { path: "/", maxAge: 0 }); + return res; +} + +export async function GET(req: Request) { + const url = new URL(req.url); + const origin = url.origin; + + // Recover the original target from the state cookie *before* any error path. + const cookieState = req.headers.get("cookie") + ?.split(";").map(c => c.trim()) + .find(c => c.startsWith(`${STATE_COOKIE}=`)) + ?.split("=")[1] ?? ""; + const [storedState, storedReturnTo = "/"] = decodeURIComponent(cookieState).split(":"); + + if (!isGithubOauthConfigured()) { + return bounce(origin, storedReturnTo, { gh_error: "GitHub OAuth not configured" }); + } + + const session = await authSession(); + if (!session?.user?.email) { + return bounce(origin, "/auth", { gh_error: "Sign in first" }); + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errParam = url.searchParams.get("error_description") ?? url.searchParams.get("error"); + + if (errParam) { + return bounce(origin, storedReturnTo, { gh_error: errParam }); + } + if (!code || !state) { + return bounce(origin, storedReturnTo, { gh_error: "Missing code or state" }); + } + if (!storedState || storedState !== state) { + return bounce(origin, storedReturnTo, { gh_error: "State mismatch (try again)" }); + } + + try { + const callbackUrl = `${origin}/api/integrations/github/callback`; + const tok = await exchangeCodeForToken(code, callbackUrl); + const me = await getAuthenticatedUser(tok.accessToken); + await persistGithubIntegration(session.user.email, me.login, tok.accessToken, tok.scope); + return bounce(origin, storedReturnTo, { gh_connected: me.login }); + } catch (err) { + const msg = err instanceof Error ? err.message : "GitHub connect failed"; + console.error("[github callback]", err); + return bounce(origin, storedReturnTo, { gh_error: msg }); + } +} diff --git a/app/api/integrations/github/connect/route.ts b/app/api/integrations/github/connect/route.ts new file mode 100644 index 00000000..b3d64942 --- /dev/null +++ b/app/api/integrations/github/connect/route.ts @@ -0,0 +1,52 @@ +/** + * GET /api/integrations/github/connect + * + * Kicks off GitHub OAuth. Generates an unguessable `state`, stores it + * in a short-lived (10 min) httpOnly cookie, and 302s to GitHub's + * authorize endpoint. + * + * The callback route validates the cookie matches the `state` GitHub + * echoes back, defending against login-CSRF. + */ + +import { NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { authSession } from "@/lib/auth/session-server"; +import { + buildAuthorizeUrl, isGithubOauthConfigured, +} from "@/lib/integrations/github"; + +const STATE_COOKIE = "gh_oauth_state"; +const STATE_TTL_S = 10 * 60; + +export async function GET(req: Request) { + if (!isGithubOauthConfigured()) { + return NextResponse.json( + { error: "GitHub OAuth is not configured on this server." }, + { status: 503 }, + ); + } + + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const callbackUrl = `${url.origin}/api/integrations/github/callback`; + const returnTo = url.searchParams.get("returnTo") ?? "/"; + + const state = randomBytes(16).toString("hex"); + const statePayload = `${state}:${returnTo}`; + + const authorize = buildAuthorizeUrl(state, callbackUrl); + const res = NextResponse.redirect(authorize); + res.cookies.set(STATE_COOKIE, statePayload, { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: url.protocol === "https:", + maxAge: STATE_TTL_S, + }); + return res; +} diff --git a/app/api/integrations/github/disconnect/route.ts b/app/api/integrations/github/disconnect/route.ts new file mode 100644 index 00000000..b6ecf515 --- /dev/null +++ b/app/api/integrations/github/disconnect/route.ts @@ -0,0 +1,20 @@ +/** + * POST /api/integrations/github/disconnect + * + * Drops the stored GitHub access token + login from the user's + * fs_users record. Does NOT revoke the OAuth grant on GitHub itself — + * users can do that from https://github.com/settings/applications. + */ + +import { NextResponse } from "next/server"; +import { authSession } from "@/lib/auth/session-server"; +import { disconnectGithubIntegration } from "@/lib/integrations/github"; + +export async function POST() { + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + await disconnectGithubIntegration(session.user.email); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/integrations/github/repos/route.ts b/app/api/integrations/github/repos/route.ts new file mode 100644 index 00000000..ff3866b0 --- /dev/null +++ b/app/api/integrations/github/repos/route.ts @@ -0,0 +1,53 @@ +/** + * GET /api/integrations/github/repos + * + * Returns the connected user's GitHub repos for the import-flow picker. + * Server-side: never exposes the raw token, only the public repo + * metadata the picker needs (name, full_name, description, language, + * is-private, last-pushed, default branch, html_url). + * + * 200 → { connected: true, login, repos: [...] } + * 200 → { connected: false } ← user hasn't linked GitHub yet + * 401 → unauthorized + */ + +import { NextResponse } from "next/server"; +import { authSession } from "@/lib/auth/session-server"; +import { loadGithubIntegration, listUserRepos } from "@/lib/integrations/github"; + +export async function GET() { + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const link = await loadGithubIntegration(session.user.email); + if (!link) { + return NextResponse.json({ connected: false }); + } + + try { + const repos = await listUserRepos(link.token); + return NextResponse.json({ + connected: true, + login: link.login, + repos: repos.map(r => ({ + id: r.id, + name: r.name, + fullName: r.full_name, + description: r.description, + defaultBranch: r.default_branch, + htmlUrl: r.html_url, + private: r.private, + language: r.language, + pushedAt: r.pushed_at, + fork: r.fork, + })), + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to list repos" }, + { status: 502 }, + ); + } +} diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index 2e8f5708..44ec9611 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'crypto'; import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces'; import { ensureProjectCoolifyProject } from '@/lib/projects'; +import { loadGithubIntegration } from '@/lib/integrations/github'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT; @@ -137,6 +138,14 @@ export async function POST(request: Request) { // because most projects don't need a 4-app monorepo and the AI // can scaffold whatever the user actually wants on demand. if (githubRepoUrl) { + // Prefer an explicitly-passed token; otherwise fall back to the + // OAuth-linked token on this user's account so private mirrors + // work without the user pasting a PAT. + let effectiveToken = githubToken as string | undefined; + if (!effectiveToken) { + const link = await loadGithubIntegration(email); + if (link) effectiveToken = link.token; + } const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333'; const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, { method: 'POST', @@ -145,7 +154,7 @@ export async function POST(request: Request) { github_url: githubRepoUrl, gitea_repo: `${repoOwner}/${repoName}`, project_name: projectName, - github_token: githubToken || undefined, + github_token: effectiveToken || undefined, }), }); if (!mirrorRes.ok) { diff --git a/components/project-creation/ImportSetup.tsx b/components/project-creation/ImportSetup.tsx index f1d9e86c..a2a2865c 100644 --- a/components/project-creation/ImportSetup.tsx +++ b/components/project-creation/ImportSetup.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { JM } from "./modal-theme"; @@ -11,27 +11,100 @@ import { } from "./setup-shared"; /** - * "Import existing code" — two-step setup. - * Step 1: project name + audience + repo URL. - * Step 2: describe what you want Vibn to focus on (optional, but - * recommended). Becomes the seed for the first AI conversation. + * "Import existing code" — two-step setup with GitHub OAuth. * - * v1: public repos only. Private-repo OAuth lands in v2. + * Step 1: project name + audience + repo source. + * Repo source defaults to the connected-GitHub picker; falls back to + * "paste a public URL" if the user hasn't linked GitHub yet (or + * prefers to bring an arbitrary repo). + * Step 2: optional "what do you want to do with it" textarea (seeds + * the AI's first message). */ export function ImportSetup({ workspace, onClose, onBack }: SetupProps) { const router = useRouter(); const [step, setStep] = useState<0 | 1>(0); const [name, setName] = useState(""); const [audience, setAudience] = useState("customers"); - const [repoUrl, setRepoUrl] = useState(""); + + // GitHub OAuth state. `connected === undefined` means we haven't + // checked yet; `null` means "checked, not linked". + const [connected, setConnected] = useState(undefined); + const [picker, setPicker] = useState<"github" | "url">("github"); + const [filter, setFilter] = useState(""); + const [selectedRepoId, setSelectedRepoId] = useState(null); + const [manualUrl, setManualUrl] = useState(""); + const [intent, setIntent] = useState(""); const [loading, setLoading] = useState(false); - const isValidUrl = /^https?:\/\//i.test(repoUrl.trim()); - const canContinue = name.trim().length > 0 && isValidUrl; + useEffect(() => { + let cancelled = false; + fetch("/api/integrations/github/repos", { credentials: "include" }) + .then(r => r.json()) + .then(d => { + if (cancelled) return; + if (d?.connected && Array.isArray(d.repos)) { + setConnected({ login: d.login, repos: d.repos }); + setPicker("github"); + } else { + setConnected(null); + setPicker("url"); // no point defaulting to a picker that's empty + } + }) + .catch(() => { if (!cancelled) setConnected(null); }); + return () => { cancelled = true; }; + }, []); + + // Surface ?gh_error / ?gh_connected toasts after returning from GitHub. + useEffect(() => { + if (typeof window === "undefined") return; + const u = new URL(window.location.href); + const err = u.searchParams.get("gh_error"); + const ok = u.searchParams.get("gh_connected"); + if (err) { + toast.error(`GitHub: ${err}`); + u.searchParams.delete("gh_error"); + window.history.replaceState({}, "", u.toString()); + } + if (ok) { + toast.success(`Connected GitHub as @${ok}`); + u.searchParams.delete("gh_connected"); + window.history.replaceState({}, "", u.toString()); + // Refresh repo list silently. + fetch("/api/integrations/github/repos", { credentials: "include" }) + .then(r => r.json()) + .then(d => { + if (d?.connected) setConnected({ login: d.login, repos: d.repos }); + }) + .catch(() => {}); + } + }, []); + + const selectedRepo = connected && typeof connected === "object" + ? connected.repos.find(r => r.id === selectedRepoId) ?? null + : null; + + const isValidUrl = /^https?:\/\//i.test(manualUrl.trim()); + const canContinue = + name.trim().length > 0 && + ((picker === "github" && !!selectedRepo) || (picker === "url" && isValidUrl)); + + const handleConnect = () => { + const returnTo = window.location.pathname + window.location.search; + window.location.href = `/api/integrations/github/connect?returnTo=${encodeURIComponent(returnTo)}`; + }; + + const handleDisconnect = async () => { + await fetch("/api/integrations/github/disconnect", { method: "POST", credentials: "include" }); + setConnected(null); + setPicker("url"); + setSelectedRepoId(null); + toast.success("Disconnected GitHub"); + }; const handleCreate = async () => { if (!canContinue) return; + const repoUrl = picker === "github" && selectedRepo ? selectedRepo.htmlUrl : manualUrl.trim(); setLoading(true); try { const res = await fetch("/api/projects/create", { @@ -41,12 +114,19 @@ export function ImportSetup({ workspace, onClose, onBack }: SetupProps) { projectName: name.trim(), projectType: "web-app", slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), - vision: intent.trim() || `Continue work on ${repoUrl.trim()}`, + vision: intent.trim() || `Continue work on ${repoUrl}`, product: { name: name.trim() }, audience, creationMode: "import", - githubRepoUrl: repoUrl.trim(), - sourceData: { audience, repoUrl: repoUrl.trim(), intent: intent.trim() || null }, + githubRepoUrl: repoUrl, + githubDefaultBranch: selectedRepo?.defaultBranch ?? null, + githubRepoId: selectedRepo?.id ?? null, + sourceData: { + audience, repoUrl, + via: picker === "github" ? "oauth" : "url", + ghLogin: picker === "github" ? connected && typeof connected === "object" ? connected.login : null : null, + intent: intent.trim() || null, + }, }), }); if (!res.ok) { @@ -64,6 +144,14 @@ export function ImportSetup({ workspace, onClose, onBack }: SetupProps) { } }; + const filteredRepos = connected && typeof connected === "object" + ? connected.repos.filter(r => { + const q = filter.trim().toLowerCase(); + if (!q) return true; + return r.fullName.toLowerCase().includes(q) || (r.description ?? "").toLowerCase().includes(q); + }) + : []; + return (
- GitHub repository link - -

- Public repos work today. Private-repo support is coming soon. -

+ {/* Repo source */} + {connected === undefined ? ( +
Checking GitHub connection…
+ ) : connected === null ? ( + + ) : ( + + )} setStep(1)} disabled={!canContinue}> @@ -130,6 +235,231 @@ export function ImportSetup({ workspace, onClose, onBack }: SetupProps) { ); } +// ───────────────────────────────────────────────────────────────────────────── +// Sub-blocks +// ───────────────────────────────────────────────────────────────────────────── + +interface GhRepo { + id: number; + name: string; + fullName: string; + description: string | null; + defaultBranch: string; + htmlUrl: string; + private: boolean; + language: string | null; + pushedAt: string | null; + fork: boolean; +} + +function NotConnectedBlock({ + onConnect, picker, setPicker, manualUrl, setManualUrl, +}: { + onConnect: () => void; + picker: "github" | "url"; + setPicker: (p: "github" | "url") => void; + manualUrl: string; + setManualUrl: (s: string) => void; +}) { + return ( + <> + Where's the code? +
+
+ +
+
+
Connect your GitHub
+
+ Pick from a list of your repos — public or private. +
+
+ +
+ + + + {picker === "url" && ( +
+ Public GitHub URL + +

+ Public repos work without connecting GitHub. Private repos need the connection above. +

+
+ )} + + ); +} + +function ConnectedBlock({ + login, picker, setPicker, repos, filter, setFilter, + selectedRepoId, setSelectedRepoId, manualUrl, setManualUrl, onDisconnect, +}: { + login: string; + picker: "github" | "url"; + setPicker: (p: "github" | "url") => void; + repos: GhRepo[]; + filter: string; setFilter: (s: string) => void; + selectedRepoId: number | null; + setSelectedRepoId: (n: number | null) => void; + manualUrl: string; + setManualUrl: (s: string) => void; + onDisconnect: () => void; +}) { + return ( + <> +
+ + Connected as @{login} +
+ +
+ + {picker === "github" ? ( + <> + Pick a repository + setFilter(e.target.value)} + placeholder={`Search ${repos.length > 0 ? `${repos.length} repos…` : "repos…"}`} + style={{ + width: "100%", padding: "9px 12px", marginBottom: 8, + borderRadius: 7, border: `1px solid ${JM.border}`, + background: JM.inputBg, fontSize: 13, + fontFamily: JM.fontSans, color: JM.ink, + outline: "none", boxSizing: "border-box", + }} + onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)} + onBlur={e => (e.currentTarget.style.borderColor = JM.border)} + /> +
+ {repos.length === 0 ? ( +
+ No matching repos +
+ ) : ( + repos.map(r => { + const sel = r.id === selectedRepoId; + return ( + + ); + }) + )} +
+ + + ) : ( + <> + Public repo URL + + + + )} + + ); +} + +function Badge({ children, muted }: { children: React.ReactNode; muted?: boolean }) { + return ( + {children} + ); +} + +function GhMark({ size = 18, dark }: { size?: number; dark?: boolean }) { + return ( + + + + ); +} + function FlowFooter({ step, total, primary, secondary, }: { @@ -145,3 +475,23 @@ function FlowFooter({
); } + +const linkButton: React.CSSProperties = { + background: "none", border: "none", padding: 0, + fontSize: 12, color: JM.indigo, cursor: "pointer", + fontFamily: JM.fontSans, fontWeight: 500, + marginBottom: 14, +}; + +const textBtn: React.CSSProperties = { + background: "none", border: "none", padding: 0, + fontSize: 11.5, color: JM.muted, cursor: "pointer", + fontFamily: JM.fontSans, + textDecoration: "underline", +}; + +const loadingBox: React.CSSProperties = { + padding: "14px 16px", borderRadius: 8, + background: JM.cream, border: `1px solid ${JM.border}`, + fontSize: 12.5, color: JM.mid, marginBottom: 14, fontFamily: JM.fontSans, +}; diff --git a/lib/integrations/github.ts b/lib/integrations/github.ts new file mode 100644 index 00000000..b38ae8eb --- /dev/null +++ b/lib/integrations/github.ts @@ -0,0 +1,189 @@ +/** + * GitHub OAuth integration helpers. + * + * Storage layout — fs_users.data.integrations.github: + * { + * login: "octocat", + * accessToken: , // encrypted at rest + * scope: "repo,read:user", + * connectedAt: ISO, + * } + * + * Tokens never leave the server. All API calls happen server-side; the + * UI only ever sees the GitHub `login` and the list of repos. + * + * Scopes requested: `repo` (private+public read+write so we can mirror) + * `read:user` (so we can show the connected username) + */ + +import { encryptSecret, decryptSecret } from "@/lib/auth/secret-box"; +import { query } from "@/lib/db-postgres"; + +const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? ""; +const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? ""; +const SCOPES = "repo,read:user"; + +export function isGithubOauthConfigured(): boolean { + return CLIENT_ID.length > 0 && CLIENT_SECRET.length > 0; +} + +/** Build the GitHub authorize URL. `state` MUST be unguessable per request. */ +export function buildAuthorizeUrl(state: string, callbackUrl: string): string { + const u = new URL("https://github.com/login/oauth/authorize"); + u.searchParams.set("client_id", CLIENT_ID); + u.searchParams.set("redirect_uri", callbackUrl); + u.searchParams.set("scope", SCOPES); + u.searchParams.set("state", state); + u.searchParams.set("allow_signup", "true"); + return u.toString(); +} + +interface TokenExchangeResult { + accessToken: string; + scope: string; + tokenType: string; +} + +/** POST /login/oauth/access_token — exchange auth code for an access token. */ +export async function exchangeCodeForToken( + code: string, callbackUrl: string, +): Promise { + const res = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + redirect_uri: callbackUrl, + }), + }); + const body = await res.json() as { + access_token?: string; scope?: string; token_type?: string; + error?: string; error_description?: string; + }; + if (!res.ok || !body.access_token) { + throw new Error(body.error_description || body.error || "GitHub token exchange failed"); + } + return { + accessToken: body.access_token, + scope: body.scope ?? "", + tokenType: body.token_type ?? "bearer", + }; +} + +/** GET /user — fetch the authenticated user's login + name. */ +export async function getAuthenticatedUser(token: string) { + const res = await fetch("https://api.github.com/user", { + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!res.ok) { + throw new Error(`GitHub /user failed: ${res.status} ${res.statusText}`); + } + return res.json() as Promise<{ login: string; name: string | null; avatar_url: string }>; +} + +interface GithubRepo { + id: number; + name: string; + full_name: string; + private: boolean; + description: string | null; + default_branch: string; + html_url: string; + pushed_at: string | null; + language: string | null; + fork: boolean; +} + +/** + * GET /user/repos — list repos the authenticated user can access. + * Returns at most `perPage` repos; we cap at 100 (GitHub max) and don't + * paginate further. The picker UI does client-side filter on top. + */ +export async function listUserRepos(token: string, perPage = 100): Promise { + const u = new URL("https://api.github.com/user/repos"); + u.searchParams.set("per_page", String(perPage)); + u.searchParams.set("sort", "pushed"); + u.searchParams.set("affiliation", "owner,collaborator,organization_member"); + const res = await fetch(u.toString(), { + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!res.ok) { + throw new Error(`GitHub /user/repos failed: ${res.status} ${res.statusText}`); + } + return res.json() as Promise; +} + +// ───────────────────────────────────────────────────────────────────────────── +// fs_users persistence +// ───────────────────────────────────────────────────────────────────────────── + +export interface StoredGithubIntegration { + login: string; + accessToken: string; // encrypted + scope: string; + connectedAt: string; +} + +/** Write the integration to fs_users.data.integrations.github. */ +export async function persistGithubIntegration( + email: string, login: string, plainToken: string, scope: string, +): Promise { + const blob: StoredGithubIntegration = { + login, + accessToken: encryptSecret(plainToken), + scope, + connectedAt: new Date().toISOString(), + }; + await query( + `UPDATE fs_users + SET data = jsonb_set( + jsonb_set(data, '{integrations}', COALESCE(data->'integrations','{}'::jsonb), true), + '{integrations,github}', $2::jsonb, true + ), + updated_at = NOW() + WHERE data->>'email' = $1`, + [email, JSON.stringify(blob)], + ); +} + +/** Read + decrypt the GitHub access token for a user, if any. */ +export async function loadGithubIntegration( + email: string, +): Promise<{ login: string; token: string; scope: string } | null> { + const rows = await query<{ blob: StoredGithubIntegration | null }>( + `SELECT data->'integrations'->'github' AS blob + FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, + [email], + ); + const blob = rows[0]?.blob; + if (!blob || !blob.accessToken) return null; + try { + return { login: blob.login, token: decryptSecret(blob.accessToken), scope: blob.scope }; + } catch { + return null; + } +} + +/** Drop the stored integration. */ +export async function disconnectGithubIntegration(email: string): Promise { + await query( + `UPDATE fs_users + SET data = data #- '{integrations,github}', + updated_at = NOW() + WHERE data->>'email' = $1`, + [email], + ); +}