202 lines
6.5 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
});
|