'use node'; import { getAuthUserId } from '@convex-dev/auth/server'; import { ConvexError, v } from 'convex/values'; import type { Doc, Id } from './_generated/dataModel'; import type { ActionCtx } from './_generated/server'; import { internal } from './_generated/api'; import { action } from './_generated/server'; import { reviewUpstreamCompatibility } from './openaiClient'; import { decryptSecret } from './secretCrypto'; const getRequiredUserId = async (ctx: ActionCtx): Promise> => { const userId = await getAuthUserId(ctx); if (!userId) throw new ConvexError('Not authenticated.'); return userId; }; export const reviewLatestUpstreamChanges = action({ args: { spoonId: v.id('spoons') }, handler: async ( ctx, { spoonId }, ): Promise<{ reviewId: Id<'aiReviews'>; risk: 'low' | 'medium' | 'high'; recommendedAction: | 'sync' | 'open_review_pr' | 'manual_review' | 'do_not_sync'; }> => { const ownerId = await getRequiredUserId(ctx); const spoon: Doc<'spoons'> = await ctx.runQuery( internal.spoons.getOwnedForAction, { spoonId, ownerId, }, ); const [state, settings, upstreamCommits, forkCommits]: [ Doc<'spoonRepositoryStates'> | null, Doc<'spoonSettings'> | null, Doc<'spoonCommits'>[], Doc<'spoonCommits'>[], ] = await Promise.all([ ctx.runQuery(internal.spoonState.getInternal, { spoonId, ownerId }), ctx.runQuery(internal.spoonSettings.getInternal, { spoonId, ownerId }), ctx.runQuery(internal.spoonCommits.listInternal, { spoonId, ownerId, side: 'upstream', limit: 80, }), ctx.runQuery(internal.spoonCommits.listInternal, { spoonId, ownerId, side: 'fork', limit: 80, }), ]); const aiSettings: Doc<'userAiSettings'> | null = await ctx.runQuery( internal.aiSettings.getForUserInternal, { userId: ownerId }, ); if (!aiSettings?.encryptedApiKey) { throw new ConvexError( 'Add your OpenAI API key in Settings before running AI review.', ); } const model = aiSettings.model; const syncRunId: Id<'syncRuns'> = await ctx.runMutation( internal.syncRuns.createInternal, { spoonId, ownerId, kind: 'ai_review', status: 'running', summary: 'Reviewing upstream changes with OpenAI.', }, ); const reviewId: Id<'aiReviews'> = await ctx.runMutation( internal.aiReviews.createInternal, { spoonId, ownerId, syncRunId, model, status: 'running', reviewType: 'upstream_update', inputSummary: `${upstreamCommits.length} upstream commit(s), ${forkCommits.length} fork-only commit(s).`, }, ); try { if (upstreamCommits.length === 0) { await ctx.runMutation(internal.aiReviews.completeInternal, { reviewId, outputSummary: 'The fork is already up to date with upstream.', risk: 'low', compatible: true, requiresHumanReview: false, recommendedAction: 'sync', potentialConflicts: [], importantFiles: [], reasoningSummary: 'No upstream-only commits are cached for this Spoon, so there is nothing to review.', }); await Promise.all([ ctx.runMutation(internal.spoons.patchSyncFields, { spoonId, lastAiReviewId: reviewId, }), ctx.runMutation(internal.syncRuns.patchInternal, { syncRunId, status: 'clean', aiAssessment: 'No upstream-only commits are waiting.', }), ]); return { reviewId, risk: 'low', recommendedAction: 'sync' }; } const review = await reviewUpstreamCompatibility( { spoonName: spoon.name, upstreamFullName: state?.upstreamFullName ?? `${spoon.upstreamOwner}/${spoon.upstreamRepo}`, forkFullName: state?.forkFullName ?? `${spoon.forkOwner ?? 'unknown'}/${spoon.forkRepo ?? 'unknown'}`, status: state?.status ?? spoon.syncStatus ?? 'unknown', upstreamAheadBy: state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0, forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0, upstreamCommits: upstreamCommits.map((commit) => ({ sha: commit.sha, message: commit.message, authorName: commit.authorName, committedAt: commit.committedAt, })), forkCommits: forkCommits.map((commit) => ({ sha: commit.sha, message: commit.message, authorName: commit.authorName, committedAt: commit.committedAt, })), importantFilePatterns: settings?.importantFilePatterns, ignoredFilePatterns: settings?.ignoredFilePatterns, }, { apiKey: decryptSecret(aiSettings.encryptedApiKey), model, reasoningEffort: aiSettings.reasoningEffort, }, ); await ctx.runMutation(internal.aiReviews.completeInternal, { reviewId, outputSummary: review.summary, risk: review.risk, compatible: review.compatible, requiresHumanReview: review.requiresHumanReview, recommendedAction: review.recommendedAction, potentialConflicts: review.potentialConflicts, importantFiles: review.importantFiles, reasoningSummary: review.reasoningSummary, }); await Promise.all([ ctx.runMutation(internal.spoons.patchSyncFields, { spoonId, lastAiReviewId: reviewId, }), ctx.runMutation(internal.syncRuns.patchInternal, { syncRunId, status: review.compatible && review.risk === 'low' ? 'clean' : 'needs_review', aiAssessment: review.summary, }), ]); return { reviewId, risk: review.risk, recommendedAction: review.recommendedAction, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); await Promise.all([ ctx.runMutation(internal.aiReviews.failInternal, { reviewId, error: message, }), ctx.runMutation(internal.syncRuns.patchInternal, { syncRunId, status: 'failed', error: message, }), ]); throw new ConvexError(message); } }, });