diff --git a/apps/web/.env.example b/apps/web/.env.example index a39ca1db..8435d0c4 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -66,6 +66,9 @@ MERGE_GATEWAY_API_KEY= # OPENAI_API_KEY= # ANTHROPIC_API_KEY= # GOOGLE_GENERATIVE_AI_API_KEY= +# Embeddings default to direct OpenAI when OPENAI_API_KEY is set. Vercel's +# free tier rejects this model; opt into a paid gateway explicitly if available. +# EMBEDDING_PROVIDER=openai # openai | vercel | merge PINECONE_DB_API_KEY= # ─── Inngest (background AI reviews) ─── @@ -96,6 +99,8 @@ PINECONE_DB_API_KEY= # INNGEST_DEV=1 # INNGEST_EVENT_KEY= # INNGEST_SIGNING_KEY= +# Delay used to collapse rapid PR synchronize events (1–30 seconds; default 5). +# PR_REVIEW_DEBOUNCE_SECONDS=5 # ─── Composio (Slack + Linear integrations) ─── # OAuth for Slack/Linear is hosted by Composio. Tokens never hit our DB. diff --git a/apps/web/app/api/reviews/trigger/route.ts b/apps/web/app/api/reviews/trigger/route.ts index a0f52843..4e6f1d32 100644 --- a/apps/web/app/api/reviews/trigger/route.ts +++ b/apps/web/app/api/reviews/trigger/route.ts @@ -7,7 +7,7 @@ import prisma from "@super/db" /** * POST /api/reviews/trigger * Body: { owner, repo, prNumber } - * Manually queue an AI review for a connected repo (useful when webhooks can't reach localhost). + * Manually run an AI review for a connected repo (useful when webhooks can't reach localhost). */ export async function POST(req: NextRequest) { try { @@ -49,6 +49,7 @@ export async function POST(req: NextRequest) { const result = await reviewPullRequest(owner, repo, prNumber, { userId: session.user.id, source: "api_trigger", + wait: true, }) return NextResponse.json(result) } catch (error) { diff --git a/apps/web/app/dashboard/pull-requests/[id]/page.tsx b/apps/web/app/dashboard/pull-requests/[id]/page.tsx index f8ae1511..f986ebc9 100644 --- a/apps/web/app/dashboard/pull-requests/[id]/page.tsx +++ b/apps/web/app/dashboard/pull-requests/[id]/page.tsx @@ -14,6 +14,16 @@ import { } from "@/modules/pull-requests/components/pr-workspace" import { toast } from "sonner" +const STALE_PENDING_MS = 10 * 60 * 1000 + +function isStalePending(review: { + status: string + updatedAt?: Date | string +} | null | undefined) { + if (review?.status !== "pending" || !review.updatedAt) return false + return Date.now() - new Date(review.updatedAt).getTime() >= STALE_PENDING_MS +} + function hasCompletedReview(review: { status: string review?: string @@ -73,15 +83,12 @@ export default function ReviewDetailPage(props: { const queueMutation = useMutation({ mutationFn: () => queueReview(id), - onSuccess: (result) => { - toast.success(result.message || "AI review queued") - queryClient.setQueryData(["review", id], (prev: unknown) => - prev && typeof prev === "object" - ? { ...(prev as object), status: "pending" } - : prev, - ) - queryClient.invalidateQueries({ queryKey: ["review", id] }) - queryClient.invalidateQueries({ queryKey: ["reviews"] }) + onSuccess: async (result) => { + toast.success(result.message || "AI review completed") + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["review", id] }), + queryClient.invalidateQueries({ queryKey: ["reviews"] }), + ]) }, onError: (error) => { toast.error( @@ -103,8 +110,9 @@ export default function ReviewDetailPage(props: { // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally once when review first loads }, [isLoading, review?.status, review?.prState, review?.review]) + const stalePending = isStalePending(review) const isGenerating = - queueMutation.isPending || review?.status === "pending" + queueMutation.isPending || (review?.status === "pending" && !stalePending) const completed = hasCompletedReview(review) const showGenerate = !!review && @@ -112,6 +120,7 @@ export default function ReviewDetailPage(props: { !isGenerating && (review.status === "unreviewed" || review.status === "failed" || + stalePending || !review.review?.trim()) if (!isLoading && !review) { diff --git a/apps/web/inngest/functions/ai-review.ts b/apps/web/inngest/functions/ai-review.ts index 7b3d1389..9ba31d37 100644 --- a/apps/web/inngest/functions/ai-review.ts +++ b/apps/web/inngest/functions/ai-review.ts @@ -4,6 +4,14 @@ import { runGeneratePrReview, } from "@/modules/ai/lib/generate-pr-review" +function reviewDebouncePeriod(): `${number}s` { + const configured = Number.parseInt(process.env.PR_REVIEW_DEBOUNCE_SECONDS ?? "5", 10) + const seconds = Number.isFinite(configured) + ? Math.min(Math.max(configured, 1), 30) + : 5 + return `${seconds}s` +} + /** * Background AI PR review worker. * Triggered by `pr.review.requested` from GitHub webhooks, dashboard, or @@ -20,10 +28,10 @@ export const generateReview = inngest.createFunction( }, { limit: 5 }, ], - // Rapid synchronize events → one review on the latest head. + // Collapse synchronize bursts without imposing a fixed 30-second wait. debounce: { key: "event.data.owner + '/' + event.data.repo + '#' + event.data.prNumber", - period: "30s", + period: reviewDebouncePeriod(), }, retries: 2, onFailure: async ({ event, error }) => { @@ -115,6 +123,7 @@ export const generateReview = inngest.createFunction( prNumber: result.prNumber, files: result.files, commentPosted: result.commentPosted, + descriptionUpdated: result.descriptionUpdated ?? false, linearNotified: result.linearNotified ?? false, linearIssueId: result.linearIssueId ?? null, linearSkippedReason: result.linearSkippedReason ?? null, diff --git a/apps/web/lib/gateway.ts b/apps/web/lib/gateway.ts index 41b77e10..3bb31e4e 100644 --- a/apps/web/lib/gateway.ts +++ b/apps/web/lib/gateway.ts @@ -153,9 +153,22 @@ export function chatModel( return googleDirect(bareModelId(modelId)) } +export function embeddingProvider(): "vercel" | "merge" | "openai" | null { + const configured = (process.env.EMBEDDING_PROVIDER ?? "").trim().toLowerCase() + if (configured === "vercel" && hasVercelKey()) return "vercel" + if (configured === "merge" && hasMergeKey()) return "merge" + if (configured === "openai" && hasOpenAIKey()) return "openai" + + // Vercel's free tier currently rejects OpenAI embeddings. Prefer a direct + // key automatically; gateways must be explicitly enabled once paid access + // is available. + if (hasOpenAIKey()) return "openai" + return null +} + export function embeddingModel( modelId: string, - provider: GatewayProviderName = activeGatewayProvider, + provider: "vercel" | "merge" | "openai" = embeddingProvider() ?? "openai", ): EmbeddingModel { if (provider === "vercel") { return vercelGateway.embeddingModel(modelId) diff --git a/apps/web/modules/ai/action/index.ts b/apps/web/modules/ai/action/index.ts index 79b3bf31..272dd31a 100644 --- a/apps/web/modules/ai/action/index.ts +++ b/apps/web/modules/ai/action/index.ts @@ -158,14 +158,20 @@ export async function reviewPullRequest( payload.source === "manual" if (options?.wait) { - await runGeneratePrReview(payload) - console.log( - `[reviewPullRequest] completed blocking in-process review for ${owner}/${repo}#${prNumber}`, - ) - return { - success: true, - message: "Review completed", - mode: "blocking" as const, + try { + await runGeneratePrReview(payload) + console.log( + `[reviewPullRequest] completed blocking in-process review for ${owner}/${repo}#${prNumber}`, + ) + return { + success: true, + message: "Review completed", + mode: "blocking" as const, + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown Error" + await markReviewFailed(payload, message) + throw error } } diff --git a/apps/web/modules/ai/lib/generate-pr-review.ts b/apps/web/modules/ai/lib/generate-pr-review.ts index aaee76dd..3795c0ae 100644 --- a/apps/web/modules/ai/lib/generate-pr-review.ts +++ b/apps/web/modules/ai/lib/generate-pr-review.ts @@ -1,5 +1,9 @@ import prisma from "@super/db" -import { getPullRequestDiff, postReviewComment } from "@/modules/github/lib/github" +import { + getPullRequestDiff, + postReviewComment, + updatePullRequestSummary, +} from "@/modules/github/lib/github" import { retrieveContext } from "@/modules/pinecone/rag" import { generateText } from "ai" import { @@ -259,6 +263,9 @@ ${diff} ### Summary 2–4 sentences on what this PR does and why it matters. +### PR description summary +A concise changelog for the PR description. Group bullets under only the relevant plain-text category headings, such as \`New Features\`, \`Bug Fixes\`, \`Documentation\`, \`Tests\`, \`Refactoring\`, or \`Infrastructure\`. Put each heading on its own line, followed by short Markdown bullets. Do not use \`#\` heading markers in this section. Omit empty categories. + ### Walkthrough Bullet list of the main changes by area/file. Keep it scannable. @@ -295,6 +302,14 @@ A cleaned-up PR body the author could paste, with: Do not include a poem. Do not wrap the whole response in a single code fence.` } +function extractPrDescriptionSummary(review: string): string | null { + const match = review.match( + /(?:^|\n)###\s+PR description summary\s*\n([\s\S]*?)(?=\n###\s|$)/i, + ) + const summary = match?.[1]?.trim() + return summary || null +} + export type GeneratePrReviewInput = { owner: string repo: string @@ -309,6 +324,7 @@ export type GeneratePrReviewResult = { prNumber: number files: number commentPosted: boolean + descriptionUpdated?: boolean review: string linearNotified?: boolean linearIssueId?: string | null @@ -466,19 +482,28 @@ export async function runGeneratePrReview( ): Promise { const { owner, repo, prNumber } = input const repoId = `${owner}/${repo}` + const pipelineStartedAt = Date.now() + const timings: Record = {} + const measure = async (stage: string, work: () => Promise): Promise => { + const startedAt = Date.now() + try { + return await work() + } finally { + timings[stage] = Date.now() - startedAt + } + } // Resolve token first so we can bind the review to a real connected user // even when the Inngest payload carries a bad/stale userId. - const { accessToken, userId } = await resolveGithubAccessToken(input) + const { accessToken, userId } = await measure("tokenMs", () => + resolveGithubAccessToken(input), + ) const resolvedInput = { ...input, userId } - await markReviewPending(resolvedInput) + await measure("pendingMs", () => markReviewPending(resolvedInput)) - const prData = await getPullRequestDiff( - accessToken, - owner, - repo, - prNumber, + const prData = await measure("githubFetchMs", () => + getPullRequestDiff(accessToken, owner, repo, prNumber), ) let context: string[] = [] @@ -491,7 +516,7 @@ export async function runGeneratePrReview( .filter(Boolean) .join("\n") - context = await retrieveContext(query, repoId, 6) + context = await measure("contextMs", () => retrieveContext(query, repoId, 6)) } catch (error) { console.error("[generate-pr-review] retrieveContext failed:", error) context = [] @@ -521,7 +546,7 @@ export async function runGeneratePrReview( diff: prData.diff, }) - const text = await generateReviewText(prompt) + const text = await measure("generationMs", () => generateReviewText(prompt)) if (!text?.trim()) { throw new Error("Model returned empty review") @@ -530,11 +555,14 @@ export async function runGeneratePrReview( const review = text const prUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}` - const repository = await findRepository(owner, repo, userId) let reviewId: string | null = null - if (!repository) { - console.warn(`[generate-pr-review] repository ${repoId} missing when saving`) - } else { + await measure("persistenceMs", async () => { + const repository = await findRepository(owner, repo, userId) + if (!repository) { + console.warn(`[generate-pr-review] repository ${repoId} missing when saving`) + return + } + const saved = await prisma.review.upsert({ where: { repositoryId_prNumber: { @@ -559,107 +587,142 @@ export async function runGeneratePrReview( select: { id: true }, }) reviewId = saved.id - } + }) // Persist completed review first so the dashboard is correct even if GitHub - // commenting fails. Sticky PR comment is best-effort after that — do not - // fail the whole job (Inngest onFailure would flip status back to failed). + // writes fail. The sticky comment and PR-body summary are independent and + // best-effort, so run them concurrently without failing the review. let commentPosted = false - try { - await postReviewComment( - accessToken, - owner, - repo, - prNumber, - review, - { headSha: prData.headSha, event: "COMMENT" }, - ) - commentPosted = true - } catch (error) { - console.error( - `[generate-pr-review] postReviewComment failed for ${repoId}#${prNumber} (review still saved):`, - error, - ) - } + let descriptionUpdated = false + const descriptionSummary = extractPrDescriptionSummary(review) + + await Promise.all([ + measure("githubCommentMs", async () => { + try { + await postReviewComment(accessToken, owner, repo, prNumber, review, { + headSha: prData.headSha, + event: "COMMENT", + }) + commentPosted = true + } catch (error) { + console.error( + `[generate-pr-review] postReviewComment failed for ${repoId}#${prNumber} (review still saved):`, + error, + ) + } + }), + measure("githubDescriptionMs", async () => { + if (!descriptionSummary) { + console.warn( + `[generate-pr-review] PR description summary missing for ${repoId}#${prNumber}`, + ) + return + } + try { + await updatePullRequestSummary( + accessToken, + owner, + repo, + prNumber, + descriptionSummary, + ) + descriptionUpdated = true + } catch (error) { + console.error( + `[generate-pr-review] updatePullRequestSummary failed for ${repoId}#${prNumber} (review still saved):`, + error, + ) + } + }), + ]) - // Push review into connected Linear workspace (Supercode AI project). - // Best-effort — never fail the review job if Linear/Composio is down. + // Optional notifications are independent. Run them concurrently so their + // latency is the slower of the two integrations rather than the sum. let linearNotified = false let linearIssueId: string | null = null let linearSkippedReason: string | null = null - try { - const { notifyLinearOfCompletedReview } = await import( - "@/modules/integrations/lib/linear" - ) - const linearResult = await notifyLinearOfCompletedReview({ - userId, - owner, - repo, - prNumber, - prTitle: prData.title, - prUrl, - prDescription: prData.description || "", - reviewMarkdown: review, - reviewId, - }) - if (linearResult.skipped) { - linearSkippedReason = linearResult.reason ?? "skipped" - console.log( - `[generate-pr-review] linear notify skipped for ${repoId}#${prNumber}: ${linearSkippedReason}`, - ) - } else { - linearNotified = true - linearIssueId = linearResult.issueId ?? null - console.log( - `[generate-pr-review] linear notify ok for ${repoId}#${prNumber} issue=${linearIssueId ?? "?"} updated=${Boolean(linearResult.updated)} project=${linearResult.projectId ?? "?"}`, - ) - } - } catch (error) { - console.error( - `[generate-pr-review] linear notify failed for ${repoId}#${prNumber} (review still saved):`, - error, - ) - } - - // Email the connected Supercode user a review summary (Resend). - // Best-effort — never fail the review job if email delivery fails. let emailNotified = false let emailId: string | null = null let emailSkippedReason: string | null = null - try { - const { notifyUserOfCompletedReview } = await import( - "@/modules/email/pr-review-email" - ) - const emailResult = await notifyUserOfCompletedReview({ - userId, - owner, - repo, - prNumber, - prTitle: prData.title, - prUrl, - prAuthor: prData.author, - prDescription: prData.description || "", - reviewMarkdown: review, - reviewId, - }) - if (emailResult.skipped) { - emailSkippedReason = emailResult.reason - console.log( - `[generate-pr-review] email notify skipped for ${repoId}#${prNumber}: ${emailSkippedReason}`, - ) - } else { - emailNotified = true - emailId = emailResult.emailId - console.log( - `[generate-pr-review] email notify ok for ${repoId}#${prNumber} id=${emailId ?? "?"}`, - ) - } - } catch (error) { - console.error( - `[generate-pr-review] email notify failed for ${repoId}#${prNumber} (review still saved):`, - error, - ) - } + + await Promise.all([ + measure("linearMs", async () => { + try { + const { notifyLinearOfCompletedReview } = await import( + "@/modules/integrations/lib/linear" + ) + const linearResult = await notifyLinearOfCompletedReview({ + userId, + owner, + repo, + prNumber, + prTitle: prData.title, + prUrl, + prDescription: prData.description || "", + reviewMarkdown: review, + reviewId, + }) + if (linearResult.skipped) { + linearSkippedReason = linearResult.reason ?? "skipped" + console.log( + `[generate-pr-review] linear notify skipped for ${repoId}#${prNumber}: ${linearSkippedReason}`, + ) + } else { + linearNotified = true + linearIssueId = linearResult.issueId ?? null + console.log( + `[generate-pr-review] linear notify ok for ${repoId}#${prNumber} issue=${linearIssueId ?? "?"} updated=${Boolean(linearResult.updated)} project=${linearResult.projectId ?? "?"}`, + ) + } + } catch (error) { + console.error( + `[generate-pr-review] linear notify failed for ${repoId}#${prNumber} (review still saved):`, + error, + ) + } + }), + measure("emailMs", async () => { + try { + const { notifyUserOfCompletedReview } = await import( + "@/modules/email/pr-review-email" + ) + const emailResult = await notifyUserOfCompletedReview({ + userId, + owner, + repo, + prNumber, + prTitle: prData.title, + prUrl, + prAuthor: prData.author, + prDescription: prData.description || "", + reviewMarkdown: review, + reviewId, + }) + if (emailResult.skipped) { + emailSkippedReason = emailResult.reason + console.log( + `[generate-pr-review] email notify skipped for ${repoId}#${prNumber}: ${emailSkippedReason}`, + ) + } else { + emailNotified = true + emailId = emailResult.emailId + console.log( + `[generate-pr-review] email notify ok for ${repoId}#${prNumber} id=${emailId ?? "?"}`, + ) + } + } catch (error) { + console.error( + `[generate-pr-review] email notify failed for ${repoId}#${prNumber} (review still saved):`, + error, + ) + } + }), + ]) + + timings.totalMs = Date.now() - pipelineStartedAt + console.log( + `[generate-pr-review] timings ${repoId}#${prNumber} ${JSON.stringify(timings)}`, + ) return { success: true, @@ -668,6 +731,7 @@ export async function runGeneratePrReview( prNumber, files: prData.changedFiles.length, commentPosted, + descriptionUpdated, review, linearNotified, linearIssueId, diff --git a/apps/web/modules/dashboard/actions/index.ts b/apps/web/modules/dashboard/actions/index.ts index 9c92605a..087539cc 100644 --- a/apps/web/modules/dashboard/actions/index.ts +++ b/apps/web/modules/dashboard/actions/index.ts @@ -470,46 +470,6 @@ export async function getReviews(repoFullName?: string): Promise { }) } - // Auto-queue open unreviewed PRs. reviewPullRequest returns immediately - // (Inngest or next/server after()) so the list stays fast. - // Cap concurrent queues per list load to avoid stampeding LLM spend. - const toQueue = items - .filter( - (item) => - item.prState === "open" && - item.status === "unreviewed", - ) - .slice(0, repoFullName ? 5 : 2) - - if (toQueue.length > 0) { - const queuedKeys = new Set() - await Promise.allSettled( - toQueue.map(async (item) => { - const key = `${item.repository.fullName}#${item.prNumber}` - if (queuedKeys.has(key)) return - queuedKeys.add(key) - try { - await reviewPullRequest( - item.repository.owner, - item.repository.name, - item.prNumber, - { - userId: session.user.id, - prTitle: item.prTitle, - }, - ) - item.status = "pending" - item.summary = item.summary ?? "AI review queued…" - } catch (error) { - console.error( - `[getReviews] auto-queue failed for ${key}:`, - error, - ) - } - }), - ) - } - return items.sort( (a, b) => new Date(b.updatedAt ?? b.createdAt).getTime() - @@ -872,11 +832,12 @@ export async function queueReview(id: string): Promise<{ success: boolean; messa prNumber = parsed.prNumber } - // Async by default: Inngest when configured, otherwise next/server after(). - // Detail page polls until status becomes completed/failed. + // Keep the server action alive until the manual review reaches a terminal + // state. Post-response callbacks can be interrupted and leave rows pending. const result = await reviewPullRequest(owner, repo, prNumber, { userId: session.user.id, source: "dashboard", + wait: true, }) return { success: Boolean(result.success), diff --git a/apps/web/modules/github/lib/github.ts b/apps/web/modules/github/lib/github.ts index 512b79c9..d85d928c 100644 --- a/apps/web/modules/github/lib/github.ts +++ b/apps/web/modules/github/lib/github.ts @@ -671,6 +671,9 @@ export async function getRepoFileContents( const MAX_DIFF_CHARS = 120_000 const SUPERCODE_REVIEW_MARKER = "" +const SUPERCODE_SUMMARY_START = "" +const SUPERCODE_SUMMARY_END = "" +const MAX_PR_SUMMARY_CHARS = 6_000 /** List open (non-draft) pull requests for auto-review backfill. */ export async function listOpenPullRequests( @@ -718,27 +721,29 @@ export async function getPullRequestDiff( ) { const octokit = new Octokit({ auth: token }) - const { data: pr } = await octokit.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - }) - - const { data: files } = await octokit.rest.pulls.listFiles({ - owner, - repo, - pull_number: prNumber, - per_page: 100, - }) - - const { data: diff } = await octokit.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - mediaType: { - format: "diff", - }, - }) + // These endpoints are independent. Fetch them together so GitHub network + // latency is paid once instead of three times serially. + const [{ data: pr }, { data: files }, { data: diff }] = await Promise.all([ + octokit.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }), + octokit.rest.pulls.listFiles({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + }), + octokit.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + mediaType: { + format: "diff", + }, + }), + ]) let diffText = diff as unknown as string if (diffText.length > MAX_DIFF_CHARS) { @@ -767,6 +772,58 @@ export async function getPullRequestDiff( } } +function formatPrSummaryBlock(summary: string): string { + const content = summary.trim().slice(0, MAX_PR_SUMMARY_CHARS) + return [ + SUPERCODE_SUMMARY_START, + "## Summary by Supercode Review", + "", + content, + "", + SUPERCODE_SUMMARY_END, + ].join("\n") +} + +function mergePrSummary(originalBody: string, summary: string): string { + const block = formatPrSummaryBlock(summary) + const start = originalBody.indexOf(SUPERCODE_SUMMARY_START) + const end = originalBody.indexOf(SUPERCODE_SUMMARY_END) + + if (start >= 0 && end >= start) { + const afterEnd = end + SUPERCODE_SUMMARY_END.length + return `${originalBody.slice(0, start)}${block}${originalBody.slice(afterEnd)}`.trim() + } + + const body = originalBody.trim() + return body ? `${body}\n\n---\n\n${block}` : block +} + +/** Add or replace Supercode's generated summary without changing author text. */ +export async function updatePullRequestSummary( + token: string, + owner: string, + repo: string, + prNumber: number, + summary: string, +): Promise { + const octokit = new Octokit({ auth: token }) + // Re-read immediately before writing so generation-time author edits survive. + const { data: pr } = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }) + const body = mergePrSummary(pr.body ?? "", summary) + + await octokit.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + body, + }) + console.log(`[github] updated PR summary on ${owner}/${repo}#${prNumber}`) +} + function formatReviewBody(review: string): string { return [ SUPERCODE_REVIEW_MARKER, diff --git a/apps/web/modules/pinecone/rag/index.ts b/apps/web/modules/pinecone/rag/index.ts index fc0241e1..9cd553fd 100644 --- a/apps/web/modules/pinecone/rag/index.ts +++ b/apps/web/modules/pinecone/rag/index.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" import { pineconeIndex } from "@/lib/pinecone/pinecone" -import { embeddingModel } from "@/lib/gateway" +import { embeddingModel, embeddingProvider } from "@/lib/gateway" import { embed } from "ai" // Pinecone metadata values must stay under ~40KB; keep content snippets small. @@ -20,8 +20,15 @@ function vectorId(repoId: string, path: string): string { } export async function generateEmbedding(text: string): Promise { + const provider = embeddingProvider() + if (!provider) { + throw new Error( + "Embeddings unavailable: configure OPENAI_API_KEY or EMBEDDING_PROVIDER with a paid gateway", + ) + } + const { embedding } = await embed({ - model: embeddingModel("openai/text-embedding-3-small"), + model: embeddingModel("openai/text-embedding-3-small", provider), value: text, }) return embedding as number[] @@ -62,6 +69,11 @@ export async function indexCodebase( "files", ) + if (!embeddingProvider()) { + console.warn("[pinecone] indexing skipped: no embedding provider configured") + return 0 + } + const usable = files.filter((file) => file.content?.trim()) const vectors = ( @@ -119,6 +131,8 @@ export async function retrieveContext( repoId: string, topK: number = 5, ) { + if (!embeddingProvider()) return [] + const embedding = await generateEmbedding(query) const results = await pineconeIndex.query({ diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts index 0795c092..d6b286dd 100644 --- a/packages/auth/src/client.ts +++ b/packages/auth/src/client.ts @@ -1,5 +1,6 @@ import { createAuthClient } from "better-auth/react"; -export const { signIn, signUp, useSession, signOut } = createAuthClient({ - baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL!, -}); +// Auth is mounted at /api/auth in each Next.js app. Let Better Auth use the +// browser's current origin so local, preview, and production deployments do +// not depend on a separately configured public URL. +export const { signIn, signUp, useSession, signOut } = createAuthClient();