Files
spoon/packages/backend/convex/aiReviewActions.ts
T
Gabriel Brown 2dfa97ee4f
Build and Push Next App / quality (push) Failing after 48s
Build and Push Next App / build-next (push) Has been skipped
Add agent workflows & stuff
2026-06-21 21:15:15 -05:00

202 lines
6.5 KiB
TypeScript

'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<Id<'users'>> => {
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);
}
},
});