Experimente / superlog /apps /api /src /github.ts
smlflg's picture
Initial public upload from Projekte/Experimente
95b671e verified
Raw History Blame Contribute Delete
75.4 kB
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<any>): 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<typeof schema.agentPullRequests.$inferInsert> = {
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<string, unknown>,
providerEventId: delivery,
occurredAt: now,
})
.onConflictDoNothing();
}
async function resolveIncidentForMergedAgentPr(opts: {
agentPr: schema.AgentPullRequest;
mergedAt: Date;
mergedByLogin: string | null;
}): Promise<void> {
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<void> {
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<string> {
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<void> {
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<T>(pathname: string, bearerToken: string): Promise<T> {
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<string, GithubPermission>;
}): Promise<string> {
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<string> {
return createInstallationToken({ installationId, permissions: { contents: "read" } });
}
async function createInstallationWriteToken(installationId: number): Promise<string> {
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<StoredRepo[]> {
const token = await createInstallationReadToken(installationId);
const repos: StoredRepo[] = [];
let page = 1;
for (; page <= GITHUB_LIST_MAX_PAGES; page += 1) {
const data = await githubRequest<GithubInstallationReposResponse>(
`/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<StoredRepo | null> {
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<RepoBranchInfo> {
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<string>();
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<any>): 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<any>): 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<number, StoredRepo[]>;
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<string> {
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<T>(pathname: string, token: string): Promise<T> {
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<CommitAuthor> {
const viewer = await githubUserRequest<GithubViewerResponse>("/user", token);
let emails: GithubEmailResponse[] = [];
try {
emails = await githubUserRequest<GithubEmailResponse[]>("/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<GithubUserInstallation[]> {
const installs: GithubUserInstallation[] = [];
for (let page = 1; page <= 10; page += 1) {
const data = await githubUserRequest<GithubUserInstallationsResponse>(
`/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<string>();
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<StoredRepo[]> {
const repos: StoredRepo[] = [];
for (let page = 1; page <= 10; page += 1) {
const data = await githubUserRequest<GithubUserInstallationReposResponse>(
`/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<GithubMgmtState>;
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");
}