import crypto from "node:crypto"; import { db, listAccessibleGithubInstallsForProject, resolveIncident, schema, syncLoopsContactsForOrg, } from "@superlog/db"; import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import type { Hono } from "hono"; import type { Context } from "hono"; import { FEEDBACK_PR_FOOTER_MARKER, isFeedbackEligibleCommenter, recordFeedback, } from "./feedback.js"; import { getDeviceFlow, getLinkedDevice, getSkillDeviceForIntegration } from "./gateway.js"; import { type RepoBranch, type RepoBranchInfo, mergeRepoBranches } from "./github-branches.js"; import { runResolvedIncidentSideEffectsForIncident } from "./incidents/resolution-side-effects.js"; import { logger } from "./logger.js"; import { resolveActiveOrgContext } from "./org-context.js"; const log = logger.child({ scope: "github" }); type Vars = { userId: string; orgId: string | null }; const DEFAULT_COMMIT_AUTHOR = { name: "Superlog app", email: "bot@superlog.sh", }; // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance. export function mountGithubPublic(app: Hono): void { const appSlug = process.env.GITHUB_APP_SLUG; const stateSecret = process.env.STATE_SIGNING_SECRET; const webhookSecret = process.env.GITHUB_APP_WEBHOOK_SECRET; const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173"; const oauthClientId = process.env.GITHUB_OAUTH_CLIENT_ID ?? process.env.GITHUB_APP_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID; const oauthClientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET ?? process.env.GITHUB_APP_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET; const installRedirectUrl = process.env.GITHUB_INSTALL_OAUTH_REDIRECT_URL ?? "http://localhost:4100/github/install/callback"; if (!appSlug) { log.warn("GITHUB_APP_SLUG not set — /github/install disabled"); } if (!stateSecret) { log.warn("STATE_SIGNING_SECRET not set — /github/install disabled"); } if (!webhookSecret) { log.warn("GITHUB_APP_WEBHOOK_SECRET not set — /github/webhook disabled"); } app.get("/github/install", (c) => { if (!appSlug || !stateSecret) { return c.json({ error: "github app not configured" }, 503); } const userCode = (c.req.query("user_code") ?? "").toUpperCase(); // CLI/MCP flow: device must be in user_linked state. Skill flow drives // GitHub install AFTER pairing (device already approved), so fall back // to the skill-device helper when getLinkedDevice rejects it. const device = getLinkedDevice(userCode) ?? getSkillDeviceForIntegration(userCode); if (!device) return c.json({ error: "unknown or not-ready device code" }, 404); const state = signState("cli", userCode, stateSecret); const url = new URL(`https://github.com/apps/${appSlug}/installations/new`); url.searchParams.set("state", state); return c.redirect(url.toString(), 302); }); app.get("/github/install/callback", async (c) => { if (!stateSecret) return c.json({ error: "github app not configured" }, 503); const installationIdRaw = c.req.query("installation_id"); const state = c.req.query("state") ?? ""; const setupAction = c.req.query("setup_action") ?? "install"; const oauthCode = c.req.query("code") ?? null; log.info( { setup_action: setupAction, installation_id: installationIdRaw ?? null, has_oauth_code: !!oauthCode, }, "github install callback received", ); const installationId = Number(installationIdRaw); const marker = setupAction === "update" ? "updated" : "done"; // First try the management-API state shape for platform provisioning. // Falls through to the existing cli/web state shape if not a mgmt state. const mgmt = verifyMgmtState(state, stateSecret); if (mgmt) { const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin); const failTarget = mgmt.returnUrl ?? `${callbackWebOrigin}/`; if (!Number.isFinite(installationId) || installationId <= 0) { return c.redirect(`${failTarget}${failTarget.includes("?") ? "&" : "?"}gh=error`, 302); } let resolvedOrgId: string; let resolvedProjectId: string | null; if (mgmt.scope === "org") { resolvedOrgId = mgmt.orgId; resolvedProjectId = null; } else { const project = await db.query.projects.findFirst({ where: eq(schema.projects.id, mgmt.projectId), }); if (!project) { return c.redirect(`${failTarget}${failTarget.includes("?") ? "&" : "?"}gh=error`, 302); } resolvedOrgId = project.orgId; resolvedProjectId = project.id; } const rowId = await upsertInstallation({ orgId: resolvedOrgId, projectId: resolvedProjectId, installationId, }); await resumeBlockedAgentRunsForOrg(resolvedOrgId, "github_install", { projectIdHint: resolvedProjectId, }); await maybeCaptureInstallerIdentity({ installationRowId: rowId, installationId, oauthCode, clientId: oauthClientId, clientSecret: oauthClientSecret, redirectUrl: installRedirectUrl, }); const successTarget = mgmt.returnUrl ?? `${callbackWebOrigin}/`; return c.redirect( `${successTarget}${successTarget.includes("?") ? "&" : "?"}gh=${marker}`, 302, ); } const decoded = verifyState(state, stateSecret); if (!decoded) return c.json({ error: "invalid state" }, 400); // "cli" kind: value is userCode → resolve org+project from device. // "web" kind: value is projectId → resolve org from the project row. let orgId: string | null; let projectId: string | null; if (decoded.kind === "cli") { const device = getLinkedDevice(decoded.value) ?? getSkillDeviceForIntegration(decoded.value); orgId = device?.orgId ?? null; projectId = device?.projectId ?? null; } else { const project = await db.query.projects.findFirst({ where: eq(schema.projects.id, decoded.value), }); orgId = project?.orgId ?? null; projectId = project?.id ?? null; } if (decoded.kind === "cli") { const userCode = decoded.value; const flow = getDeviceFlow(userCode); const flowQuery = flow === "skill" ? "&flow=skill" : ""; const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin); if (!orgId || !projectId) { return c.redirect( `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=expired`, 302, ); } if (!Number.isFinite(installationId) || installationId <= 0) { return c.redirect( `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=error`, 302, ); } const rowId = await upsertInstallation({ orgId, projectId, installationId }); await resumeBlockedAgentRunsForProjects([projectId], "github_install"); await maybeCaptureInstallerIdentity({ installationRowId: rowId, installationId, oauthCode, clientId: oauthClientId, clientSecret: oauthClientSecret, redirectUrl: installRedirectUrl, }); return c.redirect( `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=${marker}`, 302, ); } // decoded.kind === "web" — value resolved to project above. const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin); if (!Number.isFinite(installationId) || installationId <= 0 || !orgId || !projectId) { return c.redirect(`${callbackWebOrigin}/?gh=error`, 302); } const rowId = await upsertInstallation({ orgId, projectId, installationId }); await resumeBlockedAgentRunsForProjects([projectId], "github_install"); await maybeCaptureInstallerIdentity({ installationRowId: rowId, installationId, oauthCode, clientId: oauthClientId, clientSecret: oauthClientSecret, redirectUrl: installRedirectUrl, }); return c.redirect(`${callbackWebOrigin}/?gh=${marker}`, 302); }); app.post("/github/webhook", async (c) => { if (!webhookSecret) return c.json({ error: "webhook not configured" }, 503); const rawBody = await c.req.text(); const sigHeader = c.req.header("x-hub-signature-256") ?? ""; if (!verifyWebhookSignature(rawBody, sigHeader, webhookSecret)) { log.warn({ delivery: c.req.header("x-github-delivery") }, "github webhook signature failed"); return c.json({ error: "bad signature" }, 401); } const event = c.req.header("x-github-event") ?? ""; let payload: WebhookPayload; try { payload = JSON.parse(rawBody) as WebhookPayload; } catch { return c.json({ error: "invalid json" }, 400); } const delivery = c.req.header("x-github-delivery") ?? null; try { await handleWebhook(event, payload, delivery); log.info( { event, action: payload.action, installation_id: payload.installation?.id, delivery, }, "github webhook handled", ); } catch (err) { log.error( { err, event, action: payload.action, installation_id: payload.installation?.id, }, "webhook handler failed", ); // Let GitHub retry on transient failures. return c.json({ error: "handler failed" }, 500); } return c.json({ ok: true }); }); } function resolveCallbackWebOrigin(c: Context, configuredWebOrigin: string): string { const host = c.req.header("host") ?? ""; if ( host === "localhost:4100" || host === "127.0.0.1:4100" || configuredWebOrigin.endsWith(".superlog.localhost:1355") ) { return "http://localhost:5173"; } return configuredWebOrigin; } type GhRepo = { id: number; full_name: string; private: boolean }; export type StoredRepo = { id: number; fullName: string; private: boolean }; type RepoAccess = { disabledRepoIds?: number[] }; type GithubRepoSummary = StoredRepo & { enabled: boolean }; type GithubInstallationReposResponse = { repositories: GhRepo[] }; type GithubOAuthTokenResponse = { access_token?: string; token_type?: string; scope?: string; error?: string; error_description?: string; }; type GithubViewerResponse = { id: number; login: string; name: string | null; email: string | null; avatar_url: string | null; }; type GithubEmailResponse = { email: string; primary: boolean; verified: boolean; visibility: string | null; }; type GithubUserInstallation = { id: number; account: { login?: string; type?: string } | null; html_url: string; repository_selection: "all" | "selected"; suspended_at: string | null; }; type GithubUserInstallationsResponse = { installations: GithubUserInstallation[]; }; type GithubUserInstallationReposResponse = { repositories: Array<{ id: number; full_name: string; private: boolean }>; }; type GithubInstallationSummary = { installationId: number; accountLogin: string | null; accountType: string | null; enabled: boolean; manageUrl: string; repos: GithubRepoSummary[]; }; type CommitAuthor = { name: string; email: string; githubLogin: string; githubId: number; avatarUrl: string | null; }; type GhActor = { login?: string; id?: number; avatar_url?: string | null; type?: string; } | null; type WebhookPayload = { action?: string; installation?: { id: number; account?: { login?: string; type?: string }; }; repositories?: GhRepo[]; repositories_added?: GhRepo[]; repositories_removed?: GhRepo[]; // pull_request events / pull_request_review / pull_request_review_comment repository?: { full_name?: string }; pull_request?: { number?: number; node_id?: string; state?: string; title?: string; merged?: boolean; merged_at?: string | null; closed_at?: string | null; merged_by?: GhActor; user?: GhActor; head?: { sha?: string; ref?: string }; base?: { ref?: string }; html_url?: string; }; // issue_comment (only PR if issue.pull_request present) issue?: { number?: number; pull_request?: { url?: string }; }; comment?: { id?: number; body?: string; user?: GhActor; html_url?: string; author_association?: string; }; review?: { id?: number; state?: string; body?: string | null; user?: GhActor; html_url?: string; author_association?: string; }; // push events ref?: string; after?: string; before?: string; pusher?: { name?: string; email?: string }; sender?: GhActor; commits?: Array<{ id?: string; message?: string; author?: { name?: string; email?: string } }>; }; async function handleWebhook( event: string, payload: WebhookPayload, delivery: string | null, ): Promise { if ( event === "pull_request" || event === "pull_request_review" || event === "pull_request_review_comment" || event === "issue_comment" || event === "push" ) { await handleAgentPrWebhook(event, payload, delivery); return; } const installationId = payload.installation?.id; if (!installationId) return; if (event === "installation") { const action = payload.action; if (action === "created" || action === "new_permissions_accepted") { await db .update(schema.githubInstallations) .set({ accountLogin: payload.installation?.account?.login ?? null, accountType: payload.installation?.account?.type ?? null, repos: [], revokedAt: null, }) .where(eq(schema.githubInstallations.installationId, installationId)); await resumeBlockedAgentRunsForInstallation(installationId, "github_install"); return; } if (action === "deleted" || action === "suspend") { await db .update(schema.githubInstallations) .set({ revokedAt: new Date() }) .where(eq(schema.githubInstallations.installationId, installationId)); return; } if (action === "unsuspend") { await db .update(schema.githubInstallations) .set({ revokedAt: null }) .where(eq(schema.githubInstallations.installationId, installationId)); await resumeBlockedAgentRunsForInstallation(installationId, "github_install"); return; } return; } if (event === "installation_repositories") { const row = await db.query.githubInstallations.findFirst({ where: eq(schema.githubInstallations.installationId, installationId), }); if (!row) return; await db .update(schema.githubInstallations) .set({ repos: [], accountLogin: payload.installation?.account?.login ?? row.accountLogin, accountType: payload.installation?.account?.type ?? row.accountType, }) .where(eq(schema.githubInstallations.installationId, installationId)); if (payload.action === "added") { await resumeBlockedAgentRunsForInstallation(installationId, "github_repos_added"); } } } // Requeue any agentRuns sitting in `blocked_no_github` under the given // project(s). Cheap and idempotent — single bulk UPDATE + batched event // inserts. Callers: install webhook (resolves project IDs from // installationId) and the OAuth install callback (knows projectId directly, // avoids a webhook/callback race on first-time installs where the github // installations row may not exist yet when the webhook arrives). export async function resumeBlockedAgentRunsForProjects( projectIds: string[], trigger: "github_install" | "github_repos_added", ): Promise { if (projectIds.length === 0) return; const blocked = await db .select({ id: schema.agentRuns.id }) .from(schema.agentRuns) .innerJoin(schema.incidents, eq(schema.incidents.id, schema.agentRuns.incidentId)) .where( and( eq(schema.agentRuns.state, "blocked_no_github"), inArray(schema.incidents.projectId, projectIds), ), ); if (blocked.length === 0) return; const ids = blocked.map((row) => row.id); const now = new Date(); await db .update(schema.agentRuns) .set({ state: "queued", updatedAt: now }) .where(inArray(schema.agentRuns.id, ids)); await db .insert(schema.incidentEvents) .values( ids.map((id) => ({ agentRunId: id, kind: "unblocked", summary: "Investigation requeued.", detail: { trigger }, dedupeKey: `unblocked:${id}:${now.getTime()}`, processedAt: now, })), ) .onConflictDoNothing(); log.info({ projectIds, trigger, count: ids.length }, "resumed blocked agent_runs"); } // Org-scoped resume — fans out across every project in the org. Used for // org-scoped installs where the install grants access to repos that any // project can be wired up to. The optional `projectIdHint` is purely an // optimisation: if the caller already knows one project should be revived, // it's bundled into the same single query so we still issue one bulk update. async function resumeBlockedAgentRunsForOrg( orgId: string, trigger: "github_install" | "github_repos_added", opts: { projectIdHint?: string | null } = {}, ): Promise { const orgProjects = await db.query.projects.findMany({ where: eq(schema.projects.orgId, orgId), columns: { id: true }, }); const projectIds = new Set(orgProjects.map((row) => row.id)); if (opts.projectIdHint) projectIds.add(opts.projectIdHint); if (projectIds.size === 0) return; await resumeBlockedAgentRunsForProjects([...projectIds], trigger); } async function resumeBlockedAgentRunsForInstallation( installationId: number, trigger: "github_install" | "github_repos_added", ): Promise { const installs = await db.query.githubInstallations.findMany({ where: and( eq(schema.githubInstallations.installationId, installationId), isNull(schema.githubInstallations.revokedAt), ), columns: { orgId: true, projectId: true }, }); if (installs.length === 0) return; // Project-scoped installs grant access narrowly — fan out to that project // only. Org-scoped installs (projectId null) can grant repos to any project // in the org, so widen to every project under the org. const directProjectIds = installs .map((row) => row.projectId) .filter((id): id is string => id !== null); if (directProjectIds.length > 0) { await resumeBlockedAgentRunsForProjects(directProjectIds, trigger); } const orgIdsForOrgScoped = Array.from( new Set(installs.filter((row) => row.projectId === null).map((row) => row.orgId)), ); for (const orgId of orgIdsForOrgScoped) { await resumeBlockedAgentRunsForOrg(orgId, trigger); } } async function handleAgentPrWebhook( event: string, payload: WebhookPayload, delivery: string | null, ): Promise { const repoFullName = payload.repository?.full_name; if (!repoFullName) return; // Resolve which agent_pull_request row this event is about. let prNumber: number | undefined; if ( event === "pull_request" || event === "pull_request_review" || event === "pull_request_review_comment" ) { prNumber = payload.pull_request?.number; } else if (event === "issue_comment") { if (!payload.issue?.pull_request) return; prNumber = payload.issue.number; } // push: resolve by branch let agentPrRow: schema.AgentPullRequest | undefined; if (event === "push") { const ref = payload.ref; if (!ref?.startsWith("refs/heads/")) return; const branch = ref.slice("refs/heads/".length); agentPrRow = await db.query.agentPullRequests.findFirst({ where: and( eq(schema.agentPullRequests.repoFullName, repoFullName), eq(schema.agentPullRequests.branchName, branch), ), }); } else if (typeof prNumber === "number") { agentPrRow = await db.query.agentPullRequests.findFirst({ where: and( eq(schema.agentPullRequests.repoFullName, repoFullName), eq(schema.agentPullRequests.prNumber, prNumber), ), }); } if (!agentPrRow) return; // Capture human review comments as feedback. We surface this in // /admin/feedback so we can hear about how our agent-opened PRs are // landing with the people reviewing them. Bot comments (greptile and // friends) and the GitHub "review submitted" envelope (which has its // own dedicated state machine in describeAgentPrEvent) are skipped. // // Best-effort: don't let a feedback DB error cascade into a 500 that // makes GitHub retry the whole delivery. A retry would re-run the // agent_pr_events insert (idempotent via onConflictDoNothing) and the // PR state update, but a partial state where the feedback row landed // before the retry-triggering throw is exactly the kind of "I see the // feedback but not the merge-resolved incident" inconsistency we want // to avoid. Matches the wrapping pattern used for Slack feedback in // apps/api/src/slack.ts. await maybeRecordPrCommentFeedback({ event, payload, agentPrRow }).catch((err) => { log.warn({ err, event, agent_pr_id: agentPrRow.id }, "pr comment feedback capture failed"); }); const now = new Date(); // Update the parent row when this is a state-changing event. if (event === "pull_request") { const action = payload.action ?? ""; const pr = payload.pull_request ?? {}; let mergedResolution: { mergedAt: Date; mergedByLogin: string | null } | null = null; const updates: Partial = { lastSyncedAt: now, updatedAt: now, }; if (pr.head?.sha) updates.headSha = pr.head.sha; if (typeof pr.title === "string") updates.title = pr.title; if (action === "closed") { if (pr.merged) { updates.state = "merged"; if (pr.merged_at) updates.mergedAt = new Date(pr.merged_at); updates.closedAt = pr.closed_at ? new Date(pr.closed_at) : now; updates.mergedByLogin = pr.merged_by?.login ?? null; updates.mergedByGithubId = pr.merged_by?.id ?? null; mergedResolution = { mergedAt: updates.mergedAt ?? updates.closedAt ?? now, mergedByLogin: updates.mergedByLogin ?? null, }; } else { updates.state = "closed"; updates.closedAt = pr.closed_at ? new Date(pr.closed_at) : now; } } else if (action === "reopened") { updates.state = "open"; updates.closedAt = null; } await db .update(schema.agentPullRequests) .set(updates) .where(eq(schema.agentPullRequests.id, agentPrRow.id)); if (mergedResolution) { await resolveIncidentForMergedAgentPr({ agentPr: agentPrRow, mergedAt: mergedResolution.mergedAt, mergedByLogin: mergedResolution.mergedByLogin, }); } } else if (event === "push") { if (payload.after) { await db .update(schema.agentPullRequests) .set({ headSha: payload.after, lastSyncedAt: now, updatedAt: now }) .where(eq(schema.agentPullRequests.id, agentPrRow.id)); } } const { kind, summary, actor } = describeAgentPrEvent(event, payload); if (!kind) return; await db .insert(schema.agentPrEvents) .values({ agentPrId: agentPrRow.id, kind, summary, actorLogin: actor?.login ?? null, actorGithubId: actor?.id ?? null, actorAvatarUrl: actor?.avatar_url ?? null, payload: payload as unknown as Record, providerEventId: delivery, occurredAt: now, }) .onConflictDoNothing(); } async function resolveIncidentForMergedAgentPr(opts: { agentPr: schema.AgentPullRequest; mergedAt: Date; mergedByLogin: string | null; }): Promise { const { agentPr, mergedAt, mergedByLogin } = opts; const { resolved } = await resolveIncident({ incidentId: agentPr.incidentId, kind: "agent_pr_merged", reasonCode: "agent_pr_merged", reasonText: `Resolved because agent PR #${agentPr.prNumber} (${agentPr.repoFullName}) was merged${ mergedByLogin ? ` by @${mergedByLogin}` : "" }.`, agentRunId: agentPr.agentRunId, eventSummary: `Incident resolved because PR #${agentPr.prNumber} was merged.`, eventDetail: { agentPrId: agentPr.id, repoFullName: agentPr.repoFullName, prNumber: agentPr.prNumber, prUrl: agentPr.url, mergedByLogin, }, eventDedupeKey: `incident_resolved:agent_pr:${agentPr.id}`, resolvedAt: mergedAt, }); if (resolved) { await runResolvedIncidentSideEffectsForIncident({ incidentId: agentPr.incidentId, closePullRequest: (pr) => closeAgentPullRequestOnGithub({ installationId: pr.githubInstallationId, repoFullName: pr.repoFullName, prNumber: pr.prNumber, }), }); } } async function maybeRecordPrCommentFeedback(opts: { event: string; payload: WebhookPayload; agentPrRow: schema.AgentPullRequest; }): Promise { const { event, payload, agentPrRow } = opts; // Only the comment-bearing events count; PR open/close/merge has its // own first-class handling and isn't user feedback. let body: string | null = null; let actor: GhActor = null; let authorAssociation: string | null = null; let commentUrl: string | null = null; if (event === "issue_comment" && payload.action === "created") { body = payload.comment?.body ?? null; actor = payload.comment?.user ?? null; authorAssociation = payload.comment?.author_association ?? null; commentUrl = payload.comment?.html_url ?? null; } else if (event === "pull_request_review_comment" && payload.action === "created") { body = payload.comment?.body ?? null; actor = payload.comment?.user ?? null; authorAssociation = payload.comment?.author_association ?? null; commentUrl = payload.comment?.html_url ?? null; } else if (event === "pull_request_review" && payload.action === "submitted") { body = payload.review?.body ?? null; actor = payload.review?.user ?? null; authorAssociation = payload.review?.author_association ?? null; commentUrl = payload.review?.html_url ?? null; } else { return; } if (!body || body.trim().length === 0) return; // Skip our own footer echoed back by the GitHub UI — the PR description // itself is rendered into comment-like contexts occasionally (e.g. the // squashed merge commit message), and we don't want our own "leave // feedback" link surfaced as feedback. if (body.includes(FEEDBACK_PR_FOOTER_MARKER)) return; if ( !isFeedbackEligibleCommenter({ userType: actor?.type ?? null, authorAssociation, }) ) { return; } // Resolve org + project via the agent PR's installation so the admin // inbox can show which org's PR this feedback is on. const install = await db.query.githubInstallations.findFirst({ where: eq(schema.githubInstallations.id, agentPrRow.installationId), }); // Best-effort idempotency: GitHub redelivers webhooks on failure (the // outer handler returns 500 on its own catch path), so a single PR // comment can hit us multiple times. The comment URL is a stable // natural key — skip if we've already recorded feedback for it. Not a // strict unique index because the dedupe window between delivery // retries is minutes, not concurrent; a hard guarantee would need a // unique partial index, which we may add later if duplicates show up. if (commentUrl) { const existing = await db.query.feedback.findFirst({ where: and( eq(schema.feedback.source, "pr_comment"), sql`${schema.feedback.authorExternal}->>'githubCommentUrl' = ${commentUrl}`, ), columns: { id: true }, }); if (existing) return; } await recordFeedback({ kind: "pr", refId: agentPrRow.id, refRepo: agentPrRow.repoFullName, source: "pr_comment", body, authorUserId: null, authorExternal: { githubLogin: actor?.login, githubCommentUrl: commentUrl ?? undefined, }, orgId: install?.orgId ?? null, projectId: install?.projectId ?? null, }); } function describeAgentPrEvent( event: string, payload: WebhookPayload, ): { kind: string | null; summary: string | null; actor: GhActor } { if (event === "pull_request") { const action = payload.action ?? ""; const actor = payload.sender ?? payload.pull_request?.user ?? null; if (action === "closed") { const merged = payload.pull_request?.merged === true; return { kind: merged ? "pr_merged" : "pr_closed", summary: merged ? "PR merged" : "PR closed", actor: merged ? (payload.pull_request?.merged_by ?? actor) : actor, }; } if (action === "reopened") return { kind: "pr_reopened", summary: "PR reopened", actor }; if (action === "edited") return { kind: "pr_edited", summary: "PR edited", actor }; if (action === "ready_for_review") { return { kind: "pr_ready_for_review", summary: "Marked ready for review", actor }; } if (action === "converted_to_draft") { return { kind: "pr_converted_to_draft", summary: "Converted to draft", actor }; } return { kind: null, summary: null, actor: null }; } if (event === "pull_request_review") { if (payload.action !== "submitted") return { kind: null, summary: null, actor: null }; const state = payload.review?.state ?? "commented"; const summary = `Review submitted (${state})`; return { kind: `review_${state}`, summary, actor: payload.review?.user ?? payload.sender ?? null, }; } if (event === "pull_request_review_comment") { if (payload.action !== "created") return { kind: null, summary: null, actor: null }; return { kind: "review_comment", summary: "Inline review comment", actor: payload.comment?.user ?? payload.sender ?? null, }; } if (event === "issue_comment") { if (payload.action !== "created") return { kind: null, summary: null, actor: null }; return { kind: "issue_comment", summary: "Comment on PR", actor: payload.comment?.user ?? payload.sender ?? null, }; } if (event === "push") { const count = payload.commits?.length ?? 0; return { kind: "commit_pushed", summary: `${count} commit${count === 1 ? "" : "s"} pushed`, actor: payload.sender ?? null, }; } return { kind: null, summary: null, actor: null }; } function toStoredRepo(r: GhRepo): StoredRepo { return { id: r.id, fullName: r.full_name, private: r.private }; } // Upserts an installation row. Returns the row's PK id so callers can patch // it (e.g. capture installer commit author). // // Both project- and org-scoped shapes use a partial unique index on the // active rows for their respective key tuple. onConflict targets need the // same WHERE clause as the index so postgres can match. // // The `set` payloads are effectively no-ops (any conflicting row already // has revoked_at IS NULL by virtue of the partial-index WHERE clause; org_id // is determined by project_id for the project-scoped case). They exist only // because `DO UPDATE` makes RETURNING fire for the existing row, where // `DO NOTHING` would return no row and force a second SELECT to get the id. async function upsertInstallation(args: { orgId: string; projectId: string | null; installationId: number; }): Promise { if (args.projectId) { const [row] = await db .insert(schema.githubInstallations) .values({ orgId: args.orgId, projectId: args.projectId, installationId: args.installationId, }) .onConflictDoUpdate({ target: [schema.githubInstallations.projectId, schema.githubInstallations.installationId], targetWhere: sql`project_id IS NOT NULL AND revoked_at IS NULL`, set: { orgId: args.orgId, revokedAt: null }, }) .returning({ id: schema.githubInstallations.id }); if (!row) throw new Error("upsert returned no row"); return row.id; } const [row] = await db .insert(schema.githubInstallations) .values({ orgId: args.orgId, projectId: null, installationId: args.installationId, }) .onConflictDoUpdate({ target: [schema.githubInstallations.orgId, schema.githubInstallations.installationId], targetWhere: sql`project_id IS NULL AND revoked_at IS NULL`, set: { revokedAt: null }, }) .returning({ id: schema.githubInstallations.id }); if (!row) throw new Error("insert returned no row"); return row.id; } async function maybeCaptureInstallerIdentity(opts: { installationRowId: string; installationId: number; oauthCode: string | null; clientId: string | undefined; clientSecret: string | undefined; redirectUrl: string; }): Promise { if (!opts.oauthCode || !opts.clientId || !opts.clientSecret) return; try { const token = await exchangeGithubOAuthCode({ clientId: opts.clientId, clientSecret: opts.clientSecret, code: opts.oauthCode, redirectUrl: opts.redirectUrl, }); const author = await fetchCommitAuthor(token); await db .update(schema.githubInstallations) .set({ commitAuthorName: author.name, commitAuthorEmail: author.email, commitAuthorGithubLogin: author.githubLogin, commitAuthorGithubId: author.githubId, commitAuthorAvatarUrl: author.avatarUrl, commitAuthorSetAt: new Date(), }) .where(eq(schema.githubInstallations.id, opts.installationRowId)); log.info( { installation_row_id: opts.installationRowId, installation_id: opts.installationId, github_login: author.githubLogin, }, "captured installer commit author from install OAuth", ); } catch (err) { log.warn( { err, installation_row_id: opts.installationRowId, installation_id: opts.installationId }, "failed to capture installer commit author; falling back to default", ); } } function verifyWebhookSignature(body: string, header: string, secret: string): boolean { if (!header.startsWith("sha256=")) return false; const provided = Buffer.from(header.slice("sha256=".length), "hex"); const expected = crypto.createHmac("sha256", secret).update(body).digest(); if (provided.length !== expected.length) return false; return crypto.timingSafeEqual(provided, expected); } function getGithubAppConfig(): { appId: string; privateKey: string } | null { const appId = process.env.GITHUB_APP_ID?.trim(); const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(/\\n/g, "\n") ?? (process.env.GITHUB_APP_PRIVATE_KEY_BASE64 ? Buffer.from(process.env.GITHUB_APP_PRIVATE_KEY_BASE64, "base64").toString("utf8") : undefined); if (!appId || !privateKey) return null; return { appId, privateKey }; } function signGithubAppJwt(appId: string, privateKey: string): string { const now = Math.floor(Date.now() / 1000); const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); const payload = Buffer.from( JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: appId, }), ).toString("base64url"); const signingInput = `${header}.${payload}`; const signature = crypto.sign("RSA-SHA256", Buffer.from(signingInput), privateKey); return `${signingInput}.${signature.toString("base64url")}`; } async function githubRequest(pathname: string, bearerToken: string): Promise { const res = await fetch(`https://api.github.com${pathname}`, { headers: { accept: "application/vnd.github+json", authorization: `Bearer ${bearerToken}`, "x-github-api-version": "2022-11-28", "user-agent": "superlog-api", }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`github GET ${pathname} failed: ${res.status} ${text}`); } return (await res.json()) as T; } type GithubPermission = "read" | "write"; async function createInstallationToken(opts: { installationId: number; permissions: Record; }): Promise { const cfg = getGithubAppConfig(); if (!cfg) throw new Error("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY are required"); const appJwt = signGithubAppJwt(cfg.appId, cfg.privateKey); const res = await fetch( `https://api.github.com/app/installations/${opts.installationId}/access_tokens`, { method: "POST", headers: { accept: "application/vnd.github+json", authorization: `Bearer ${appJwt}`, "content-type": "application/json; charset=utf-8", "x-github-api-version": "2022-11-28", "user-agent": "superlog-api", }, body: JSON.stringify({ permissions: opts.permissions }), }, ); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error( `github POST /app/installations/${opts.installationId}/access_tokens failed: ${res.status} ${text}`, ); } const data = (await res.json()) as { token: string }; return data.token; } async function createInstallationReadToken(installationId: number): Promise { return createInstallationToken({ installationId, permissions: { contents: "read" } }); } async function createInstallationWriteToken(installationId: number): Promise { return createInstallationToken({ installationId, permissions: { contents: "write", pull_requests: "write" }, }); } export async function mergeGithubPullRequest(opts: { installationId: number; repoFullName: string; prNumber: number; method: "squash" | "merge" | "rebase"; }): Promise<{ sha: string | null }> { const token = await createInstallationWriteToken(opts.installationId); const res = await fetch( `https://api.github.com/repos/${opts.repoFullName}/pulls/${opts.prNumber}/merge`, { method: "PUT", headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8", "x-github-api-version": "2022-11-28", "user-agent": "superlog-api", }, body: JSON.stringify({ merge_method: opts.method }), }, ); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`github PUT /pulls/${opts.prNumber}/merge failed: ${res.status} ${text}`); } const body = (await res.json().catch(() => ({}))) as { sha?: string }; return { sha: body.sha ?? null }; } export async function closeAgentPullRequestOnGithub(opts: { installationId: number; repoFullName: string; prNumber: number; }): Promise<{ ok: true } | { ok: false; error: string }> { try { const token = await createInstallationWriteToken(opts.installationId); const res = await fetch( `https://api.github.com/repos/${opts.repoFullName}/pulls/${opts.prNumber}`, { method: "PATCH", headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8", "x-github-api-version": "2022-11-28", "user-agent": "superlog-api", }, body: JSON.stringify({ state: "closed" }), }, ); if (res.ok) return { ok: true }; const text = await res.text().catch(() => ""); return { ok: false, error: `github PATCH /pulls/${opts.prNumber} ${res.status} ${text}` }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } // Safety ceiling on paginated GitHub list calls: 100 pages × 100 per page = // 10k items. High enough to cover every realistic repo/branch count while // still bounding the loop so a pathological account can't spin it forever. const GITHUB_LIST_MAX_PAGES = 100; export async function listCurrentInstallationRepos(installationId: number): Promise { const token = await createInstallationReadToken(installationId); const repos: StoredRepo[] = []; let page = 1; for (; page <= GITHUB_LIST_MAX_PAGES; page += 1) { const data = await githubRequest( `/installation/repositories?per_page=100&page=${page}`, token, ); repos.push(...data.repositories.map(toStoredRepo)); if (data.repositories.length < 100) break; } if (page > GITHUB_LIST_MAX_PAGES) { log.warn( { installationId, cap: GITHUB_LIST_MAX_PAGES * 100 }, "installation repo list hit the pagination cap; results may be truncated", ); } return repos; } // Direct, O(1) repo lookup that doesn't depend on paginating the install's // full repo list. Used by the management-API grant flow: a 1000+ repo install // would otherwise silently 404 anything past page 10 of the list. Returns // null if the install's token can't see this repo (i.e. not covered). export async function fetchInstallationRepoById( installationId: number, repoId: number, ): Promise { const token = await createInstallationReadToken(installationId); try { const data = await githubRequest<{ id: number; full_name: string; private: boolean }>( `/repositories/${repoId}`, token, ); return { id: data.id, fullName: data.full_name, private: data.private }; } catch (err) { if ( err instanceof Error && (err.message.includes("failed: 404") || err.message.includes("failed: 403")) ) { return null; } throw err; } } async function fetchRepoBranchInfo( installationId: number, repoFullName: string, ): Promise { const token = await createInstallationReadToken(installationId); const repo = await githubRequest<{ default_branch?: string }>(`/repos/${repoFullName}`, token); const branches: string[] = []; let page = 1; for (; page <= GITHUB_LIST_MAX_PAGES; page += 1) { const data = await githubRequest<{ name: string }[]>( `/repos/${repoFullName}/branches?per_page=100&page=${page}`, token, ); branches.push(...data.map((b) => b.name)); if (data.length < 100) break; } if (page > GITHUB_LIST_MAX_PAGES) { log.warn( { repo: repoFullName, cap: GITHUB_LIST_MAX_PAGES * 100 }, "repo branch list hit the pagination cap; results may be truncated", ); } return { defaultBranch: repo.default_branch ?? null, branches }; } // Lists the branch set the agent could target for a project: the union of // branches across every enabled repo the project's GitHub installation(s) can // reach. `errored` is true when there were repos to inspect but every lookup // failed (token/network), so callers can disable the picker instead of // pretending the repo simply has no branches. export async function listProjectRepoBranches( projectId: string, ): Promise<{ branches: RepoBranch[]; errored: boolean }> { const accessible = await listAccessibleGithubInstallsForProject(projectId); const perRepo: RepoBranchInfo[] = []; const seenRepos = new Set(); let sawError = false; for (const { installation: row, allowedRepoIds } of accessible) { if (!row.agentEnabled) continue; let repos: StoredRepo[]; try { repos = await listCurrentInstallationRepos(row.installationId); } catch (err) { sawError = true; log.warn( { err, projectId, installationId: row.installationId }, "github repository listing failed while loading branches", ); continue; } const grantSet = allowedRepoIds === null ? null : new Set(allowedRepoIds); const repoAccess = normalizeRepoAccess(row.repoAccess); for (const repo of repos) { if (grantSet && !grantSet.has(repo.id)) continue; if (!isRepoEnabled(repoAccess, repo.id)) continue; if (seenRepos.has(repo.fullName)) continue; seenRepos.add(repo.fullName); try { perRepo.push(await fetchRepoBranchInfo(row.installationId, repo.fullName)); } catch (err) { sawError = true; log.warn({ err, projectId, repo: repo.fullName }, "github branch listing failed for repo"); } } } // Distinguish "GitHub call failed" from "project genuinely has no branches": // only flag errored when something broke AND we produced nothing, so a // partial failure still returns the branches we did manage to load. return { branches: mergeRepoBranches(perRepo), errored: sawError && perRepo.length === 0 }; } // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance. export function mountGithubAuthed(app: Hono): void { const appSlug = process.env.GITHUB_APP_SLUG; const oauthClientId = process.env.GITHUB_OAUTH_CLIENT_ID ?? process.env.GITHUB_APP_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID; const oauthClientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET ?? process.env.GITHUB_APP_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET; const stateSecret = process.env.STATE_SIGNING_SECRET; const authorRedirectUrl = process.env.GITHUB_AUTHOR_OAUTH_REDIRECT_URL ?? `${process.env.BETTER_AUTH_URL ?? "http://localhost:4100"}/github/author/callback`; async function resolveActiveContext( userId: string | undefined, preferredOrgId?: string | null, ): Promise<{ orgId: string; projectId: string } | null> { if (!userId) return null; const ctx = await resolveActiveOrgContext({ userId, preferredOrgId: preferredOrgId ?? null, }).catch(() => null); if (!ctx) return null; return { orgId: ctx.org.id, projectId: ctx.project.id }; } app.get("/api/github/installation", async (c) => { const active = await resolveActiveContext( c.var.userId as string | undefined, c.var.orgId as string | null | undefined, ); if (!active) return c.json({ installed: false }); const accessible = await listAccessibleGithubInstallsForProject(active.projectId); if (accessible.length === 0) return c.json({ installed: false }); const installations: GithubInstallationSummary[] = []; let repoVerificationUnavailable = false; for (const { installation: row, allowedRepoIds } of accessible) { let repos = Array.isArray(row.repos) ? (row.repos as StoredRepo[]) : []; try { repos = await listCurrentInstallationRepos(row.installationId); } catch (err) { log.warn( { err, projectId: active.projectId, installationId: row.installationId }, "github repository listing failed", ); if ( err instanceof Error && err.message.includes("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY") ) { repoVerificationUnavailable = true; } if (isMissingGithubInstallation(err)) { await db .update(schema.githubInstallations) .set({ revokedAt: new Date() }) .where(eq(schema.githubInstallations.id, row.id)); continue; } } const repoAccess = normalizeRepoAccess(row.repoAccess); // Granted org-scoped installs are restricted to the project's allowed // repo set; project-owned installs see everything in the install. const grantSet = allowedRepoIds === null ? null : new Set(allowedRepoIds); const scopedRepos = grantSet === null ? repos : repos.filter((repo) => grantSet.has(repo.id)); installations.push({ installationId: row.installationId, accountLogin: row.accountLogin, accountType: row.accountType, enabled: row.agentEnabled, manageUrl: githubInstallationManageUrl(row), repos: scopedRepos.map((repo) => ({ ...repo, enabled: isRepoEnabled(repoAccess, repo.id), })), }); } if (installations.length === 0) return c.json({ installed: false }); const authorRow = accessible .map(({ installation }) => installation) .find((row) => row.commitAuthorName && row.commitAuthorEmail); const repos = dedupeRepoSummaries( installations.flatMap((installation) => installation.repos.map((repo) => ({ ...repo, enabled: installation.enabled && repo.enabled, })), ), ); const firstInstallation = installations[0]; if (!firstInstallation) return c.json({ installed: false }); return c.json({ installed: true, installationId: firstInstallation.installationId, accountLogin: firstInstallation.accountLogin, manageUrl: firstInstallation.manageUrl, repoVerificationUnavailable, installations, repos, commitAuthor: authorRow?.commitAuthorName && authorRow.commitAuthorEmail ? { source: "github_user", name: authorRow.commitAuthorName, email: authorRow.commitAuthorEmail, githubLogin: authorRow.commitAuthorGithubLogin, githubId: authorRow.commitAuthorGithubId, avatarUrl: authorRow.commitAuthorAvatarUrl, setAt: authorRow.commitAuthorSetAt?.toISOString() ?? null, } : { source: "app", name: DEFAULT_COMMIT_AUTHOR.name, email: DEFAULT_COMMIT_AUTHOR.email, githubLogin: null, githubId: null, avatarUrl: null, setAt: null, }, }); }); app.post("/api/github/skip", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); await db .update(schema.orgs) .set({ githubSetupSkippedAt: new Date() }) .where(eq(schema.orgs.id, ctx.orgId)); return c.json({ ok: true, orgId: ctx.orgId }); }); // Lands here after Better Auth completes GitHub OAuth. If the user's org // already has a GitHub App install we redirect back to the web app; if not, // we kick off the App install flow inline so signing in with GitHub // doubles as installing the App. The callbackURL passed from the web // client points at this endpoint; the cookie session is set by Better Auth // before the redirect lands here so `c.var.userId` is populated by the // /api/* middleware. app.get("/api/github/post-signin", async (c) => { const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173"; const active = await resolveActiveContext( c.var.userId as string | undefined, c.var.orgId as string | null | undefined, ); if (!active) return c.redirect(`${webOrigin}/`, 302); const installs = await listAccessibleGithubInstallsForProject(active.projectId); if (installs.length > 0) return c.redirect(`${webOrigin}/`, 302); if (!appSlug || !stateSecret) { // GitHub app not configured in this env — just send the user home so // they aren't stuck on a blank page. return c.redirect(`${webOrigin}/`, 302); } const state = signState("web", active.projectId, stateSecret); const url = new URL(`https://github.com/apps/${appSlug}/installations/new`); url.searchParams.set("state", state); return c.redirect(url.toString(), 302); }); app.post("/api/github/install-url", async (c) => { if (!appSlug || !stateSecret) { return c.json({ error: "github app not configured" }, 503); } const active = await resolveActiveContext( c.var.userId as string | undefined, c.var.orgId as string | null | undefined, ); if (!active) return c.json({ error: "no org for user" }, 404); // "web" state value is now the projectId; the callback resolves the org // from the project row. const state = signState("web", active.projectId, stateSecret); const url = new URL(`https://github.com/apps/${appSlug}/installations/new`); url.searchParams.set("state", state); log.info( { org_id: active.orgId, project_id: active.projectId, app_slug: appSlug }, "github install url created", ); return c.json({ url: url.toString() }); }); app.post("/api/github/repo-access", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); const body = (await c.req.json().catch(() => null)) as { installationId?: unknown; enabled?: unknown; repoId?: unknown; repoEnabled?: unknown; } | null; const installationId = Number(body?.installationId); if (!Number.isFinite(installationId) || installationId <= 0) { return c.json({ error: "invalid installation id" }, 400); } const row = await db.query.githubInstallations.findFirst({ where: and( eq(schema.githubInstallations.projectId, ctx.projectId), eq(schema.githubInstallations.installationId, installationId), isNull(schema.githubInstallations.revokedAt), ), }); if (!row) return c.json({ error: "github installation not found" }, 404); const patch: { agentEnabled?: boolean; repoAccess?: RepoAccess; } = {}; if (typeof body?.enabled === "boolean") { patch.agentEnabled = body.enabled; } if (body?.repoId !== undefined || body?.repoEnabled !== undefined) { const repoId = Number(body?.repoId); if (!Number.isFinite(repoId) || repoId <= 0 || typeof body?.repoEnabled !== "boolean") { return c.json({ error: "invalid repo access update" }, 400); } patch.repoAccess = setRepoEnabled(row.repoAccess, repoId, body.repoEnabled); } if (Object.keys(patch).length === 0) { return c.json({ error: "no repo access change provided" }, 400); } await db .update(schema.githubInstallations) .set(patch) .where(eq(schema.githubInstallations.id, row.id)); return c.json({ ok: true }); }); app.post("/api/github/author-login-url", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); if (!oauthClientId || !oauthClientSecret || !stateSecret) { if (!appSlug || !stateSecret) { return c.json({ error: "github oauth not configured" }, 503); } log.warn( { org_id: ctx.orgId, project_id: ctx.projectId }, "github oauth not configured; falling back to app installation flow", ); const state = signState("web", ctx.projectId, stateSecret); const url = new URL(`https://github.com/apps/${appSlug}/installations/new`); url.searchParams.set("state", state); return c.json({ url: url.toString() }); } const state = signAuthorState( { orgId: ctx.orgId, projectId: ctx.projectId, userId: ctx.userId, purpose: "author" }, stateSecret, ); const url = new URL("https://github.com/login/oauth/authorize"); url.searchParams.set("client_id", oauthClientId); url.searchParams.set("redirect_uri", authorRedirectUrl); url.searchParams.set("scope", "read:user user:email"); url.searchParams.set("state", state); return c.json({ url: url.toString() }); }); app.post("/api/github/access-login-url", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); if (!oauthClientId || !oauthClientSecret || !stateSecret) { if (!appSlug || !stateSecret) { return c.json({ error: "github oauth not configured" }, 503); } log.warn( { org_id: ctx.orgId, project_id: ctx.projectId }, "github oauth not configured; falling back to app installation flow", ); const state = signState("web", ctx.projectId, stateSecret); const url = new URL(`https://github.com/apps/${appSlug}/installations/new`); url.searchParams.set("state", state); return c.json({ url: url.toString() }); } const state = signAuthorState( { orgId: ctx.orgId, projectId: ctx.projectId, userId: ctx.userId, purpose: "access" }, stateSecret, ); const url = new URL("https://github.com/login/oauth/authorize"); url.searchParams.set("client_id", oauthClientId); url.searchParams.set("redirect_uri", authorRedirectUrl); url.searchParams.set("scope", "read:user user:email"); url.searchParams.set("state", state); return c.json({ url: url.toString() }); }); app.post("/api/github/commit-author/reset", async (c) => { const ctx = await resolveUserOrg(c); if (!ctx) return c.json({ error: "no org for user" }, 404); await db .update(schema.githubInstallations) .set({ commitAuthorName: null, commitAuthorEmail: null, commitAuthorGithubLogin: null, commitAuthorGithubId: null, commitAuthorAvatarUrl: null, commitAuthorSetByUserId: null, commitAuthorSetAt: null, }) .where( and( eq(schema.githubInstallations.projectId, ctx.projectId), isNull(schema.githubInstallations.revokedAt), ), ); return c.json({ ok: true }); }); } // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance. export function mountGithubAuthorOAuth(app: Hono): void { const clientId = process.env.GITHUB_OAUTH_CLIENT_ID ?? process.env.GITHUB_APP_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID; const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET ?? process.env.GITHUB_APP_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET; const redirectUrl = process.env.GITHUB_AUTHOR_OAUTH_REDIRECT_URL ?? `${process.env.BETTER_AUTH_URL ?? "http://localhost:4100"}/github/author/callback`; const stateSecret = process.env.STATE_SIGNING_SECRET; const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173"; if (!clientId || !clientSecret) { log.warn("GITHUB_OAUTH_CLIENT_ID/SECRET not set — /github/author/callback disabled"); } app.get("/github/author/callback", async (c) => { if (!clientId || !clientSecret || !stateSecret) { return c.json({ error: "github oauth not configured" }, 503); } const err = c.req.query("error"); if (err) return c.redirect(`${webOrigin}/settings?github_author=denied`, 302); const code = c.req.query("code"); const state = c.req.query("state") ?? ""; if (!code) return c.redirect(`${webOrigin}/settings?github_author=error`, 302); const decoded = verifyAuthorState(state, stateSecret); if (!decoded) return c.json({ error: "invalid state" }, 400); let token: string; try { token = await exchangeGithubOAuthCode({ clientId, clientSecret, code, redirectUrl }); } catch (e) { log.error({ err: e, org_id: decoded.orgId }, "github author oauth exchange failed"); return c.redirect(`${webOrigin}/settings?github_author=error`, 302); } let author: CommitAuthor | null = null; let installations: GithubUserInstallation[]; let reposByInstallation: Map; try { [author, installations] = decoded.purpose === "author" ? await Promise.all([fetchCommitAuthor(token), listUserAccessibleInstallations(token)]) : [null, await listUserAccessibleInstallations(token)]; reposByInstallation = new Map( await Promise.all( installations.map( async (installation) => [installation.id, await listUserAccessibleRepos(token, installation.id)] as const, ), ), ); } catch (e) { log.error({ err: e, org_id: decoded.orgId }, "github author fetch failed"); return c.redirect(`${webOrigin}/settings?github_author=error`, 302); } if (installations.length === 0) { log.info( { org_id: decoded.orgId, user_id: decoded.userId, github_login: author?.githubLogin }, "github oauth connected but no accessible app installation found", ); const param = decoded.purpose === "author" ? "github_author" : "github"; return c.redirect(`${webOrigin}/settings?${param}=no_install`, 302); } const upserted = await db.transaction(async (tx) => { const rows: Array<{ id: string }> = []; for (const installation of installations) { const repos = reposByInstallation.get(installation.id) ?? []; const inserted = await tx .insert(schema.githubInstallations) .values({ orgId: decoded.orgId, projectId: decoded.projectId, installationId: installation.id, accountLogin: installation.account?.login ?? null, accountType: installation.account?.type ?? null, repos, ...(author ? { commitAuthorName: author.name, commitAuthorEmail: author.email, commitAuthorGithubLogin: author.githubLogin, commitAuthorGithubId: author.githubId, commitAuthorAvatarUrl: author.avatarUrl, commitAuthorSetByUserId: decoded.userId, commitAuthorSetAt: new Date(), } : {}), revokedAt: null, }) .onConflictDoUpdate({ target: [ schema.githubInstallations.projectId, schema.githubInstallations.installationId, ], targetWhere: sql`project_id IS NOT NULL AND revoked_at IS NULL`, set: { orgId: decoded.orgId, accountLogin: installation.account?.login ?? null, accountType: installation.account?.type ?? null, repos, ...(author ? { commitAuthorName: author.name, commitAuthorEmail: author.email, commitAuthorGithubLogin: author.githubLogin, commitAuthorGithubId: author.githubId, commitAuthorAvatarUrl: author.avatarUrl, commitAuthorSetByUserId: decoded.userId, commitAuthorSetAt: new Date(), } : {}), revokedAt: null, }, }) .returning({ id: schema.githubInstallations.id }); rows.push(...inserted); } if (author) { await tx .update(schema.githubInstallations) .set({ commitAuthorName: author.name, commitAuthorEmail: author.email, commitAuthorGithubLogin: author.githubLogin, commitAuthorGithubId: author.githubId, commitAuthorAvatarUrl: author.avatarUrl, commitAuthorSetByUserId: decoded.userId, commitAuthorSetAt: new Date(), }) .where( and( eq(schema.githubInstallations.projectId, decoded.projectId), isNull(schema.githubInstallations.revokedAt), ), ); } return rows; }); log.info( { org_id: decoded.orgId, user_id: decoded.userId, github_login: author?.githubLogin, purpose: decoded.purpose, installation_ids: installations.map((installation) => installation.id), installation_row_ids: upserted.map((row) => row.id), }, "github installations reconciled from user oauth", ); void syncLoopsContactsForOrg({ orgId: decoded.orgId, appUrl: webOrigin }).catch((err) => { log.warn({ err, org_id: decoded.orgId }, "loops contact sync failed after github connect"); }); if (decoded.purpose === "author") { return c.redirect(`${webOrigin}/settings?github_author=connected`, 302); } // access flow: send back to root so the gate can pick up and show onboarding return c.redirect(`${webOrigin}/`, 302); }); } async function exchangeGithubOAuthCode(opts: { clientId: string; clientSecret: string; code: string; redirectUrl: string; }): Promise { const res = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { accept: "application/json", "content-type": "application/json", "user-agent": "superlog-api", }, body: JSON.stringify({ client_id: opts.clientId, client_secret: opts.clientSecret, code: opts.code, redirect_uri: opts.redirectUrl, }), }); const data = (await res.json()) as GithubOAuthTokenResponse; if (!res.ok || !data.access_token) { throw new Error(data.error_description ?? data.error ?? `github oauth failed: ${res.status}`); } return data.access_token; } async function githubUserRequest(pathname: string, token: string): Promise { const res = await fetch(`https://api.github.com${pathname}`, { headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "x-github-api-version": "2022-11-28", "user-agent": "superlog-api", }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`github GET ${pathname} failed: ${res.status} ${text}`); } return (await res.json()) as T; } async function fetchCommitAuthor(token: string): Promise { const viewer = await githubUserRequest("/user", token); let emails: GithubEmailResponse[] = []; try { emails = await githubUserRequest("/user/emails", token); } catch (err) { log.warn( { err, github_login: viewer.login }, "github user emails unavailable; falling back to profile or noreply email", ); } const selectedEmail = emails.find((email) => email.primary && email.verified)?.email ?? emails.find((email) => email.verified)?.email ?? viewer.email ?? `${viewer.id}+${viewer.login}@users.noreply.github.com`; const name = sanitizeGitName(viewer.name ?? viewer.login); const email = sanitizeGitEmail(selectedEmail); if (!name || !email) throw new Error("github profile did not provide a usable git identity"); return { name, email, githubLogin: viewer.login, githubId: viewer.id, avatarUrl: viewer.avatar_url, }; } async function listUserAccessibleInstallations(token: string): Promise { const installs: GithubUserInstallation[] = []; for (let page = 1; page <= 10; page += 1) { const data = await githubUserRequest( `/user/installations?per_page=100&page=${page}`, token, ); installs.push(...data.installations); if (data.installations.length < 100) break; } return installs.filter((installation) => !installation.suspended_at); } function githubInstallationManageUrl(installation: { installationId: number; accountLogin: string | null; accountType: string | null; }): string { if (installation.accountType === "Organization" && installation.accountLogin) { return `https://github.com/organizations/${installation.accountLogin}/settings/installations/${installation.installationId}`; } return `https://github.com/settings/installations/${installation.installationId}`; } function isMissingGithubInstallation(err: unknown): boolean { return err instanceof Error && err.message.includes("failed: 404"); } function normalizeRepoAccess(value: unknown): RepoAccess { if (!value || typeof value !== "object") return {}; const disabledRepoIds = (value as RepoAccess).disabledRepoIds; if (!Array.isArray(disabledRepoIds)) return {}; return { disabledRepoIds: [ ...new Set(disabledRepoIds.filter((id): id is number => Number.isFinite(id) && id > 0)), ], }; } function isRepoEnabled(repoAccess: RepoAccess, repoId: number): boolean { return !(repoAccess.disabledRepoIds ?? []).includes(repoId); } function setRepoEnabled(value: unknown, repoId: number, enabled: boolean): RepoAccess { const repoAccess = normalizeRepoAccess(value); const disabled = new Set(repoAccess.disabledRepoIds ?? []); if (enabled) { disabled.delete(repoId); } else { disabled.add(repoId); } return { disabledRepoIds: [...disabled].sort((a, b) => a - b) }; } function dedupeRepoSummaries(repos: GithubRepoSummary[]): GithubRepoSummary[] { const seen = new Set(); const deduped: GithubRepoSummary[] = []; for (const repo of repos) { const existing = deduped.find((item) => item.fullName === repo.fullName); if (existing) { existing.enabled = existing.enabled || repo.enabled; continue; } if (!seen.has(repo.fullName)) { seen.add(repo.fullName); deduped.push({ ...repo }); } } return deduped; } async function listUserAccessibleRepos( token: string, installationId: number, ): Promise { const repos: StoredRepo[] = []; for (let page = 1; page <= 10; page += 1) { const data = await githubUserRequest( `/user/installations/${installationId}/repositories?per_page=100&page=${page}`, token, ); repos.push(...data.repositories.map(toStoredRepo)); if (data.repositories.length < 100) break; } return repos; } function sanitizeGitName(value: string): string | null { const cleaned = value .replace(/[\r\n<>]/g, " ") .replace(/\s+/g, " ") .trim(); return cleaned || null; } function sanitizeGitEmail(value: string): string | null { const cleaned = value.trim(); if (!cleaned || /[\r\n<>]/.test(cleaned) || !cleaned.includes("@")) return null; return cleaned; } async function resolveUserOrg( c: Context<{ Variables: Vars }>, ): Promise<{ userId: string; orgId: string; projectId: string } | null> { const userId = c.var.userId as string | undefined; if (!userId) return null; const ctx = await resolveActiveOrgContext({ userId, preferredOrgId: c.var.orgId as string | null | undefined, }).catch(() => null); if (!ctx) return null; return { userId: ctx.user.id, orgId: ctx.org.id, projectId: ctx.project.id }; } type AuthorStatePayload = { orgId: string; projectId: string; userId: string; purpose: "access" | "author"; }; function signAuthorState(p: AuthorStatePayload, secret: string): string { const body = `${p.orgId}.${p.projectId}.${p.userId}.${p.purpose}.${Date.now()}`; const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url"); return `${Buffer.from(body, "utf8").toString("base64url")}.${sig}`; } function verifyAuthorState(state: string, secret: string): AuthorStatePayload | null { const [payloadB64, sig] = state.split("."); if (!payloadB64 || !sig) return null; const body = Buffer.from(payloadB64, "base64url").toString("utf8"); const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url"); const provided = Buffer.from(sig); const expectedBuf = Buffer.from(expected); if (provided.length !== expectedBuf.length) return null; if (!crypto.timingSafeEqual(provided, expectedBuf)) return null; // Current format is `${orgId}.${projectId}.${userId}.${purpose}.${ts}` (5 // parts). Old 3- or 4-part states (no projectId) are rejected — 10-min TTL // means worst case is users mid-flow during deploy clicking again. const parts = body.split("."); if (parts.length !== 5) return null; const [orgId, projectId, userId, purpose, tsRaw] = parts as [ string, string, string, string, string, ]; if (!orgId || !projectId || !userId || !tsRaw) return null; if (purpose !== "access" && purpose !== "author") return null; const ts = Number(tsRaw); if (!Number.isFinite(ts) || Date.now() - ts > 10 * 60 * 1000) return null; return { orgId, projectId, userId, purpose }; } type StateKind = "cli" | "web"; function signState(kind: StateKind, value: string, secret: string): string { const payload = `${kind}.${value}.${Date.now()}`; const sig = crypto.createHmac("sha256", secret).update(payload).digest("base64url"); return `${base64url(payload)}.${sig}`; } function verifyState(state: string, secret: string): { kind: StateKind; value: string } | null { const [payloadB64, sig] = state.split("."); if (!payloadB64 || !sig) return null; const payload = Buffer.from(payloadB64, "base64url").toString("utf8"); const expected = crypto.createHmac("sha256", secret).update(payload).digest("base64url"); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; const [kind, value, tsRaw] = payload.split("."); if ((kind !== "cli" && kind !== "web") || !value || !tsRaw) return null; const ts = Number(tsRaw); // Match device code TTL: 10 min window. if (!Number.isFinite(ts) || Date.now() - ts > 10 * 60 * 1000) return null; return { kind, value }; } // Management-API install state. Used when a platform backend mints an install // URL via /api/v1/...; the state encodes scope (org vs project) and an // optional return URL the user is bounced to after install. 30-min TTL since // the customer's operator might not click immediately. const MGMT_STATE_PREFIX = "mgmt-v1"; export type GithubMgmtState = | { scope: "org"; orgId: string; returnUrl: string | null } | { scope: "project"; projectId: string; returnUrl: string | null }; function signMgmtState(payload: GithubMgmtState, secret: string): string { const body = `${MGMT_STATE_PREFIX}.${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}.${Date.now()}`; const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url"); return `${Buffer.from(body, "utf8").toString("base64url")}.${sig}`; } function verifyMgmtState(state: string, secret: string): GithubMgmtState | null { const [payloadB64, sig] = state.split("."); if (!payloadB64 || !sig) return null; const body = Buffer.from(payloadB64, "base64url").toString("utf8"); const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url"); const provided = Buffer.from(sig); const expectedBuf = Buffer.from(expected); if (provided.length !== expectedBuf.length) return null; if (!crypto.timingSafeEqual(provided, expectedBuf)) return null; const parts = body.split("."); if (parts.length !== 3 || parts[0] !== MGMT_STATE_PREFIX) return null; const ts = Number(parts[2]); if (!Number.isFinite(ts) || Date.now() - ts > 30 * 60 * 1000) return null; try { const json = Buffer.from(parts[1] as string, "base64url").toString("utf8"); const parsed = JSON.parse(json) as Partial; if (parsed.scope === "org" && typeof parsed.orgId === "string") { return { scope: "org", orgId: parsed.orgId, returnUrl: parsed.returnUrl ?? null }; } if (parsed.scope === "project" && typeof parsed.projectId === "string") { return { scope: "project", projectId: parsed.projectId, returnUrl: parsed.returnUrl ?? null }; } return null; } catch { return null; } } // Public helper used by the management API to mint install URLs without // touching state internals. export function buildGithubMgmtInstallUrl(args: GithubMgmtState): { url: string } | null { const appSlug = process.env.GITHUB_APP_SLUG; const stateSecret = process.env.STATE_SIGNING_SECRET; if (!appSlug || !stateSecret) return null; const state = signMgmtState(args, stateSecret); const url = new URL(`https://github.com/apps/${appSlug}/installations/new`); url.searchParams.set("state", state); return { url: url.toString() }; } function base64url(s: string): string { return Buffer.from(s, "utf8").toString("base64url"); }