Add agent workflows & stuff
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc, Id } from './_generated/dataModel';
|
||||
import type { MutationCtx } from './_generated/server';
|
||||
import { internalMutation, mutation, query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
|
||||
|
||||
const jobStatus = v.union(
|
||||
v.literal('queued'),
|
||||
v.literal('claimed'),
|
||||
v.literal('preparing'),
|
||||
v.literal('running'),
|
||||
v.literal('checks_running'),
|
||||
v.literal('changes_ready'),
|
||||
v.literal('draft_pr_opened'),
|
||||
v.literal('failed'),
|
||||
v.literal('cancelled'),
|
||||
v.literal('timed_out'),
|
||||
);
|
||||
|
||||
const eventLevel = v.union(
|
||||
v.literal('debug'),
|
||||
v.literal('info'),
|
||||
v.literal('warn'),
|
||||
v.literal('error'),
|
||||
);
|
||||
|
||||
const eventPhase = v.union(
|
||||
v.literal('queued'),
|
||||
v.literal('clone'),
|
||||
v.literal('plan'),
|
||||
v.literal('edit'),
|
||||
v.literal('install'),
|
||||
v.literal('check'),
|
||||
v.literal('test'),
|
||||
v.literal('commit'),
|
||||
v.literal('push'),
|
||||
v.literal('pr'),
|
||||
v.literal('cleanup'),
|
||||
);
|
||||
|
||||
const artifactKind = v.union(
|
||||
v.literal('plan'),
|
||||
v.literal('diff'),
|
||||
v.literal('test_output'),
|
||||
v.literal('summary'),
|
||||
v.literal('error'),
|
||||
v.literal('pr_body'),
|
||||
);
|
||||
|
||||
const artifactContentType = v.union(
|
||||
v.literal('text/markdown'),
|
||||
v.literal('text/plain'),
|
||||
v.literal('application/json'),
|
||||
v.literal('text/x-diff'),
|
||||
);
|
||||
|
||||
const defaultAgentSettings = {
|
||||
enabled: true,
|
||||
branchPrefix: 'spoon/agent',
|
||||
agentModel: 'gpt-5.1-codex',
|
||||
reasoningEffort: 'high' as const,
|
||||
maxJobDurationMs: 1_800_000,
|
||||
maxOutputBytes: 200_000,
|
||||
};
|
||||
|
||||
const getWorkerToken = () => process.env.SPOON_WORKER_TOKEN?.trim();
|
||||
|
||||
const requireWorkerToken = (workerToken: string) => {
|
||||
const expected = getWorkerToken();
|
||||
if (!expected) throw new ConvexError('SPOON_WORKER_TOKEN is not configured.');
|
||||
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
|
||||
};
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 36) || 'task';
|
||||
|
||||
const shortId = (id: string) => id.replace(/[^a-zA-Z0-9]/g, '').slice(-8);
|
||||
|
||||
const buildBranch = (
|
||||
requestId: Id<'agentRequests'>,
|
||||
prompt: string,
|
||||
prefix: string,
|
||||
requestedBranchName?: string,
|
||||
) => {
|
||||
const requested = optionalText(requestedBranchName);
|
||||
if (requested) return requested.replace(/^\/+|\/+$/g, '');
|
||||
return `${prefix.replace(/\/+$/g, '')}/${shortId(requestId)}/${slugify(
|
||||
prompt,
|
||||
)}`;
|
||||
};
|
||||
|
||||
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
|
||||
const settings = await ctx.db
|
||||
.query('spoonAgentSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||
.first();
|
||||
return {
|
||||
...defaultAgentSettings,
|
||||
defaultBaseBranch: spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
|
||||
...settings,
|
||||
};
|
||||
};
|
||||
|
||||
const assertSecretOwnership = async (
|
||||
ctx: MutationCtx,
|
||||
spoonId: Id<'spoons'>,
|
||||
ownerId: Id<'users'>,
|
||||
secretIds: Id<'spoonSecrets'>[],
|
||||
) => {
|
||||
for (const secretId of secretIds) {
|
||||
const secret = await ctx.db.get(secretId);
|
||||
if (secret?.ownerId !== ownerId || secret.spoonId !== spoonId) {
|
||||
throw new ConvexError('Selected secrets must belong to this Spoon.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createFromRequest = mutation({
|
||||
args: {
|
||||
requestId: v.id('agentRequests'),
|
||||
selectedSecretIds: v.array(v.id('spoonSecrets')),
|
||||
baseBranch: v.optional(v.string()),
|
||||
requestedBranchName: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const request = await ctx.db.get(args.requestId);
|
||||
if (request?.ownerId !== ownerId) {
|
||||
throw new ConvexError('Agent request not found.');
|
||||
}
|
||||
if (request.agentJobId) {
|
||||
throw new ConvexError('This request already has an agent job.');
|
||||
}
|
||||
const spoon = await getOwnedSpoon(ctx, request.spoonId, ownerId);
|
||||
if (spoon.provider !== 'github') {
|
||||
throw new ConvexError('Agent jobs currently require a GitHub Spoon.');
|
||||
}
|
||||
if (!spoon.forkOwner || !spoon.forkRepo || !spoon.forkUrl) {
|
||||
throw new ConvexError(
|
||||
'Add fork repository metadata before queueing a job.',
|
||||
);
|
||||
}
|
||||
const settings = await getAgentSettings(ctx, spoon);
|
||||
if (!settings.enabled) {
|
||||
throw new ConvexError('Agent jobs are disabled for this Spoon.');
|
||||
}
|
||||
await assertSecretOwnership(
|
||||
ctx,
|
||||
spoon._id,
|
||||
ownerId,
|
||||
args.selectedSecretIds,
|
||||
);
|
||||
const now = Date.now();
|
||||
const baseBranch =
|
||||
optionalText(args.baseBranch) ?? settings.defaultBaseBranch;
|
||||
const workBranch = buildBranch(
|
||||
request._id,
|
||||
request.prompt,
|
||||
settings.branchPrefix,
|
||||
args.requestedBranchName,
|
||||
);
|
||||
const jobId = await ctx.db.insert('agentJobs', {
|
||||
spoonId: spoon._id,
|
||||
ownerId,
|
||||
agentRequestId: request._id,
|
||||
status: 'queued',
|
||||
prompt: request.prompt,
|
||||
baseBranch,
|
||||
workBranch,
|
||||
githubInstallationId: spoon.githubInstallationId,
|
||||
forkOwner: spoon.forkOwner,
|
||||
forkRepo: spoon.forkRepo,
|
||||
forkUrl: spoon.forkUrl,
|
||||
upstreamOwner: spoon.upstreamOwner,
|
||||
upstreamRepo: spoon.upstreamRepo,
|
||||
selectedSecretIds: args.selectedSecretIds,
|
||||
model: settings.agentModel,
|
||||
reasoningEffort: settings.reasoningEffort,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(request._id, {
|
||||
agentJobId: jobId,
|
||||
selectedSecretIds: args.selectedSecretIds,
|
||||
baseBranch,
|
||||
requestedBranchName: optionalText(args.requestedBranchName),
|
||||
status: 'queued',
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert('agentJobEvents', {
|
||||
jobId,
|
||||
spoonId: spoon._id,
|
||||
ownerId,
|
||||
level: 'info',
|
||||
phase: 'queued',
|
||||
message: 'Agent job queued.',
|
||||
createdAt: now,
|
||||
});
|
||||
return jobId;
|
||||
},
|
||||
});
|
||||
|
||||
export const listForSpoon = query({
|
||||
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { spoonId, limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return await ctx.db
|
||||
.query('agentJobs')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.take(limit ?? 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { jobId: v.id('agentJobs') },
|
||||
handler: async (ctx, { jobId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const job = await ctx.db.get(jobId);
|
||||
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||
return job;
|
||||
},
|
||||
});
|
||||
|
||||
export const listEvents = query({
|
||||
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { jobId, limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const job = await ctx.db.get(jobId);
|
||||
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||
return await ctx.db
|
||||
.query('agentJobEvents')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||
.order('asc')
|
||||
.take(limit ?? 200);
|
||||
},
|
||||
});
|
||||
|
||||
export const listArtifacts = query({
|
||||
args: { jobId: v.id('agentJobs') },
|
||||
handler: async (ctx, { jobId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const job = await ctx.db.get(jobId);
|
||||
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||
return await ctx.db
|
||||
.query('agentJobArtifacts')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||
.order('asc')
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = mutation({
|
||||
args: { jobId: v.id('agentJobs') },
|
||||
handler: async (ctx, { jobId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const job = await ctx.db.get(jobId);
|
||||
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||
if (
|
||||
!['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes(
|
||||
job.status,
|
||||
)
|
||||
) {
|
||||
throw new ConvexError('This job cannot be cancelled.');
|
||||
}
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(jobId, {
|
||||
status: 'cancelled',
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(job.agentRequestId, {
|
||||
status: 'cancelled',
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert('agentJobEvents', {
|
||||
jobId,
|
||||
spoonId: job.spoonId,
|
||||
ownerId,
|
||||
level: 'warn',
|
||||
phase: 'cleanup',
|
||||
message: 'Agent job cancelled by user.',
|
||||
createdAt: now,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const claimNextInternal = internalMutation({
|
||||
args: { workerId: v.string() },
|
||||
handler: async (ctx, { workerId }) => {
|
||||
const job = await ctx.db
|
||||
.query('agentJobs')
|
||||
.withIndex('by_claim', (q) => q.eq('status', 'queued'))
|
||||
.order('asc')
|
||||
.first();
|
||||
if (!job) return null;
|
||||
const spoon = await ctx.db.get(job.spoonId);
|
||||
const aiSettings = await ctx.db
|
||||
.query('userAiSettings')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', job.ownerId).eq('provider', 'openai'),
|
||||
)
|
||||
.first();
|
||||
const agentSettings = await ctx.db
|
||||
.query('spoonAgentSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', job.spoonId))
|
||||
.first();
|
||||
const secrets = [];
|
||||
for (const secretId of job.selectedSecretIds) {
|
||||
const secret = await ctx.db.get(secretId);
|
||||
if (secret?.ownerId === job.ownerId && secret.spoonId === job.spoonId) {
|
||||
secrets.push(secret);
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(job._id, {
|
||||
status: 'claimed',
|
||||
claimedBy: workerId,
|
||||
claimedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(job.agentRequestId, {
|
||||
status: 'running',
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert('agentJobEvents', {
|
||||
jobId: job._id,
|
||||
spoonId: job.spoonId,
|
||||
ownerId: job.ownerId,
|
||||
level: 'info',
|
||||
phase: 'queued',
|
||||
message: `Claimed by ${workerId}.`,
|
||||
createdAt: now,
|
||||
});
|
||||
return {
|
||||
job: { ...job, status: 'claimed' as const, claimedBy: workerId },
|
||||
spoon,
|
||||
aiSettings,
|
||||
agentSettings,
|
||||
secrets,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const updateStatus = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
workerId: v.string(),
|
||||
jobId: v.id('agentJobs'),
|
||||
status: jobStatus,
|
||||
error: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (job?.claimedBy !== args.workerId) {
|
||||
throw new ConvexError('Agent job not claimed by this worker.');
|
||||
}
|
||||
const now = Date.now();
|
||||
const patch: Partial<Doc<'agentJobs'>> = {
|
||||
status: args.status,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (args.status === 'running' && !job.startedAt) patch.startedAt = now;
|
||||
if (
|
||||
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||
args.status,
|
||||
)
|
||||
) {
|
||||
patch.completedAt = now;
|
||||
}
|
||||
if (args.error !== undefined) patch.error = args.error;
|
||||
if (args.summary !== undefined) patch.summary = args.summary;
|
||||
await ctx.db.patch(args.jobId, patch);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const completeWithDraftPr = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
workerId: v.string(),
|
||||
jobId: v.id('agentJobs'),
|
||||
commitSha: v.string(),
|
||||
pullRequestUrl: v.string(),
|
||||
pullRequestNumber: v.number(),
|
||||
summary: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (job?.claimedBy !== args.workerId) {
|
||||
throw new ConvexError('Agent job not claimed by this worker.');
|
||||
}
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(args.jobId, {
|
||||
status: 'draft_pr_opened',
|
||||
commitSha: args.commitSha,
|
||||
pullRequestUrl: args.pullRequestUrl,
|
||||
pullRequestNumber: args.pullRequestNumber,
|
||||
summary: args.summary,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(job.agentRequestId, {
|
||||
status: 'merge_request_opened',
|
||||
mergeRequestUrl: args.pullRequestUrl,
|
||||
summary: args.summary,
|
||||
updatedAt: now,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const appendEvent = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
workerId: v.string(),
|
||||
jobId: v.id('agentJobs'),
|
||||
level: eventLevel,
|
||||
phase: eventPhase,
|
||||
message: v.string(),
|
||||
metadata: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (job?.claimedBy !== args.workerId) {
|
||||
throw new ConvexError('Agent job not claimed by this worker.');
|
||||
}
|
||||
return await ctx.db.insert('agentJobEvents', {
|
||||
jobId: args.jobId,
|
||||
spoonId: job.spoonId,
|
||||
ownerId: job.ownerId,
|
||||
level: args.level,
|
||||
phase: args.phase,
|
||||
message: args.message,
|
||||
metadata: args.metadata,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const addArtifact = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
workerId: v.string(),
|
||||
jobId: v.id('agentJobs'),
|
||||
kind: artifactKind,
|
||||
title: v.string(),
|
||||
content: v.string(),
|
||||
contentType: artifactContentType,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (job?.claimedBy !== args.workerId) {
|
||||
throw new ConvexError('Agent job not claimed by this worker.');
|
||||
}
|
||||
return await ctx.db.insert('agentJobArtifacts', {
|
||||
jobId: args.jobId,
|
||||
spoonId: job.spoonId,
|
||||
ownerId: job.ownerId,
|
||||
kind: args.kind,
|
||||
title: args.title,
|
||||
content: args.content,
|
||||
contentType: args.contentType,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
'use node';
|
||||
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import { internal } from './_generated/api';
|
||||
import { action } from './_generated/server';
|
||||
import { decryptSecret } from './secretCrypto';
|
||||
|
||||
type ClaimedJob = {
|
||||
job: Doc<'agentJobs'>;
|
||||
spoon: Doc<'spoons'> | null;
|
||||
aiSettings: Doc<'userAiSettings'> | null;
|
||||
agentSettings: Doc<'spoonAgentSettings'> | null;
|
||||
secrets: Doc<'spoonSecrets'>[];
|
||||
};
|
||||
|
||||
type WorkerClaim = {
|
||||
job: Doc<'agentJobs'>;
|
||||
spoon: Doc<'spoons'>;
|
||||
openai: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: Doc<'agentJobs'>['reasoningEffort'];
|
||||
};
|
||||
agentSettings: Doc<'spoonAgentSettings'> | null;
|
||||
github: {
|
||||
installationId?: string;
|
||||
};
|
||||
secrets: { name: string; value: string }[];
|
||||
};
|
||||
|
||||
const requireWorkerToken = (workerToken: string) => {
|
||||
const expected = process.env.SPOON_WORKER_TOKEN?.trim();
|
||||
if (!expected) throw new ConvexError('SPOON_WORKER_TOKEN is not configured.');
|
||||
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
|
||||
};
|
||||
|
||||
export const claimNextForWorker = action({
|
||||
args: {
|
||||
workerId: v.string(),
|
||||
workerToken: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<WorkerClaim | null> => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const claimed = (await ctx.runMutation(
|
||||
internal.agentJobs.claimNextInternal,
|
||||
{
|
||||
workerId: args.workerId,
|
||||
},
|
||||
)) as ClaimedJob | null;
|
||||
if (!claimed) return null;
|
||||
if (!claimed.spoon) {
|
||||
throw new ConvexError('Claimed job points at a missing Spoon.');
|
||||
}
|
||||
if (!claimed.aiSettings?.encryptedApiKey) {
|
||||
throw new ConvexError(
|
||||
'OpenAI is not configured for this user. Add an OpenAI API key in settings.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
job: claimed.job,
|
||||
spoon: claimed.spoon,
|
||||
openai: {
|
||||
apiKey: decryptSecret(claimed.aiSettings.encryptedApiKey),
|
||||
model: claimed.job.model,
|
||||
reasoningEffort: claimed.job.reasoningEffort,
|
||||
},
|
||||
agentSettings: claimed.agentSettings,
|
||||
github: {
|
||||
installationId:
|
||||
claimed.job.githubInstallationId ??
|
||||
claimed.spoon.githubInstallationId ??
|
||||
process.env.GITHUB_APP_INSTALLATION_ID,
|
||||
},
|
||||
secrets: claimed.secrets.map((secret: Doc<'spoonSecrets'>) => ({
|
||||
name: secret.name,
|
||||
value: decryptSecret(secret.encryptedValue),
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -20,6 +20,19 @@ export const listRecent = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listForSpoon = query({
|
||||
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { spoonId, limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return await ctx.db
|
||||
.query('agentRequests')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.take(limit ?? 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
@@ -35,6 +48,9 @@ export const create = mutation({
|
||||
ownerId,
|
||||
prompt: requireText(args.prompt, 'Prompt'),
|
||||
status: 'queued',
|
||||
requestType: 'future_code_change',
|
||||
priority: 'normal',
|
||||
source: 'user',
|
||||
targetBranch: optionalText(args.targetBranch),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -42,6 +58,30 @@ export const create = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const updatePrompt = mutation({
|
||||
args: {
|
||||
requestId: v.id('agentRequests'),
|
||||
prompt: v.string(),
|
||||
targetBranch: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const request = await ctx.db.get(args.requestId);
|
||||
if (request?.ownerId !== ownerId) {
|
||||
throw new ConvexError('Agent request not found.');
|
||||
}
|
||||
if (request.status !== 'draft' && request.status !== 'queued') {
|
||||
throw new ConvexError('Only draft or queued requests can be edited.');
|
||||
}
|
||||
await ctx.db.patch(args.requestId, {
|
||||
prompt: requireText(args.prompt, 'Prompt'),
|
||||
targetBranch: optionalText(args.targetBranch),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = mutation({
|
||||
args: { requestId: v.id('agentRequests') },
|
||||
handler: async (ctx, { requestId }) => {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
'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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc, Id } from './_generated/dataModel';
|
||||
import { internalMutation, query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId } from './model';
|
||||
|
||||
const reviewStatus = v.union(
|
||||
v.literal('queued'),
|
||||
v.literal('running'),
|
||||
v.literal('completed'),
|
||||
v.literal('failed'),
|
||||
);
|
||||
|
||||
const reviewType = v.union(
|
||||
v.literal('upstream_update'),
|
||||
v.literal('manual_prompt'),
|
||||
v.literal('merge_safety'),
|
||||
);
|
||||
|
||||
const risk = v.union(
|
||||
v.literal('unknown'),
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high'),
|
||||
);
|
||||
|
||||
const recommendedAction = v.union(
|
||||
v.literal('sync'),
|
||||
v.literal('open_review_pr'),
|
||||
v.literal('manual_review'),
|
||||
v.literal('do_not_sync'),
|
||||
v.literal('unknown'),
|
||||
);
|
||||
|
||||
export const listForSpoon = query({
|
||||
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { spoonId, limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return await ctx.db
|
||||
.query('aiReviews')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.take(limit ?? 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const getLatestForSpoon = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return await ctx.db
|
||||
.query('aiReviews')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
export const listRecent = query({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
return await ctx.db
|
||||
.query('aiReviews')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.take(limit ?? 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const createInternal = internalMutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
syncRunId: v.optional(v.id('syncRuns')),
|
||||
model: v.string(),
|
||||
status: reviewStatus,
|
||||
reviewType,
|
||||
inputSummary: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<'aiReviews'>> => {
|
||||
const now = Date.now();
|
||||
return await ctx.db.insert('aiReviews', {
|
||||
...args,
|
||||
risk: 'unknown',
|
||||
compatible: false,
|
||||
requiresHumanReview: true,
|
||||
recommendedAction: 'unknown',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const completeInternal = internalMutation({
|
||||
args: {
|
||||
reviewId: v.id('aiReviews'),
|
||||
outputSummary: v.string(),
|
||||
risk,
|
||||
compatible: v.boolean(),
|
||||
requiresHumanReview: v.boolean(),
|
||||
recommendedAction,
|
||||
potentialConflicts: v.array(v.string()),
|
||||
importantFiles: v.array(v.string()),
|
||||
reasoningSummary: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const review = await ctx.db.get(args.reviewId);
|
||||
if (!review) throw new ConvexError('AI review not found.');
|
||||
const patch: Partial<Doc<'aiReviews'>> = {
|
||||
status: 'completed',
|
||||
outputSummary: args.outputSummary,
|
||||
risk: args.risk,
|
||||
compatible: args.compatible,
|
||||
requiresHumanReview: args.requiresHumanReview,
|
||||
recommendedAction: args.recommendedAction,
|
||||
potentialConflicts: args.potentialConflicts,
|
||||
importantFiles: args.importantFiles,
|
||||
reasoningSummary: args.reasoningSummary,
|
||||
updatedAt: Date.now(),
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
await ctx.db.patch(args.reviewId, patch);
|
||||
return review;
|
||||
},
|
||||
});
|
||||
|
||||
export const failInternal = internalMutation({
|
||||
args: { reviewId: v.id('aiReviews'), error: v.string() },
|
||||
handler: async (ctx, { reviewId, error }) => {
|
||||
await ctx.db.patch(reviewId, {
|
||||
status: 'failed',
|
||||
error,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc, Id } from './_generated/dataModel';
|
||||
import type { MutationCtx } from './_generated/server';
|
||||
import {
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from './_generated/server';
|
||||
import { getRequiredUserId } from './model';
|
||||
|
||||
const reasoningEffort = v.union(
|
||||
v.literal('none'),
|
||||
v.literal('minimal'),
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high'),
|
||||
v.literal('xhigh'),
|
||||
);
|
||||
|
||||
export const getMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const settings = await ctx.db
|
||||
.query('userAiSettings')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', userId).eq('provider', 'openai'),
|
||||
)
|
||||
.first();
|
||||
if (!settings) {
|
||||
return {
|
||||
configured: false,
|
||||
apiKeyPreview: undefined,
|
||||
model: 'gpt-5.5',
|
||||
reasoningEffort: 'medium' as const,
|
||||
updatedAt: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
configured: Boolean(settings.encryptedApiKey),
|
||||
apiKeyPreview: settings.apiKeyPreview,
|
||||
model: settings.model,
|
||||
reasoningEffort: settings.reasoningEffort,
|
||||
updatedAt: settings.updatedAt,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getForUserInternal = internalQuery({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx, { userId }) => {
|
||||
return await ctx.db
|
||||
.query('userAiSettings')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', userId).eq('provider', 'openai'),
|
||||
)
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
const upsert = async (
|
||||
ctx: MutationCtx,
|
||||
userId: Id<'users'>,
|
||||
patch: Partial<Doc<'userAiSettings'>>,
|
||||
) => {
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query('userAiSettings')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', userId).eq('provider', 'openai'),
|
||||
)
|
||||
.first();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { ...patch, updatedAt: now });
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert('userAiSettings', {
|
||||
userId,
|
||||
provider: 'openai',
|
||||
model: patch.model ?? 'gpt-5.5',
|
||||
reasoningEffort: patch.reasoningEffort ?? 'medium',
|
||||
encryptedApiKey: patch.encryptedApiKey,
|
||||
apiKeyPreview: patch.apiKeyPreview,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
};
|
||||
|
||||
export const upsertEncryptedInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id('users'),
|
||||
encryptedApiKey: v.string(),
|
||||
apiKeyPreview: v.string(),
|
||||
model: v.string(),
|
||||
reasoningEffort,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await upsert(ctx, args.userId, {
|
||||
encryptedApiKey: args.encryptedApiKey,
|
||||
apiKeyPreview: args.apiKeyPreview,
|
||||
model: args.model,
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const updatePreferences = mutation({
|
||||
args: {
|
||||
model: v.string(),
|
||||
reasoningEffort,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
return await upsert(ctx, userId, {
|
||||
model: args.model.trim() || 'gpt-5.5',
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const removeOpenAiKey = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const settings = await ctx.db
|
||||
.query('userAiSettings')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', userId).eq('provider', 'openai'),
|
||||
)
|
||||
.first();
|
||||
if (!settings) throw new ConvexError('OpenAI settings not found.');
|
||||
await ctx.db.patch(settings._id, {
|
||||
encryptedApiKey: undefined,
|
||||
apiKeyPreview: undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
'use node';
|
||||
|
||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { ActionCtx } from './_generated/server';
|
||||
import { internal } from './_generated/api';
|
||||
import { action } from './_generated/server';
|
||||
import { encryptSecret } from './secretCrypto';
|
||||
|
||||
const reasoningEffort = v.union(
|
||||
v.literal('none'),
|
||||
v.literal('minimal'),
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high'),
|
||||
v.literal('xhigh'),
|
||||
);
|
||||
|
||||
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
return userId;
|
||||
};
|
||||
|
||||
const previewKey = (apiKey: string) => {
|
||||
const trimmed = apiKey.trim();
|
||||
if (trimmed.length <= 10) return 'configured';
|
||||
return `${trimmed.slice(0, 7)}...${trimmed.slice(-4)}`;
|
||||
};
|
||||
|
||||
export const saveOpenAiSettings = action({
|
||||
args: {
|
||||
apiKey: v.string(),
|
||||
model: v.string(),
|
||||
reasoningEffort,
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ success: true }> => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const apiKey = args.apiKey.trim();
|
||||
if (!apiKey) throw new ConvexError('OpenAI API key is required.');
|
||||
await ctx.runMutation(internal.aiSettings.upsertEncryptedInternal, {
|
||||
userId,
|
||||
encryptedApiKey: encryptSecret(apiKey),
|
||||
apiKeyPreview: previewKey(apiKey),
|
||||
model: args.model.trim() || 'gpt-5.5',
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import Authentik from '@auth/core/providers/authentik';
|
||||
import GitHub from '@auth/core/providers/github';
|
||||
import {
|
||||
convexAuth,
|
||||
getAuthUserId,
|
||||
@@ -21,6 +22,12 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
clientSecret: process.env.AUTH_AUTHENTIK_SECRET,
|
||||
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
|
||||
}),
|
||||
GitHub({
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
clientId: process.env.AUTH_GITHUB_ID ?? process.env.GITHUB_APP_CLIENT_ID,
|
||||
clientSecret:
|
||||
process.env.AUTH_GITHUB_SECRET ?? process.env.GITHUB_APP_CLIENT_SECRET,
|
||||
}),
|
||||
Password,
|
||||
],
|
||||
});
|
||||
@@ -39,6 +46,7 @@ const getAuthAccountById = async (ctx: QueryCtx, userId: Id<'users'>) => {
|
||||
const authAccount = await ctx.db
|
||||
.query('authAccounts')
|
||||
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
|
||||
.order('desc')
|
||||
.first();
|
||||
if (!authAccount) throw new ConvexError('Auth account not found');
|
||||
return authAccount;
|
||||
@@ -83,7 +91,12 @@ export const updateUser = mutation({
|
||||
if (args.image !== undefined) {
|
||||
const oldImage = user.image as Id<'_storage'> | undefined;
|
||||
patch.image = args.image;
|
||||
if (oldImage && oldImage !== args.image) {
|
||||
if (
|
||||
oldImage &&
|
||||
oldImage !== args.image &&
|
||||
!oldImage.startsWith('http://') &&
|
||||
!oldImage.startsWith('https://')
|
||||
) {
|
||||
await ctx.storage.delete(oldImage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { cronJobs } from 'convex/server';
|
||||
|
||||
import { internal } from './_generated/api';
|
||||
|
||||
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
|
||||
const crons = cronJobs();
|
||||
crons.interval(
|
||||
'Refresh due GitHub Spoons',
|
||||
{ hours: 1 },
|
||||
internal.githubSync.refreshDueSpoons,
|
||||
{
|
||||
limit: 10,
|
||||
},
|
||||
);
|
||||
/* Example cron jobs
|
||||
crons.cron(
|
||||
// Run at 7:00 AM CST / 8:00 AM CDT
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import { mutation, query } from './_generated/server';
|
||||
|
||||
export const generateUploadUrl = mutation(async (ctx) => {
|
||||
@@ -9,10 +10,18 @@ export const generateUploadUrl = mutation(async (ctx) => {
|
||||
return await ctx.storage.generateUploadUrl();
|
||||
});
|
||||
|
||||
const isRemoteImageUrl = (value: string) =>
|
||||
value.startsWith('http://') || value.startsWith('https://');
|
||||
|
||||
export const getImageUrl = query({
|
||||
args: { storageId: v.id('_storage') },
|
||||
args: { storageId: v.string() },
|
||||
handler: async (ctx, { storageId }) => {
|
||||
const url = await ctx.storage.getUrl(storageId);
|
||||
return url ?? null;
|
||||
if (isRemoteImageUrl(storageId)) return storageId;
|
||||
try {
|
||||
const url = await ctx.storage.getUrl(storageId as Id<'_storage'>);
|
||||
return url ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import {
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from './_generated/server';
|
||||
import { getRequiredUserId } from './model';
|
||||
|
||||
export const getInstallUrl = query({
|
||||
args: {},
|
||||
handler: () => {
|
||||
const slug = process.env.GITHUB_APP_SLUG;
|
||||
if (!slug) return null;
|
||||
return `https://github.com/apps/${slug}/installations/new`;
|
||||
},
|
||||
});
|
||||
|
||||
export const getConnection = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
return await ctx.db
|
||||
.query('gitConnections')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', userId).eq('provider', 'github'),
|
||||
)
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
export const connectInstallation = mutation({
|
||||
args: { installationId: v.string() },
|
||||
handler: async (ctx, { installationId }) => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const trimmedInstallationId = installationId.trim();
|
||||
if (!trimmedInstallationId) {
|
||||
throw new ConvexError('GitHub installation ID is required.');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query('gitConnections')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', userId).eq('provider', 'github'),
|
||||
)
|
||||
.first();
|
||||
|
||||
const patch = {
|
||||
provider: 'github' as const,
|
||||
displayName: `GitHub installation ${trimmedInstallationId}`,
|
||||
installationId: trimmedInstallationId,
|
||||
scopes: [
|
||||
'metadata:read',
|
||||
'administration:write',
|
||||
'contents:write',
|
||||
'pull_requests:write',
|
||||
],
|
||||
status: 'active' as const,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
return await ctx.db.insert('gitConnections', {
|
||||
userId,
|
||||
...patch,
|
||||
connectedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const getConnectionForUser = internalQuery({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx, { userId }) => {
|
||||
return await ctx.db
|
||||
.query('gitConnections')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', userId).eq('provider', 'github'),
|
||||
)
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
export const upsertConnectionForUser = internalMutation({
|
||||
args: {
|
||||
userId: v.id('users'),
|
||||
providerAccountId: v.optional(v.string()),
|
||||
displayName: v.string(),
|
||||
username: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
installationId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query('gitConnections')
|
||||
.withIndex('by_user_provider', (q) =>
|
||||
q.eq('userId', args.userId).eq('provider', 'github'),
|
||||
)
|
||||
.first();
|
||||
|
||||
const patch = {
|
||||
providerAccountId: args.providerAccountId,
|
||||
displayName: args.displayName,
|
||||
username: args.username,
|
||||
avatarUrl: args.avatarUrl,
|
||||
installationId: args.installationId,
|
||||
scopes: [
|
||||
'metadata:read',
|
||||
'administration:write',
|
||||
'contents:write',
|
||||
'pull_requests:write',
|
||||
'checks:write',
|
||||
'statuses:write',
|
||||
'issues:write',
|
||||
],
|
||||
status: 'active' as const,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
return await ctx.db.insert('gitConnections', {
|
||||
userId: args.userId,
|
||||
provider: 'github',
|
||||
...patch,
|
||||
connectedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const createForkSpoonRecord = internalMutation({
|
||||
args: {
|
||||
ownerId: v.id('users'),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
upstreamOwner: v.string(),
|
||||
upstreamRepo: v.string(),
|
||||
upstreamDefaultBranch: v.string(),
|
||||
upstreamUrl: v.string(),
|
||||
forkOwner: v.string(),
|
||||
forkRepo: v.string(),
|
||||
forkDefaultBranch: v.string(),
|
||||
forkUrl: v.string(),
|
||||
visibility: v.union(
|
||||
v.literal('public'),
|
||||
v.literal('private'),
|
||||
v.literal('internal'),
|
||||
v.literal('unknown'),
|
||||
),
|
||||
connectionId: v.optional(v.id('gitConnections')),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<'spoons'>> => {
|
||||
const now = Date.now();
|
||||
const spoonId = await ctx.db.insert('spoons', {
|
||||
ownerId: args.ownerId,
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
provider: 'github',
|
||||
upstreamOwner: args.upstreamOwner,
|
||||
upstreamRepo: args.upstreamRepo,
|
||||
upstreamDefaultBranch: args.upstreamDefaultBranch,
|
||||
upstreamUrl: args.upstreamUrl,
|
||||
forkOwner: args.forkOwner,
|
||||
forkRepo: args.forkRepo,
|
||||
forkDefaultBranch: args.forkDefaultBranch,
|
||||
forkUrl: args.forkUrl,
|
||||
visibility: args.visibility,
|
||||
maintenanceMode: 'watch',
|
||||
syncCadence: 'daily',
|
||||
productionRefStrategy: 'default_branch',
|
||||
status: 'active',
|
||||
syncStatus: 'unknown',
|
||||
connectionId: args.connectionId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await ctx.db.insert('spoonSettings', {
|
||||
spoonId,
|
||||
ownerId: args.ownerId,
|
||||
autoRefreshEnabled: true,
|
||||
autoReviewEnabled: true,
|
||||
autoSyncEnabled: false,
|
||||
requireAiLowRiskForSync: true,
|
||||
requireCleanCompareForSync: true,
|
||||
ignoredFilePatterns: [],
|
||||
importantFilePatterns: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await ctx.db.insert('syncRuns', {
|
||||
spoonId,
|
||||
ownerId: args.ownerId,
|
||||
kind: 'manual_check',
|
||||
status: 'clean',
|
||||
summary: `Created GitHub fork ${args.forkOwner}/${args.forkRepo} from ${args.upstreamOwner}/${args.upstreamRepo}.`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return spoonId;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createAppAuth } from '@octokit/auth-app';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { ConvexError } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
|
||||
export type GitHubCommitSummary = {
|
||||
sha: string;
|
||||
message: string;
|
||||
authorName?: string;
|
||||
authorEmail?: string;
|
||||
authorLogin?: string;
|
||||
committedAt?: number;
|
||||
htmlUrl?: string;
|
||||
filesChanged?: number;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestSummary = {
|
||||
githubId: number;
|
||||
number: number;
|
||||
repoFullName: string;
|
||||
title: string;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
authorLogin?: string;
|
||||
baseRef: string;
|
||||
headRef: string;
|
||||
headRepoFullName?: string;
|
||||
htmlUrl: string;
|
||||
createdAtGithub?: number;
|
||||
updatedAtGithub?: number;
|
||||
mergedAtGithub?: number;
|
||||
};
|
||||
|
||||
export type GitHubCompareSummary = {
|
||||
aheadBy: number;
|
||||
mergeBaseSha?: string;
|
||||
headSha?: string;
|
||||
baseSha?: string;
|
||||
htmlUrl?: string;
|
||||
commits: GitHubCommitSummary[];
|
||||
};
|
||||
|
||||
const getEnv = (name: string) => {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new ConvexError(`${name} is not configured.`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizePrivateKey = (value: string) => value.replaceAll('\\n', '\n');
|
||||
|
||||
export const getInstallationOctokit = (installationId: string) =>
|
||||
new Octokit({
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId: getEnv('GITHUB_APP_ID'),
|
||||
privateKey: normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY')),
|
||||
installationId,
|
||||
},
|
||||
userAgent: 'Spoon',
|
||||
request: {
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const getSpoonInstallationId = (
|
||||
spoon: Doc<'spoons'>,
|
||||
connection?: Doc<'gitConnections'> | null,
|
||||
) => {
|
||||
const installationId =
|
||||
spoon.githubInstallationId ?? connection?.installationId ?? undefined;
|
||||
if (!installationId) {
|
||||
throw new ConvexError('Connect a GitHub App installation first.');
|
||||
}
|
||||
return installationId;
|
||||
};
|
||||
|
||||
export const getRepository = async (
|
||||
octokit: Octokit,
|
||||
owner: string,
|
||||
repo: string,
|
||||
) => {
|
||||
const result = await octokit.rest.repos.get({ owner, repo });
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const toMillis = (value?: string | null) =>
|
||||
value ? new Date(value).getTime() : undefined;
|
||||
|
||||
const normalizeCompareCommit = (
|
||||
commit: Awaited<
|
||||
ReturnType<Octokit['rest']['repos']['compareCommitsWithBasehead']>
|
||||
>['data']['commits'][number],
|
||||
): GitHubCommitSummary => ({
|
||||
sha: commit.sha,
|
||||
message: commit.commit.message,
|
||||
authorName: commit.commit.author?.name ?? undefined,
|
||||
authorEmail: commit.commit.author?.email ?? undefined,
|
||||
authorLogin: commit.author?.login ?? undefined,
|
||||
committedAt: toMillis(
|
||||
commit.commit.author?.date ?? commit.commit.committer?.date,
|
||||
),
|
||||
htmlUrl: commit.html_url,
|
||||
});
|
||||
|
||||
export const compareAcrossForkNetwork = async (
|
||||
octokit: Octokit,
|
||||
args: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
baseOwner: string;
|
||||
baseBranch: string;
|
||||
headOwner: string;
|
||||
headBranch: string;
|
||||
},
|
||||
): Promise<GitHubCompareSummary> => {
|
||||
const basehead = `${args.baseOwner}:${args.baseBranch}...${args.headOwner}:${args.headBranch}`;
|
||||
const result = await octokit.rest.repos.compareCommitsWithBasehead({
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
basehead,
|
||||
per_page: 100,
|
||||
});
|
||||
const commits = result.data.commits.map(normalizeCompareCommit);
|
||||
return {
|
||||
aheadBy: result.data.ahead_by,
|
||||
mergeBaseSha: result.data.merge_base_commit.sha,
|
||||
headSha: commits[commits.length - 1]?.sha,
|
||||
baseSha: result.data.base_commit.sha,
|
||||
htmlUrl: result.data.html_url,
|
||||
commits,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePullRequest = (
|
||||
repoFullName: string,
|
||||
pull: Awaited<ReturnType<Octokit['rest']['pulls']['list']>>['data'][number],
|
||||
): GitHubPullRequestSummary => ({
|
||||
githubId: pull.id,
|
||||
number: pull.number,
|
||||
repoFullName,
|
||||
title: pull.title,
|
||||
state: pull.merged_at ? 'merged' : pull.state === 'open' ? 'open' : 'closed',
|
||||
draft: pull.draft === true,
|
||||
authorLogin: pull.user?.login ?? undefined,
|
||||
baseRef: pull.base.ref,
|
||||
headRef: pull.head.ref,
|
||||
headRepoFullName: pull.head.repo.full_name,
|
||||
htmlUrl: pull.html_url,
|
||||
createdAtGithub: toMillis(pull.created_at),
|
||||
updatedAtGithub: toMillis(pull.updated_at),
|
||||
mergedAtGithub: toMillis(pull.merged_at),
|
||||
});
|
||||
|
||||
export const listPullRequests = async (
|
||||
octokit: Octokit,
|
||||
args: { owner: string; repo: string; head?: string },
|
||||
) => {
|
||||
const result = await octokit.rest.pulls.list({
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
state: 'all',
|
||||
per_page: 100,
|
||||
head: args.head,
|
||||
});
|
||||
return result.data.map((pull) =>
|
||||
normalizePullRequest(`${args.owner}/${args.repo}`, pull),
|
||||
);
|
||||
};
|
||||
|
||||
export const syncForkBranch = async (
|
||||
octokit: Octokit,
|
||||
args: { forkOwner: string; forkRepo: string; branch: string },
|
||||
) => {
|
||||
const result = await octokit.rest.repos.mergeUpstream({
|
||||
owner: args.forkOwner,
|
||||
repo: args.forkRepo,
|
||||
branch: args.branch,
|
||||
});
|
||||
return result.data;
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
'use node';
|
||||
|
||||
import { createSign } from 'node:crypto';
|
||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { ActionCtx } from './_generated/server';
|
||||
import { internal } from './_generated/api';
|
||||
import { action } from './_generated/server';
|
||||
|
||||
type GitHubInstallationAccount = {
|
||||
id?: number;
|
||||
login?: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
|
||||
type GitHubInstallation = {
|
||||
id: number;
|
||||
account?: GitHubInstallationAccount;
|
||||
};
|
||||
|
||||
type GitHubRepository = {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
fork: boolean;
|
||||
html_url: string;
|
||||
default_branch: string;
|
||||
owner: {
|
||||
login: string;
|
||||
};
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
type GitHubListRepositoriesResponse = {
|
||||
repositories: GitHubRepository[];
|
||||
};
|
||||
|
||||
const base64Url = (value: string | Buffer) =>
|
||||
Buffer.from(value)
|
||||
.toString('base64')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_')
|
||||
.replaceAll('=', '');
|
||||
|
||||
const getEnv = (name: string) => {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new ConvexError(`${name} is not configured.`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizePrivateKey = (value: string) => value.replaceAll('\\n', '\n');
|
||||
|
||||
const firstText = (...values: (string | null | undefined)[]) =>
|
||||
values.map((value) => value?.trim()).find((value) => value);
|
||||
|
||||
const createGitHubAppJwt = () => {
|
||||
const appId = getEnv('GITHUB_APP_ID');
|
||||
const privateKey = normalizePrivateKey(getEnv('GITHUB_APP_PRIVATE_KEY'));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const payload = base64Url(
|
||||
JSON.stringify({
|
||||
iat: now - 60,
|
||||
exp: now + 9 * 60,
|
||||
iss: appId,
|
||||
}),
|
||||
);
|
||||
const body = `${header}.${payload}`;
|
||||
const signature = createSign('RSA-SHA256').update(body).sign(privateKey);
|
||||
return `${body}.${base64Url(signature)}`;
|
||||
};
|
||||
|
||||
const githubFetch = async <T>(
|
||||
path: string,
|
||||
token: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'User-Agent': 'Spoon',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new ConvexError(
|
||||
`GitHub API request failed (${response.status}): ${body}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
const createInstallationToken = async (installationId: string) => {
|
||||
const jwt = createGitHubAppJwt();
|
||||
const result = await githubFetch<{ token: string; expires_at: string }>(
|
||||
`/app/installations/${installationId}/access_tokens`,
|
||||
jwt,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return result.token;
|
||||
};
|
||||
|
||||
const getRequiredUserId = async (ctx: ActionCtx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
return userId;
|
||||
};
|
||||
|
||||
export const syncConfiguredInstallation = action({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<Id<'gitConnections'>> => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const installationId = getEnv('GITHUB_APP_INSTALLATION_ID');
|
||||
const jwt = createGitHubAppJwt();
|
||||
const installation = await githubFetch<GitHubInstallation>(
|
||||
`/app/installations/${installationId}`,
|
||||
jwt,
|
||||
);
|
||||
const account = installation.account;
|
||||
const displayName =
|
||||
account?.login ?? `GitHub installation ${installationId}`;
|
||||
|
||||
return await ctx.runMutation(internal.github.upsertConnectionForUser, {
|
||||
userId,
|
||||
providerAccountId: account?.id?.toString(),
|
||||
displayName,
|
||||
username: account?.login,
|
||||
avatarUrl: account?.avatar_url,
|
||||
installationId: installation.id.toString(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const listInstallationRepositories = action({
|
||||
args: {},
|
||||
handler: async (
|
||||
ctx,
|
||||
): Promise<
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
fullName: string;
|
||||
owner: string;
|
||||
private: boolean;
|
||||
fork: boolean;
|
||||
url: string;
|
||||
defaultBranch: string;
|
||||
description?: string;
|
||||
}[]
|
||||
> => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const connection = await ctx.runQuery(
|
||||
internal.github.getConnectionForUser,
|
||||
{
|
||||
userId,
|
||||
},
|
||||
);
|
||||
if (!connection?.installationId) {
|
||||
throw new ConvexError('Connect a GitHub App installation first.');
|
||||
}
|
||||
|
||||
const token = await createInstallationToken(connection.installationId);
|
||||
const result = await githubFetch<GitHubListRepositoriesResponse>(
|
||||
'/installation/repositories?per_page=100',
|
||||
token,
|
||||
);
|
||||
|
||||
return result.repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
owner: repo.owner.login,
|
||||
private: repo.private,
|
||||
fork: repo.fork,
|
||||
url: repo.html_url,
|
||||
defaultBranch: repo.default_branch,
|
||||
description: repo.description ?? undefined,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const createFork = action({
|
||||
args: {
|
||||
upstreamOwner: v.string(),
|
||||
upstreamRepo: v.string(),
|
||||
name: v.optional(v.string()),
|
||||
description: v.optional(v.string()),
|
||||
organization: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<'spoons'>> => {
|
||||
const userId = await getRequiredUserId(ctx);
|
||||
const connection = await ctx.runQuery(
|
||||
internal.github.getConnectionForUser,
|
||||
{
|
||||
userId,
|
||||
},
|
||||
);
|
||||
if (!connection?.installationId) {
|
||||
throw new ConvexError('Connect a GitHub App installation first.');
|
||||
}
|
||||
|
||||
const upstreamOwner = args.upstreamOwner.trim();
|
||||
const upstreamRepo = args.upstreamRepo.trim();
|
||||
if (!upstreamOwner || !upstreamRepo) {
|
||||
throw new ConvexError('Upstream owner and repository are required.');
|
||||
}
|
||||
|
||||
const token = await createInstallationToken(connection.installationId);
|
||||
const upstream = await githubFetch<GitHubRepository>(
|
||||
`/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent(
|
||||
upstreamRepo,
|
||||
)}`,
|
||||
token,
|
||||
);
|
||||
|
||||
const body: Record<string, string | boolean> = {
|
||||
default_branch_only: false,
|
||||
};
|
||||
const forkName = args.name?.trim();
|
||||
if (forkName) body.name = forkName;
|
||||
const organization = args.organization?.trim();
|
||||
if (organization) body.organization = organization;
|
||||
|
||||
const fork = await githubFetch<GitHubRepository>(
|
||||
`/repos/${encodeURIComponent(upstreamOwner)}/${encodeURIComponent(
|
||||
upstreamRepo,
|
||||
)}/forks`,
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const description = firstText(args.description, upstream.description);
|
||||
|
||||
return await ctx.runMutation(internal.github.createForkSpoonRecord, {
|
||||
ownerId: userId,
|
||||
name: fork.name,
|
||||
description,
|
||||
upstreamOwner,
|
||||
upstreamRepo,
|
||||
upstreamDefaultBranch: upstream.default_branch,
|
||||
upstreamUrl: upstream.html_url,
|
||||
forkOwner: fork.owner.login,
|
||||
forkRepo: fork.name,
|
||||
forkDefaultBranch: fork.default_branch,
|
||||
forkUrl: fork.html_url,
|
||||
visibility: fork.private ? 'private' : 'public',
|
||||
connectionId: connection._id,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
'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 type { GitHubCompareSummary } from './githubClient';
|
||||
import { internal } from './_generated/api';
|
||||
import { action, internalAction } from './_generated/server';
|
||||
import {
|
||||
compareAcrossForkNetwork,
|
||||
getInstallationOctokit,
|
||||
getRepository,
|
||||
getSpoonInstallationId,
|
||||
listPullRequests,
|
||||
syncForkBranch,
|
||||
} from './githubClient';
|
||||
|
||||
const getRequiredUserId = async (ctx: ActionCtx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
return userId;
|
||||
};
|
||||
|
||||
const toStatus = (upstreamAheadBy: number, forkAheadBy: number) => {
|
||||
if (upstreamAheadBy === 0 && forkAheadBy === 0) return 'up_to_date' as const;
|
||||
if (upstreamAheadBy > 0 && forkAheadBy === 0) return 'behind' as const;
|
||||
if (upstreamAheadBy === 0 && forkAheadBy > 0) return 'ahead' as const;
|
||||
if (upstreamAheadBy > 0 && forkAheadBy > 0) return 'diverged' as const;
|
||||
return 'unknown' as const;
|
||||
};
|
||||
|
||||
const getLastCommitAt = (compare: GitHubCompareSummary) =>
|
||||
compare.commits[compare.commits.length - 1]?.committedAt;
|
||||
|
||||
const ensureForkMetadata = (spoon: Doc<'spoons'>) => {
|
||||
if (!spoon.forkOwner || !spoon.forkRepo) {
|
||||
throw new ConvexError('Fork metadata is required before GitHub refresh.');
|
||||
}
|
||||
return {
|
||||
forkOwner: spoon.forkOwner,
|
||||
forkRepo: spoon.forkRepo,
|
||||
forkBranch: spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
|
||||
};
|
||||
};
|
||||
|
||||
const refreshOwnedSpoon = async (
|
||||
ctx: ActionCtx,
|
||||
ownerId: Id<'users'>,
|
||||
spoonId: Id<'spoons'>,
|
||||
kind: 'manual_check' | 'scheduled_check' = 'manual_check',
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
status: ReturnType<typeof toStatus>;
|
||||
upstreamAheadBy: number;
|
||||
forkAheadBy: number;
|
||||
}> => {
|
||||
const spoon: Doc<'spoons'> = await ctx.runQuery(
|
||||
internal.spoons.getOwnedForAction,
|
||||
{
|
||||
spoonId,
|
||||
ownerId,
|
||||
},
|
||||
);
|
||||
if (spoon.provider !== 'github') {
|
||||
throw new ConvexError(
|
||||
'GitHub refresh is only available for GitHub Spoons.',
|
||||
);
|
||||
}
|
||||
const connection = await ctx.runQuery(internal.github.getConnectionForUser, {
|
||||
userId: ownerId,
|
||||
});
|
||||
const installationId = getSpoonInstallationId(spoon, connection);
|
||||
const { forkOwner, forkRepo, forkBranch } = ensureForkMetadata(spoon);
|
||||
const syncRunId = await ctx.runMutation(internal.syncRuns.createInternal, {
|
||||
spoonId,
|
||||
ownerId,
|
||||
kind,
|
||||
status: 'running',
|
||||
summary: 'Refreshing GitHub repository state.',
|
||||
});
|
||||
|
||||
await ctx.runMutation(internal.spoons.patchSyncFields, {
|
||||
spoonId,
|
||||
syncStatus: 'checking',
|
||||
lastSyncRunId: syncRunId,
|
||||
lastGithubRefreshAt: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
const octokit = getInstallationOctokit(installationId);
|
||||
const [upstreamRepo, forkRepoData] = await Promise.all([
|
||||
getRepository(octokit, spoon.upstreamOwner, spoon.upstreamRepo),
|
||||
getRepository(octokit, forkOwner, forkRepo),
|
||||
]);
|
||||
const upstreamBranch =
|
||||
spoon.upstreamDefaultBranch || upstreamRepo.default_branch;
|
||||
const resolvedForkBranch = forkBranch || forkRepoData.default_branch;
|
||||
const [upstreamCompare, forkCompare, forkPulls, upstreamPulls]: [
|
||||
GitHubCompareSummary,
|
||||
GitHubCompareSummary,
|
||||
Awaited<ReturnType<typeof listPullRequests>>,
|
||||
Awaited<ReturnType<typeof listPullRequests>>,
|
||||
] = await Promise.all([
|
||||
compareAcrossForkNetwork(octokit, {
|
||||
owner: spoon.upstreamOwner,
|
||||
repo: spoon.upstreamRepo,
|
||||
baseOwner: forkOwner,
|
||||
baseBranch: resolvedForkBranch,
|
||||
headOwner: spoon.upstreamOwner,
|
||||
headBranch: upstreamBranch,
|
||||
}),
|
||||
compareAcrossForkNetwork(octokit, {
|
||||
owner: spoon.upstreamOwner,
|
||||
repo: spoon.upstreamRepo,
|
||||
baseOwner: spoon.upstreamOwner,
|
||||
baseBranch: upstreamBranch,
|
||||
headOwner: forkOwner,
|
||||
headBranch: resolvedForkBranch,
|
||||
}),
|
||||
listPullRequests(octokit, { owner: forkOwner, repo: forkRepo }),
|
||||
listPullRequests(octokit, {
|
||||
owner: spoon.upstreamOwner,
|
||||
repo: spoon.upstreamRepo,
|
||||
head: `${forkOwner}:${resolvedForkBranch}`,
|
||||
}),
|
||||
]);
|
||||
const status = toStatus(upstreamCompare.aheadBy, forkCompare.aheadBy);
|
||||
const openForkPullRequestCount = forkPulls.filter(
|
||||
(pull) => pull.state === 'open',
|
||||
).length;
|
||||
const openUpstreamPullRequestCount = upstreamPulls.filter(
|
||||
(pull) => pull.state === 'open',
|
||||
).length;
|
||||
const now = Date.now();
|
||||
|
||||
await Promise.all([
|
||||
ctx.runMutation(internal.spoonState.upsert, {
|
||||
spoonId,
|
||||
ownerId,
|
||||
upstreamFullName: upstreamRepo.full_name,
|
||||
forkFullName: forkRepoData.full_name,
|
||||
upstreamDefaultBranch: upstreamRepo.default_branch,
|
||||
forkDefaultBranch: forkRepoData.default_branch,
|
||||
upstreamHeadSha: upstreamCompare.headSha,
|
||||
forkHeadSha: forkCompare.headSha,
|
||||
mergeBaseSha: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
|
||||
upstreamAheadBy: upstreamCompare.aheadBy,
|
||||
forkAheadBy: forkCompare.aheadBy,
|
||||
status,
|
||||
openForkPullRequestCount,
|
||||
openUpstreamPullRequestCount,
|
||||
lastCommitAt:
|
||||
getLastCommitAt(upstreamCompare) ?? getLastCommitAt(forkCompare),
|
||||
rawCompareUrl: upstreamCompare.htmlUrl,
|
||||
}),
|
||||
ctx.runMutation(internal.spoonCommits.replaceForSpoon, {
|
||||
spoonId,
|
||||
ownerId,
|
||||
side: 'upstream',
|
||||
commits: upstreamCompare.commits,
|
||||
}),
|
||||
ctx.runMutation(internal.spoonCommits.replaceForSpoon, {
|
||||
spoonId,
|
||||
ownerId,
|
||||
side: 'fork',
|
||||
commits: forkCompare.commits,
|
||||
}),
|
||||
ctx.runMutation(internal.spoonPullRequests.replaceForSpoon, {
|
||||
spoonId,
|
||||
ownerId,
|
||||
scope: 'fork',
|
||||
pullRequests: forkPulls,
|
||||
}),
|
||||
ctx.runMutation(internal.spoonPullRequests.replaceForSpoon, {
|
||||
spoonId,
|
||||
ownerId,
|
||||
scope: 'from_fork_to_upstream',
|
||||
pullRequests: upstreamPulls,
|
||||
}),
|
||||
]);
|
||||
|
||||
await ctx.runMutation(internal.spoons.patchSyncFields, {
|
||||
spoonId,
|
||||
syncStatus: status,
|
||||
upstreamAheadBy: upstreamCompare.aheadBy,
|
||||
forkAheadBy: forkCompare.aheadBy,
|
||||
lastMergeBaseCommit:
|
||||
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
|
||||
lastUpstreamCommit: upstreamCompare.headSha,
|
||||
lastForkCommit: forkCompare.headSha,
|
||||
lastGithubRefreshAt: now,
|
||||
lastSuccessfulRefreshAt: now,
|
||||
lastCheckedAt: now,
|
||||
lastError: '',
|
||||
});
|
||||
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||
syncRunId,
|
||||
status: status === 'diverged' ? 'needs_review' : 'clean',
|
||||
summary: `GitHub refresh complete: ${upstreamCompare.aheadBy} upstream commit(s), ${forkCompare.aheadBy} fork-only commit(s).`,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
upstreamAheadBy: upstreamCompare.aheadBy,
|
||||
forkAheadBy: forkCompare.aheadBy,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await Promise.all([
|
||||
ctx.runMutation(internal.spoons.patchSyncFields, {
|
||||
spoonId,
|
||||
syncStatus: 'error',
|
||||
lastGithubRefreshAt: Date.now(),
|
||||
lastCheckedAt: Date.now(),
|
||||
lastError: message,
|
||||
}),
|
||||
ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||
syncRunId,
|
||||
status: 'failed',
|
||||
error: message,
|
||||
}),
|
||||
]);
|
||||
throw new ConvexError(message);
|
||||
}
|
||||
};
|
||||
|
||||
export const refreshSpoonGithubState = action({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (
|
||||
ctx,
|
||||
{ spoonId },
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
status: ReturnType<typeof toStatus>;
|
||||
upstreamAheadBy: number;
|
||||
forkAheadBy: number;
|
||||
}> => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
return await refreshOwnedSpoon(ctx, ownerId, spoonId);
|
||||
},
|
||||
});
|
||||
|
||||
export const syncForkWithUpstream = action({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (
|
||||
ctx,
|
||||
{ spoonId },
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
status: ReturnType<typeof toStatus>;
|
||||
upstreamAheadBy: number;
|
||||
forkAheadBy: number;
|
||||
}> => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const spoon: Doc<'spoons'> = await ctx.runQuery(
|
||||
internal.spoons.getOwnedForAction,
|
||||
{
|
||||
spoonId,
|
||||
ownerId,
|
||||
},
|
||||
);
|
||||
const state = await ctx.runQuery(internal.spoonState.getInternal, {
|
||||
spoonId,
|
||||
ownerId,
|
||||
});
|
||||
if (state?.status !== 'behind' || state.forkAheadBy !== 0) {
|
||||
throw new ConvexError(
|
||||
'Sync is only available for behind, non-diverged forks.',
|
||||
);
|
||||
}
|
||||
const connection = await ctx.runQuery(
|
||||
internal.github.getConnectionForUser,
|
||||
{
|
||||
userId: ownerId,
|
||||
},
|
||||
);
|
||||
const installationId = getSpoonInstallationId(spoon, connection);
|
||||
const { forkOwner, forkRepo, forkBranch } = ensureForkMetadata(spoon);
|
||||
const syncRunId = await ctx.runMutation(internal.syncRuns.createInternal, {
|
||||
spoonId,
|
||||
ownerId,
|
||||
kind: 'merge_attempt',
|
||||
status: 'running',
|
||||
summary: 'Syncing fork branch with upstream.',
|
||||
});
|
||||
try {
|
||||
const octokit = getInstallationOctokit(installationId);
|
||||
await syncForkBranch(octokit, {
|
||||
forkOwner,
|
||||
forkRepo,
|
||||
branch: forkBranch,
|
||||
});
|
||||
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||
syncRunId,
|
||||
status: 'merged',
|
||||
summary: 'GitHub fork sync completed.',
|
||||
});
|
||||
return await refreshOwnedSpoon(ctx, ownerId, spoonId, 'manual_check');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const conflict = message.toLowerCase().includes('conflict');
|
||||
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||
syncRunId,
|
||||
status: conflict ? 'conflict' : 'failed',
|
||||
error: message,
|
||||
});
|
||||
await ctx.runMutation(internal.spoons.patchSyncFields, {
|
||||
spoonId,
|
||||
syncStatus: conflict ? 'conflict' : 'error',
|
||||
lastError: message,
|
||||
});
|
||||
throw new ConvexError(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const refreshDueSpoons = internalAction({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (
|
||||
ctx,
|
||||
{ limit },
|
||||
): Promise<
|
||||
(
|
||||
| {
|
||||
success: boolean;
|
||||
status: ReturnType<typeof toStatus>;
|
||||
upstreamAheadBy: number;
|
||||
forkAheadBy: number;
|
||||
}
|
||||
| { success: false; spoonId: Id<'spoons'>; error: string }
|
||||
)[]
|
||||
> => {
|
||||
const due: { spoonId: Id<'spoons'>; ownerId: Id<'users'> }[] =
|
||||
await ctx.runQuery(internal.spoonSettings.listRefreshDue, {
|
||||
limit: limit ?? 10,
|
||||
});
|
||||
const results: (
|
||||
| {
|
||||
success: boolean;
|
||||
status: ReturnType<typeof toStatus>;
|
||||
upstreamAheadBy: number;
|
||||
forkAheadBy: number;
|
||||
}
|
||||
| { success: false; spoonId: Id<'spoons'>; error: string }
|
||||
)[] = [];
|
||||
for (const item of due) {
|
||||
try {
|
||||
results.push(
|
||||
await refreshOwnedSpoon(
|
||||
ctx,
|
||||
item.ownerId,
|
||||
item.spoonId,
|
||||
'scheduled_check',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
success: false,
|
||||
spoonId: item.spoonId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
});
|
||||
Vendored
+12
@@ -5,6 +5,18 @@ declare const process: {
|
||||
readonly USESEND_API_KEY?: string;
|
||||
readonly USESEND_URL?: string;
|
||||
readonly USESEND_FROM_EMAIL?: string;
|
||||
readonly AUTH_GITHUB_ID?: string;
|
||||
readonly AUTH_GITHUB_SECRET?: string;
|
||||
readonly GITHUB_APP_ID?: string;
|
||||
readonly GITHUB_APP_CLIENT_ID?: string;
|
||||
readonly GITHUB_APP_CLIENT_SECRET?: string;
|
||||
readonly GITHUB_APP_PRIVATE_KEY?: string;
|
||||
readonly GITHUB_APP_WEBHOOK_SECRET?: string;
|
||||
readonly GITHUB_APP_SLUG?: string;
|
||||
readonly GITHUB_APP_INSTALLATION_ID?: string;
|
||||
readonly GITHUB_APP_OWNER?: string;
|
||||
readonly SPOON_ENCRYPTION_KEY?: string;
|
||||
readonly SPOON_WORKER_TOKEN?: string;
|
||||
readonly [key: string]: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { query } from './_generated/server';
|
||||
import { getRequiredUserId } from './model';
|
||||
|
||||
export const getStatus = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
await getRequiredUserId(ctx);
|
||||
return {
|
||||
encryptionConfigured: Boolean(
|
||||
process.env.SPOON_ENCRYPTION_KEY?.trim() ??
|
||||
process.env.INSTANCE_SECRET?.trim(),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ConvexError } from 'convex/values';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
type ReviewRisk = 'low' | 'medium' | 'high';
|
||||
type ReviewAction = 'sync' | 'open_review_pr' | 'manual_review' | 'do_not_sync';
|
||||
|
||||
export type AiCompatibilityReview = {
|
||||
summary: string;
|
||||
risk: ReviewRisk;
|
||||
compatible: boolean;
|
||||
requiresHumanReview: boolean;
|
||||
recommendedAction: ReviewAction;
|
||||
potentialConflicts: string[];
|
||||
importantFiles: string[];
|
||||
reasoningSummary: string;
|
||||
};
|
||||
|
||||
export type ReviewInput = {
|
||||
spoonName: string;
|
||||
upstreamFullName: string;
|
||||
forkFullName: string;
|
||||
status: string;
|
||||
upstreamAheadBy: number;
|
||||
forkAheadBy: number;
|
||||
upstreamCommits: {
|
||||
sha: string;
|
||||
message: string;
|
||||
authorName?: string;
|
||||
committedAt?: number;
|
||||
}[];
|
||||
forkCommits: {
|
||||
sha: string;
|
||||
message: string;
|
||||
authorName?: string;
|
||||
committedAt?: number;
|
||||
}[];
|
||||
importantFilePatterns?: string[];
|
||||
ignoredFilePatterns?: string[];
|
||||
};
|
||||
|
||||
export type ReasoningEffort =
|
||||
| 'none'
|
||||
| 'minimal'
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'xhigh';
|
||||
|
||||
export type OpenAiReviewSettings = {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
};
|
||||
|
||||
const reviewSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
compatible: { type: 'boolean' },
|
||||
requiresHumanReview: { type: 'boolean' },
|
||||
recommendedAction: {
|
||||
type: 'string',
|
||||
enum: ['sync', 'open_review_pr', 'manual_review', 'do_not_sync'],
|
||||
},
|
||||
potentialConflicts: { type: 'array', items: { type: 'string' } },
|
||||
importantFiles: { type: 'array', items: { type: 'string' } },
|
||||
reasoningSummary: { type: 'string' },
|
||||
},
|
||||
required: [
|
||||
'summary',
|
||||
'risk',
|
||||
'compatible',
|
||||
'requiresHumanReview',
|
||||
'recommendedAction',
|
||||
'potentialConflicts',
|
||||
'importantFiles',
|
||||
'reasoningSummary',
|
||||
],
|
||||
} as const;
|
||||
|
||||
const isReviewRisk = (value: unknown): value is ReviewRisk =>
|
||||
value === 'low' || value === 'medium' || value === 'high';
|
||||
|
||||
const isReviewAction = (value: unknown): value is ReviewAction =>
|
||||
value === 'sync' ||
|
||||
value === 'open_review_pr' ||
|
||||
value === 'manual_review' ||
|
||||
value === 'do_not_sync';
|
||||
|
||||
const validateReview = (value: unknown): AiCompatibilityReview => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new ConvexError('OpenAI returned an invalid review payload.');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.summary !== 'string' ||
|
||||
!isReviewRisk(record.risk) ||
|
||||
typeof record.compatible !== 'boolean' ||
|
||||
typeof record.requiresHumanReview !== 'boolean' ||
|
||||
!isReviewAction(record.recommendedAction) ||
|
||||
!Array.isArray(record.potentialConflicts) ||
|
||||
!Array.isArray(record.importantFiles) ||
|
||||
typeof record.reasoningSummary !== 'string'
|
||||
) {
|
||||
throw new ConvexError('OpenAI review did not match the expected schema.');
|
||||
}
|
||||
return {
|
||||
summary: record.summary,
|
||||
risk: record.risk,
|
||||
compatible: record.compatible,
|
||||
requiresHumanReview: record.requiresHumanReview,
|
||||
recommendedAction: record.recommendedAction,
|
||||
potentialConflicts: record.potentialConflicts.filter(
|
||||
(item): item is string => typeof item === 'string',
|
||||
),
|
||||
importantFiles: record.importantFiles.filter(
|
||||
(item): item is string => typeof item === 'string',
|
||||
),
|
||||
reasoningSummary: record.reasoningSummary,
|
||||
};
|
||||
};
|
||||
|
||||
export const reviewUpstreamCompatibility = async (
|
||||
input: ReviewInput,
|
||||
settings: OpenAiReviewSettings,
|
||||
): Promise<AiCompatibilityReview> => {
|
||||
const response = await new OpenAI({
|
||||
apiKey: settings.apiKey,
|
||||
}).responses.create({
|
||||
model: settings.model,
|
||||
store: false,
|
||||
reasoning: {
|
||||
effort: settings.reasoningEffort,
|
||||
},
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are reviewing whether upstream changes can be safely brought into a maintained fork. You do not execute code. You do not claim tests passed. Treat fork-only commits as user customizations that must be preserved. If changed files overlap with fork-only changes, increase risk. If patch context is incomplete, say so and require human review. Prefer conservative recommendations. Return only the required structured output.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify(input, null, 2),
|
||||
},
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'spoon_upstream_compatibility_review',
|
||||
strict: true,
|
||||
schema: reviewSchema,
|
||||
},
|
||||
},
|
||||
});
|
||||
const raw = response.output_text;
|
||||
if (!raw) throw new ConvexError('OpenAI returned an empty review.');
|
||||
return validateReview(JSON.parse(raw));
|
||||
};
|
||||
@@ -104,6 +104,30 @@ const applicationTables = {
|
||||
lastCheckedAt: v.optional(v.number()),
|
||||
lastUpstreamCommit: v.optional(v.string()),
|
||||
lastForkCommit: v.optional(v.string()),
|
||||
connectionId: v.optional(v.id('gitConnections')),
|
||||
githubInstallationId: v.optional(v.string()),
|
||||
githubRepositoryId: v.optional(v.number()),
|
||||
upstreamRepositoryId: v.optional(v.number()),
|
||||
syncStatus: v.optional(
|
||||
v.union(
|
||||
v.literal('unknown'),
|
||||
v.literal('up_to_date'),
|
||||
v.literal('behind'),
|
||||
v.literal('ahead'),
|
||||
v.literal('diverged'),
|
||||
v.literal('checking'),
|
||||
v.literal('conflict'),
|
||||
v.literal('error'),
|
||||
),
|
||||
),
|
||||
upstreamAheadBy: v.optional(v.number()),
|
||||
forkAheadBy: v.optional(v.number()),
|
||||
lastMergeBaseCommit: v.optional(v.string()),
|
||||
lastSyncRunId: v.optional(v.id('syncRuns')),
|
||||
lastAiReviewId: v.optional(v.id('aiReviews')),
|
||||
lastGithubRefreshAt: v.optional(v.number()),
|
||||
lastSuccessfulRefreshAt: v.optional(v.number()),
|
||||
lastError: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
@@ -111,6 +135,87 @@ const applicationTables = {
|
||||
.index('by_owner_status', ['ownerId', 'status'])
|
||||
.index('by_owner_provider', ['ownerId', 'provider'])
|
||||
.index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']),
|
||||
spoonRepositoryStates: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
upstreamFullName: v.string(),
|
||||
forkFullName: v.string(),
|
||||
upstreamDefaultBranch: v.string(),
|
||||
forkDefaultBranch: v.string(),
|
||||
upstreamHeadSha: v.optional(v.string()),
|
||||
forkHeadSha: v.optional(v.string()),
|
||||
mergeBaseSha: v.optional(v.string()),
|
||||
upstreamAheadBy: v.number(),
|
||||
forkAheadBy: v.number(),
|
||||
status: v.union(
|
||||
v.literal('up_to_date'),
|
||||
v.literal('behind'),
|
||||
v.literal('ahead'),
|
||||
v.literal('diverged'),
|
||||
v.literal('unknown'),
|
||||
),
|
||||
openForkPullRequestCount: v.number(),
|
||||
openUpstreamPullRequestCount: v.number(),
|
||||
lastCommitAt: v.optional(v.number()),
|
||||
rawCompareUrl: v.optional(v.string()),
|
||||
refreshedAt: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_status', ['ownerId', 'status']),
|
||||
spoonCommits: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
sha: v.string(),
|
||||
side: v.union(v.literal('upstream'), v.literal('fork')),
|
||||
message: v.string(),
|
||||
authorName: v.optional(v.string()),
|
||||
authorEmail: v.optional(v.string()),
|
||||
authorLogin: v.optional(v.string()),
|
||||
committedAt: v.optional(v.number()),
|
||||
htmlUrl: v.optional(v.string()),
|
||||
filesChanged: v.optional(v.number()),
|
||||
additions: v.optional(v.number()),
|
||||
deletions: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_spoon_side', ['spoonId', 'side'])
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_sha', ['spoonId', 'sha'])
|
||||
.index('by_committed', ['spoonId', 'committedAt']),
|
||||
spoonPullRequests: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
githubId: v.number(),
|
||||
number: v.number(),
|
||||
repoFullName: v.string(),
|
||||
scope: v.union(
|
||||
v.literal('fork'),
|
||||
v.literal('upstream'),
|
||||
v.literal('from_fork_to_upstream'),
|
||||
),
|
||||
title: v.string(),
|
||||
state: v.union(v.literal('open'), v.literal('closed'), v.literal('merged')),
|
||||
draft: v.boolean(),
|
||||
authorLogin: v.optional(v.string()),
|
||||
baseRef: v.string(),
|
||||
headRef: v.string(),
|
||||
headRepoFullName: v.optional(v.string()),
|
||||
htmlUrl: v.string(),
|
||||
createdAtGithub: v.optional(v.number()),
|
||||
updatedAtGithub: v.optional(v.number()),
|
||||
mergedAtGithub: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_spoon_scope', ['spoonId', 'scope'])
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_github_id', ['githubId'])
|
||||
.index('by_state', ['spoonId', 'state']),
|
||||
syncRuns: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
@@ -143,10 +248,113 @@ const applicationTables = {
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner_status', ['ownerId', 'status'])
|
||||
.index('by_created', ['createdAt']),
|
||||
aiReviews: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
syncRunId: v.optional(v.id('syncRuns')),
|
||||
model: v.string(),
|
||||
status: v.union(
|
||||
v.literal('queued'),
|
||||
v.literal('running'),
|
||||
v.literal('completed'),
|
||||
v.literal('failed'),
|
||||
),
|
||||
reviewType: v.union(
|
||||
v.literal('upstream_update'),
|
||||
v.literal('manual_prompt'),
|
||||
v.literal('merge_safety'),
|
||||
),
|
||||
inputSummary: v.string(),
|
||||
outputSummary: v.optional(v.string()),
|
||||
risk: v.union(
|
||||
v.literal('unknown'),
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high'),
|
||||
),
|
||||
compatible: v.boolean(),
|
||||
requiresHumanReview: v.boolean(),
|
||||
recommendedAction: v.union(
|
||||
v.literal('sync'),
|
||||
v.literal('open_review_pr'),
|
||||
v.literal('manual_review'),
|
||||
v.literal('do_not_sync'),
|
||||
v.literal('unknown'),
|
||||
),
|
||||
potentialConflicts: v.optional(v.array(v.string())),
|
||||
importantFiles: v.optional(v.array(v.string())),
|
||||
reasoningSummary: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
completedAt: v.optional(v.number()),
|
||||
})
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_status', ['ownerId', 'status'])
|
||||
.index('by_sync_run', ['syncRunId'])
|
||||
.index('by_created', ['createdAt']),
|
||||
spoonSettings: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
autoRefreshEnabled: v.boolean(),
|
||||
autoReviewEnabled: v.boolean(),
|
||||
autoSyncEnabled: v.boolean(),
|
||||
requireAiLowRiskForSync: v.boolean(),
|
||||
requireCleanCompareForSync: v.boolean(),
|
||||
ignoredFilePatterns: v.optional(v.array(v.string())),
|
||||
importantFilePatterns: v.optional(v.array(v.string())),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner', ['ownerId']),
|
||||
spoonRemotes: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
label: v.string(),
|
||||
url: v.string(),
|
||||
remoteName: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner', ['ownerId']),
|
||||
userAiSettings: defineTable({
|
||||
userId: v.id('users'),
|
||||
provider: v.literal('openai'),
|
||||
encryptedApiKey: v.optional(v.string()),
|
||||
apiKeyPreview: v.optional(v.string()),
|
||||
model: v.string(),
|
||||
reasoningEffort: v.union(
|
||||
v.literal('none'),
|
||||
v.literal('minimal'),
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high'),
|
||||
v.literal('xhigh'),
|
||||
),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_user_provider', ['userId', 'provider']),
|
||||
agentRequests: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
agentJobId: v.optional(v.id('agentJobs')),
|
||||
prompt: v.string(),
|
||||
requestType: v.optional(
|
||||
v.union(
|
||||
v.literal('manual_prompt'),
|
||||
v.literal('upstream_review'),
|
||||
v.literal('future_code_change'),
|
||||
),
|
||||
),
|
||||
priority: v.optional(
|
||||
v.union(v.literal('low'), v.literal('normal'), v.literal('high')),
|
||||
),
|
||||
source: v.optional(v.union(v.literal('user'), v.literal('system'))),
|
||||
status: v.union(
|
||||
v.literal('draft'),
|
||||
v.literal('queued'),
|
||||
@@ -157,6 +365,9 @@ const applicationTables = {
|
||||
v.literal('cancelled'),
|
||||
),
|
||||
targetBranch: v.optional(v.string()),
|
||||
selectedSecretIds: v.optional(v.array(v.id('spoonSecrets'))),
|
||||
baseBranch: v.optional(v.string()),
|
||||
requestedBranchName: v.optional(v.string()),
|
||||
mergeRequestUrl: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
@@ -167,6 +378,151 @@ const applicationTables = {
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner_status', ['ownerId', 'status'])
|
||||
.index('by_created', ['createdAt']),
|
||||
spoonSecrets: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
name: v.string(),
|
||||
encryptedValue: v.string(),
|
||||
valuePreview: v.optional(v.string()),
|
||||
description: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_name', ['spoonId', 'name']),
|
||||
spoonAgentSettings: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
enabled: v.boolean(),
|
||||
defaultBaseBranch: v.optional(v.string()),
|
||||
branchPrefix: v.string(),
|
||||
installCommand: v.optional(v.string()),
|
||||
checkCommand: v.optional(v.string()),
|
||||
testCommand: v.optional(v.string()),
|
||||
agentModel: v.string(),
|
||||
reasoningEffort: v.union(
|
||||
v.literal('none'),
|
||||
v.literal('minimal'),
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high'),
|
||||
v.literal('xhigh'),
|
||||
),
|
||||
maxJobDurationMs: v.number(),
|
||||
maxOutputBytes: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner', ['ownerId']),
|
||||
agentJobs: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
agentRequestId: v.id('agentRequests'),
|
||||
status: v.union(
|
||||
v.literal('queued'),
|
||||
v.literal('claimed'),
|
||||
v.literal('preparing'),
|
||||
v.literal('running'),
|
||||
v.literal('checks_running'),
|
||||
v.literal('changes_ready'),
|
||||
v.literal('draft_pr_opened'),
|
||||
v.literal('failed'),
|
||||
v.literal('cancelled'),
|
||||
v.literal('timed_out'),
|
||||
),
|
||||
prompt: v.string(),
|
||||
baseBranch: v.string(),
|
||||
workBranch: v.string(),
|
||||
githubInstallationId: v.optional(v.string()),
|
||||
forkOwner: v.string(),
|
||||
forkRepo: v.string(),
|
||||
forkUrl: v.string(),
|
||||
upstreamOwner: v.string(),
|
||||
upstreamRepo: v.string(),
|
||||
selectedSecretIds: v.array(v.id('spoonSecrets')),
|
||||
model: v.string(),
|
||||
reasoningEffort: v.union(
|
||||
v.literal('none'),
|
||||
v.literal('minimal'),
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high'),
|
||||
v.literal('xhigh'),
|
||||
),
|
||||
commitSha: v.optional(v.string()),
|
||||
pullRequestUrl: v.optional(v.string()),
|
||||
pullRequestNumber: v.optional(v.number()),
|
||||
summary: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
claimedBy: v.optional(v.string()),
|
||||
claimedAt: v.optional(v.number()),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_request', ['agentRequestId'])
|
||||
.index('by_status', ['status'])
|
||||
.index('by_claim', ['status', 'createdAt']),
|
||||
agentJobEvents: defineTable({
|
||||
jobId: v.id('agentJobs'),
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
level: v.union(
|
||||
v.literal('debug'),
|
||||
v.literal('info'),
|
||||
v.literal('warn'),
|
||||
v.literal('error'),
|
||||
),
|
||||
phase: v.union(
|
||||
v.literal('queued'),
|
||||
v.literal('clone'),
|
||||
v.literal('plan'),
|
||||
v.literal('edit'),
|
||||
v.literal('install'),
|
||||
v.literal('check'),
|
||||
v.literal('test'),
|
||||
v.literal('commit'),
|
||||
v.literal('push'),
|
||||
v.literal('pr'),
|
||||
v.literal('cleanup'),
|
||||
),
|
||||
message: v.string(),
|
||||
metadata: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index('by_job', ['jobId'])
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner', ['ownerId']),
|
||||
agentJobArtifacts: defineTable({
|
||||
jobId: v.id('agentJobs'),
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
kind: v.union(
|
||||
v.literal('plan'),
|
||||
v.literal('diff'),
|
||||
v.literal('test_output'),
|
||||
v.literal('summary'),
|
||||
v.literal('error'),
|
||||
v.literal('pr_body'),
|
||||
),
|
||||
title: v.string(),
|
||||
content: v.string(),
|
||||
contentType: v.union(
|
||||
v.literal('text/markdown'),
|
||||
v.literal('text/plain'),
|
||||
v.literal('application/json'),
|
||||
v.literal('text/x-diff'),
|
||||
),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index('by_job', ['jobId'])
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner', ['ownerId']),
|
||||
};
|
||||
|
||||
export default defineSchema({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use node';
|
||||
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import { ConvexError } from 'convex/values';
|
||||
|
||||
const getSecret = () => {
|
||||
const secret =
|
||||
process.env.SPOON_ENCRYPTION_KEY?.trim() ??
|
||||
process.env.INSTANCE_SECRET?.trim();
|
||||
if (!secret) {
|
||||
throw new ConvexError(
|
||||
'SPOON_ENCRYPTION_KEY is not configured. Add it before storing user API keys.',
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
};
|
||||
|
||||
const getKey = () => createHash('sha256').update(getSecret()).digest();
|
||||
|
||||
export const encryptSecret = (plaintext: string) => {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [
|
||||
iv.toString('base64url'),
|
||||
tag.toString('base64url'),
|
||||
ciphertext.toString('base64url'),
|
||||
].join('.');
|
||||
};
|
||||
|
||||
export const decryptSecret = (encrypted: string) => {
|
||||
const [ivRaw, tagRaw, ciphertextRaw] = encrypted.split('.');
|
||||
if (!ivRaw || !tagRaw || !ciphertextRaw) {
|
||||
throw new ConvexError('Stored secret has an invalid format.');
|
||||
}
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
getKey(),
|
||||
Buffer.from(ivRaw, 'base64url'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextRaw, 'base64url')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plaintext.toString('utf8');
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
|
||||
|
||||
const reasoningEffort = v.union(
|
||||
v.literal('none'),
|
||||
v.literal('minimal'),
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high'),
|
||||
v.literal('xhigh'),
|
||||
);
|
||||
|
||||
const defaults = {
|
||||
enabled: true,
|
||||
branchPrefix: 'spoon/agent',
|
||||
agentModel: 'gpt-5.1-codex',
|
||||
reasoningEffort: 'high' as const,
|
||||
maxJobDurationMs: 1_800_000,
|
||||
maxOutputBytes: 200_000,
|
||||
};
|
||||
|
||||
export const getForSpoon = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
const settings = await ctx.db
|
||||
.query('spoonAgentSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.first();
|
||||
return (
|
||||
settings ?? {
|
||||
spoonId,
|
||||
ownerId,
|
||||
...defaults,
|
||||
defaultBaseBranch:
|
||||
spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
enabled: v.optional(v.boolean()),
|
||||
defaultBaseBranch: v.optional(v.string()),
|
||||
branchPrefix: v.optional(v.string()),
|
||||
installCommand: v.optional(v.string()),
|
||||
checkCommand: v.optional(v.string()),
|
||||
testCommand: v.optional(v.string()),
|
||||
agentModel: v.optional(v.string()),
|
||||
reasoningEffort: v.optional(reasoningEffort),
|
||||
maxJobDurationMs: v.optional(v.number()),
|
||||
maxOutputBytes: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const spoon = await getOwnedSpoon(ctx, args.spoonId, ownerId);
|
||||
const now = Date.now();
|
||||
let settings = await ctx.db
|
||||
.query('spoonAgentSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
|
||||
.first();
|
||||
|
||||
if (!settings) {
|
||||
const id = await ctx.db.insert('spoonAgentSettings', {
|
||||
spoonId: args.spoonId,
|
||||
ownerId,
|
||||
...defaults,
|
||||
defaultBaseBranch:
|
||||
spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
settings = await ctx.db.get(id);
|
||||
}
|
||||
|
||||
if (!settings) throw new Error('Agent settings not found.');
|
||||
|
||||
const patch: Partial<Doc<'spoonAgentSettings'>> = { updatedAt: now };
|
||||
if (args.enabled !== undefined) patch.enabled = args.enabled;
|
||||
if (args.defaultBaseBranch !== undefined) {
|
||||
patch.defaultBaseBranch = optionalText(args.defaultBaseBranch);
|
||||
}
|
||||
if (args.branchPrefix !== undefined) {
|
||||
patch.branchPrefix =
|
||||
optionalText(args.branchPrefix) ?? defaults.branchPrefix;
|
||||
}
|
||||
if (args.installCommand !== undefined) {
|
||||
patch.installCommand = optionalText(args.installCommand);
|
||||
}
|
||||
if (args.checkCommand !== undefined) {
|
||||
patch.checkCommand = optionalText(args.checkCommand);
|
||||
}
|
||||
if (args.testCommand !== undefined) {
|
||||
patch.testCommand = optionalText(args.testCommand);
|
||||
}
|
||||
if (args.agentModel !== undefined) {
|
||||
patch.agentModel = optionalText(args.agentModel) ?? defaults.agentModel;
|
||||
}
|
||||
if (args.reasoningEffort !== undefined) {
|
||||
patch.reasoningEffort = args.reasoningEffort;
|
||||
}
|
||||
if (args.maxJobDurationMs !== undefined) {
|
||||
patch.maxJobDurationMs = Math.max(60_000, args.maxJobDurationMs);
|
||||
}
|
||||
if (args.maxOutputBytes !== undefined) {
|
||||
patch.maxOutputBytes = Math.max(10_000, args.maxOutputBytes);
|
||||
}
|
||||
|
||||
await ctx.db.patch(settings._id, patch);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import { internalMutation, internalQuery, query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId } from './model';
|
||||
|
||||
const side = v.union(v.literal('upstream'), v.literal('fork'));
|
||||
|
||||
export const listForSpoon = query({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
side: v.optional(side),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, { spoonId, side, limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
if (side) {
|
||||
return await ctx.db
|
||||
.query('spoonCommits')
|
||||
.withIndex('by_spoon_side', (q) =>
|
||||
q.eq('spoonId', spoonId).eq('side', side),
|
||||
)
|
||||
.order('desc')
|
||||
.take(limit ?? 100);
|
||||
}
|
||||
const commits = await ctx.db
|
||||
.query('spoonCommits')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.collect();
|
||||
return commits
|
||||
.filter((commit) => commit.spoonId === spoonId)
|
||||
.slice(0, limit ?? 100);
|
||||
},
|
||||
});
|
||||
|
||||
export const listInternal = internalQuery({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
side,
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, { spoonId, ownerId, side, limit }) => {
|
||||
const rows = await ctx.db
|
||||
.query('spoonCommits')
|
||||
.withIndex('by_spoon_side', (q) =>
|
||||
q.eq('spoonId', spoonId).eq('side', side),
|
||||
)
|
||||
.order('desc')
|
||||
.take(limit ?? 100);
|
||||
return rows.filter((row) => row.ownerId === ownerId);
|
||||
},
|
||||
});
|
||||
|
||||
export const replaceForSpoon = internalMutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
side,
|
||||
commits: v.array(
|
||||
v.object({
|
||||
sha: v.string(),
|
||||
message: v.string(),
|
||||
authorName: v.optional(v.string()),
|
||||
authorEmail: v.optional(v.string()),
|
||||
authorLogin: v.optional(v.string()),
|
||||
committedAt: v.optional(v.number()),
|
||||
htmlUrl: v.optional(v.string()),
|
||||
filesChanged: v.optional(v.number()),
|
||||
additions: v.optional(v.number()),
|
||||
deletions: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, { spoonId, ownerId, side, commits }) => {
|
||||
const existing = await ctx.db
|
||||
.query('spoonCommits')
|
||||
.withIndex('by_spoon_side', (q) =>
|
||||
q.eq('spoonId', spoonId).eq('side', side),
|
||||
)
|
||||
.collect();
|
||||
await Promise.all(existing.map((commit) => ctx.db.delete(commit._id)));
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
commits.map((commit) =>
|
||||
ctx.db.insert('spoonCommits', {
|
||||
spoonId,
|
||||
ownerId,
|
||||
side,
|
||||
...commit,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import { internalMutation, query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId } from './model';
|
||||
|
||||
const scope = v.union(
|
||||
v.literal('fork'),
|
||||
v.literal('upstream'),
|
||||
v.literal('from_fork_to_upstream'),
|
||||
);
|
||||
|
||||
const state = v.union(
|
||||
v.literal('open'),
|
||||
v.literal('closed'),
|
||||
v.literal('merged'),
|
||||
);
|
||||
|
||||
export const listForSpoon = query({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
scope: v.optional(scope),
|
||||
state: v.optional(state),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, args.spoonId, ownerId);
|
||||
const requestedScope = args.scope;
|
||||
const rows = requestedScope
|
||||
? await ctx.db
|
||||
.query('spoonPullRequests')
|
||||
.withIndex('by_spoon_scope', (q) =>
|
||||
q.eq('spoonId', args.spoonId).eq('scope', requestedScope),
|
||||
)
|
||||
.order('desc')
|
||||
.collect()
|
||||
: await ctx.db
|
||||
.query('spoonPullRequests')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
|
||||
.order('desc')
|
||||
.collect();
|
||||
return rows
|
||||
.filter((row) => !args.state || row.state === args.state)
|
||||
.slice(0, args.limit ?? 100);
|
||||
},
|
||||
});
|
||||
|
||||
export const replaceForSpoon = internalMutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
scope,
|
||||
pullRequests: v.array(
|
||||
v.object({
|
||||
githubId: v.number(),
|
||||
number: v.number(),
|
||||
repoFullName: v.string(),
|
||||
title: v.string(),
|
||||
state,
|
||||
draft: v.boolean(),
|
||||
authorLogin: v.optional(v.string()),
|
||||
baseRef: v.string(),
|
||||
headRef: v.string(),
|
||||
headRepoFullName: v.optional(v.string()),
|
||||
htmlUrl: v.string(),
|
||||
createdAtGithub: v.optional(v.number()),
|
||||
updatedAtGithub: v.optional(v.number()),
|
||||
mergedAtGithub: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, { spoonId, ownerId, scope, pullRequests }) => {
|
||||
const existing = await ctx.db
|
||||
.query('spoonPullRequests')
|
||||
.withIndex('by_spoon_scope', (q) =>
|
||||
q.eq('spoonId', spoonId).eq('scope', scope),
|
||||
)
|
||||
.collect();
|
||||
await Promise.all(
|
||||
existing.map((pullRequest) => ctx.db.delete(pullRequest._id)),
|
||||
);
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
pullRequests.map((pullRequest) =>
|
||||
ctx.db.insert('spoonPullRequests', {
|
||||
spoonId,
|
||||
ownerId,
|
||||
scope,
|
||||
...pullRequest,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import { mutation, query } from './_generated/server';
|
||||
import {
|
||||
getOwnedSpoon,
|
||||
getRequiredUserId,
|
||||
optionalText,
|
||||
requireText,
|
||||
} from './model';
|
||||
|
||||
export const listForSpoon = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return await ctx.db
|
||||
.query('spoonRemotes')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('asc')
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
label: v.string(),
|
||||
url: v.string(),
|
||||
remoteName: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, args.spoonId, ownerId);
|
||||
const now = Date.now();
|
||||
return await ctx.db.insert('spoonRemotes', {
|
||||
spoonId: args.spoonId,
|
||||
ownerId,
|
||||
label: requireText(args.label, 'Remote label'),
|
||||
url: requireText(args.url, 'Remote URL'),
|
||||
remoteName: optionalText(args.remoteName),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { remoteId: v.id('spoonRemotes') },
|
||||
handler: async (ctx, { remoteId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const remote = await ctx.db.get(remoteId);
|
||||
if (remote?.ownerId !== ownerId) {
|
||||
throw new ConvexError('Remote not found.');
|
||||
}
|
||||
await ctx.db.delete(remoteId);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import { internalMutation, mutation, query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId } from './model';
|
||||
|
||||
const publicSecret = (secret: Doc<'spoonSecrets'>) => ({
|
||||
_id: secret._id,
|
||||
_creationTime: secret._creationTime,
|
||||
spoonId: secret.spoonId,
|
||||
ownerId: secret.ownerId,
|
||||
name: secret.name,
|
||||
valuePreview: secret.valuePreview,
|
||||
description: secret.description,
|
||||
createdAt: secret.createdAt,
|
||||
updatedAt: secret.updatedAt,
|
||||
});
|
||||
|
||||
export const listForSpoon = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
const secrets = await ctx.db
|
||||
.query('spoonSecrets')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('asc')
|
||||
.collect();
|
||||
return secrets.map(publicSecret);
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { secretId: v.id('spoonSecrets') },
|
||||
handler: async (ctx, { secretId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const secret = await ctx.db.get(secretId);
|
||||
if (secret?.ownerId !== ownerId) {
|
||||
throw new ConvexError('Spoon secret not found.');
|
||||
}
|
||||
await ctx.db.delete(secretId);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const upsertEncryptedInternal = internalMutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
name: v.string(),
|
||||
encryptedValue: v.string(),
|
||||
valuePreview: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query('spoonSecrets')
|
||||
.withIndex('by_name', (q) =>
|
||||
q.eq('spoonId', args.spoonId).eq('name', args.name),
|
||||
)
|
||||
.first();
|
||||
if (existing) {
|
||||
if (existing.ownerId !== args.ownerId) {
|
||||
throw new ConvexError('Spoon secret not found.');
|
||||
}
|
||||
await ctx.db.patch(existing._id, {
|
||||
encryptedValue: args.encryptedValue,
|
||||
valuePreview: args.valuePreview,
|
||||
description: args.description,
|
||||
updatedAt: now,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert('spoonSecrets', {
|
||||
...args,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const patchEncryptedInternal = internalMutation({
|
||||
args: {
|
||||
secretId: v.id('spoonSecrets'),
|
||||
ownerId: v.id('users'),
|
||||
encryptedValue: v.optional(v.string()),
|
||||
valuePreview: v.optional(v.string()),
|
||||
description: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const secret = await ctx.db.get(args.secretId);
|
||||
if (secret?.ownerId !== args.ownerId) {
|
||||
throw new ConvexError('Spoon secret not found.');
|
||||
}
|
||||
await ctx.db.patch(args.secretId, {
|
||||
encryptedValue: args.encryptedValue ?? secret.encryptedValue,
|
||||
valuePreview: args.valuePreview ?? secret.valuePreview,
|
||||
description: args.description,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
'use node';
|
||||
|
||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { ActionCtx } from './_generated/server';
|
||||
import { internal } from './_generated/api';
|
||||
import { action } from './_generated/server';
|
||||
import { encryptSecret } from './secretCrypto';
|
||||
|
||||
const secretNamePattern = /^[A-Z_][A-Z0-9_]*$/;
|
||||
|
||||
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
return userId;
|
||||
};
|
||||
|
||||
const previewSecret = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return 'empty';
|
||||
if (trimmed.length <= 8) return 'configured';
|
||||
return `${trimmed.slice(0, 3)}...${trimmed.slice(-3)}`;
|
||||
};
|
||||
|
||||
const normalizeName = (name: string) => {
|
||||
const normalized = name.trim().toUpperCase();
|
||||
if (!secretNamePattern.test(normalized)) {
|
||||
throw new ConvexError(
|
||||
'Secret names must look like environment variables, for example AUTH_SECRET.',
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const optionalText = (value?: string) => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const create = action({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
name: v.string(),
|
||||
value: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<'spoonSecrets'>> => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await ctx.runQuery(internal.spoons.getOwnedForAction, {
|
||||
spoonId: args.spoonId,
|
||||
ownerId,
|
||||
});
|
||||
const value = args.value.trim();
|
||||
if (!value) throw new ConvexError('Secret value is required.');
|
||||
return await ctx.runMutation(
|
||||
internal.spoonSecrets.upsertEncryptedInternal,
|
||||
{
|
||||
spoonId: args.spoonId,
|
||||
ownerId,
|
||||
name: normalizeName(args.name),
|
||||
encryptedValue: encryptSecret(value),
|
||||
valuePreview: previewSecret(value),
|
||||
description: optionalText(args.description),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const update = action({
|
||||
args: {
|
||||
secretId: v.id('spoonSecrets'),
|
||||
value: v.optional(v.string()),
|
||||
description: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ success: true }> => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const patch = args.value?.trim()
|
||||
? {
|
||||
encryptedValue: encryptSecret(args.value.trim()),
|
||||
valuePreview: previewSecret(args.value.trim()),
|
||||
}
|
||||
: {};
|
||||
await ctx.runMutation(internal.spoonSecrets.patchEncryptedInternal, {
|
||||
secretId: args.secretId,
|
||||
ownerId,
|
||||
description: optionalText(args.description),
|
||||
...patch,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import {
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId } from './model';
|
||||
|
||||
const defaultSettings = {
|
||||
autoRefreshEnabled: true,
|
||||
autoReviewEnabled: true,
|
||||
autoSyncEnabled: false,
|
||||
requireAiLowRiskForSync: true,
|
||||
requireCleanCompareForSync: true,
|
||||
ignoredFilePatterns: [] as string[],
|
||||
importantFilePatterns: [] as string[],
|
||||
};
|
||||
|
||||
export const getForSpoon = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return (
|
||||
(await ctx.db
|
||||
.query('spoonSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.first()) ?? { spoonId, ownerId, ...defaultSettings }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const ensureForSpoon = internalMutation({
|
||||
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
|
||||
handler: async (ctx, { spoonId, ownerId }) => {
|
||||
const existing = await ctx.db
|
||||
.query('spoonSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.first();
|
||||
if (existing) return existing._id;
|
||||
const now = Date.now();
|
||||
return await ctx.db.insert('spoonSettings', {
|
||||
spoonId,
|
||||
ownerId,
|
||||
...defaultSettings,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const getInternal = internalQuery({
|
||||
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
|
||||
handler: async (ctx, { spoonId, ownerId }) => {
|
||||
const settings = await ctx.db
|
||||
.query('spoonSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.first();
|
||||
if (settings && settings.ownerId !== ownerId) {
|
||||
throw new Error('Spoon settings ownership mismatch.');
|
||||
}
|
||||
return settings;
|
||||
},
|
||||
});
|
||||
|
||||
export const listRefreshDue = internalQuery({
|
||||
args: { limit: v.number() },
|
||||
handler: async (ctx, { limit }) => {
|
||||
const now = Date.now();
|
||||
const settings = await ctx.db.query('spoonSettings').collect();
|
||||
const due = [];
|
||||
for (const item of settings) {
|
||||
if (!item.autoRefreshEnabled) continue;
|
||||
const spoon = await ctx.db.get(item.spoonId);
|
||||
if (
|
||||
!spoon ||
|
||||
spoon.status === 'archived' ||
|
||||
spoon.provider !== 'github'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (spoon.syncCadence === 'manual') continue;
|
||||
const cadenceMs =
|
||||
spoon.syncCadence === 'weekly'
|
||||
? 7 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
const last = spoon.lastGithubRefreshAt ?? spoon.lastCheckedAt ?? 0;
|
||||
if (now - last >= cadenceMs) {
|
||||
due.push({ spoonId: item.spoonId, ownerId: item.ownerId });
|
||||
}
|
||||
if (due.length >= limit) break;
|
||||
}
|
||||
return due;
|
||||
},
|
||||
});
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
autoRefreshEnabled: v.optional(v.boolean()),
|
||||
autoReviewEnabled: v.optional(v.boolean()),
|
||||
autoSyncEnabled: v.optional(v.boolean()),
|
||||
requireAiLowRiskForSync: v.optional(v.boolean()),
|
||||
requireCleanCompareForSync: v.optional(v.boolean()),
|
||||
ignoredFilePatterns: v.optional(v.array(v.string())),
|
||||
importantFilePatterns: v.optional(v.array(v.string())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, args.spoonId, ownerId);
|
||||
let settings = await ctx.db
|
||||
.query('spoonSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
|
||||
.first();
|
||||
if (!settings) {
|
||||
const id = await ctx.db.insert('spoonSettings', {
|
||||
spoonId: args.spoonId,
|
||||
ownerId,
|
||||
...defaultSettings,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
settings = await ctx.db.get(id);
|
||||
}
|
||||
const patch: Partial<Doc<'spoonSettings'>> = { updatedAt: Date.now() };
|
||||
if (args.autoRefreshEnabled !== undefined) {
|
||||
patch.autoRefreshEnabled = args.autoRefreshEnabled;
|
||||
}
|
||||
if (args.autoReviewEnabled !== undefined) {
|
||||
patch.autoReviewEnabled = args.autoReviewEnabled;
|
||||
}
|
||||
if (args.autoSyncEnabled !== undefined) {
|
||||
patch.autoSyncEnabled = args.autoSyncEnabled;
|
||||
}
|
||||
if (args.requireAiLowRiskForSync !== undefined) {
|
||||
patch.requireAiLowRiskForSync = args.requireAiLowRiskForSync;
|
||||
}
|
||||
if (args.requireCleanCompareForSync !== undefined) {
|
||||
patch.requireCleanCompareForSync = args.requireCleanCompareForSync;
|
||||
}
|
||||
if (args.ignoredFilePatterns !== undefined) {
|
||||
patch.ignoredFilePatterns = args.ignoredFilePatterns;
|
||||
}
|
||||
if (args.importantFilePatterns !== undefined) {
|
||||
patch.importantFilePatterns = args.importantFilePatterns;
|
||||
}
|
||||
if (!settings) throw new Error('Spoon settings not found.');
|
||||
await ctx.db.patch(settings._id, patch);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc, Id } from './_generated/dataModel';
|
||||
import { internalMutation, internalQuery, query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId } from './model';
|
||||
|
||||
const syncStatus = v.union(
|
||||
v.literal('up_to_date'),
|
||||
v.literal('behind'),
|
||||
v.literal('ahead'),
|
||||
v.literal('diverged'),
|
||||
v.literal('unknown'),
|
||||
);
|
||||
|
||||
export const deriveSyncStatus = ({
|
||||
upstreamAheadBy,
|
||||
forkAheadBy,
|
||||
}: {
|
||||
upstreamAheadBy: number;
|
||||
forkAheadBy: number;
|
||||
}): Doc<'spoonRepositoryStates'>['status'] => {
|
||||
if (upstreamAheadBy === 0 && forkAheadBy === 0) return 'up_to_date';
|
||||
if (upstreamAheadBy > 0 && forkAheadBy === 0) return 'behind';
|
||||
if (upstreamAheadBy === 0 && forkAheadBy > 0) return 'ahead';
|
||||
if (upstreamAheadBy > 0 && forkAheadBy > 0) return 'diverged';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const getForSpoon = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return await ctx.db
|
||||
.query('spoonRepositoryStates')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
export const listForOwner = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
return await ctx.db
|
||||
.query('spoonRepositoryStates')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const getInternal = internalQuery({
|
||||
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
|
||||
handler: async (ctx, { spoonId, ownerId }) => {
|
||||
const state = await ctx.db
|
||||
.query('spoonRepositoryStates')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.first();
|
||||
if (state && state.ownerId !== ownerId) {
|
||||
throw new ConvexError('Repository state ownership mismatch.');
|
||||
}
|
||||
return state;
|
||||
},
|
||||
});
|
||||
|
||||
export const upsert = internalMutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
upstreamFullName: v.string(),
|
||||
forkFullName: v.string(),
|
||||
upstreamDefaultBranch: v.string(),
|
||||
forkDefaultBranch: v.string(),
|
||||
upstreamHeadSha: v.optional(v.string()),
|
||||
forkHeadSha: v.optional(v.string()),
|
||||
mergeBaseSha: v.optional(v.string()),
|
||||
upstreamAheadBy: v.number(),
|
||||
forkAheadBy: v.number(),
|
||||
status: syncStatus,
|
||||
openForkPullRequestCount: v.number(),
|
||||
openUpstreamPullRequestCount: v.number(),
|
||||
lastCommitAt: v.optional(v.number()),
|
||||
rawCompareUrl: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<'spoonRepositoryStates'>> => {
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query('spoonRepositoryStates')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
|
||||
.first();
|
||||
const patch = {
|
||||
ownerId: args.ownerId,
|
||||
upstreamFullName: args.upstreamFullName,
|
||||
forkFullName: args.forkFullName,
|
||||
upstreamDefaultBranch: args.upstreamDefaultBranch,
|
||||
forkDefaultBranch: args.forkDefaultBranch,
|
||||
upstreamHeadSha: args.upstreamHeadSha,
|
||||
forkHeadSha: args.forkHeadSha,
|
||||
mergeBaseSha: args.mergeBaseSha,
|
||||
upstreamAheadBy: args.upstreamAheadBy,
|
||||
forkAheadBy: args.forkAheadBy,
|
||||
status: args.status,
|
||||
openForkPullRequestCount: args.openForkPullRequestCount,
|
||||
openUpstreamPullRequestCount: args.openUpstreamPullRequestCount,
|
||||
lastCommitAt: args.lastCommitAt,
|
||||
rawCompareUrl: args.rawCompareUrl,
|
||||
refreshedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (existing) {
|
||||
if (existing.ownerId !== args.ownerId) {
|
||||
throw new ConvexError('Repository state ownership mismatch.');
|
||||
}
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert('spoonRepositoryStates', {
|
||||
spoonId: args.spoonId,
|
||||
...patch,
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import {
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from './_generated/server';
|
||||
import {
|
||||
getOwnedSpoon,
|
||||
getRequiredUserId,
|
||||
@@ -49,6 +54,17 @@ const spoonStatus = v.union(
|
||||
v.literal('archived'),
|
||||
);
|
||||
|
||||
const spoonSyncStatus = v.union(
|
||||
v.literal('unknown'),
|
||||
v.literal('up_to_date'),
|
||||
v.literal('behind'),
|
||||
v.literal('ahead'),
|
||||
v.literal('diverged'),
|
||||
v.literal('checking'),
|
||||
v.literal('conflict'),
|
||||
v.literal('error'),
|
||||
);
|
||||
|
||||
const hasForkMetadata = (args: {
|
||||
forkOwner?: string;
|
||||
forkRepo?: string;
|
||||
@@ -79,6 +95,48 @@ export const get = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getDetails = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
const [state, settings, latestReview, recentRuns, agentRequests] =
|
||||
await Promise.all([
|
||||
ctx.db
|
||||
.query('spoonRepositoryStates')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.first(),
|
||||
ctx.db
|
||||
.query('spoonSettings')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.first(),
|
||||
ctx.db
|
||||
.query('aiReviews')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.first(),
|
||||
ctx.db
|
||||
.query('syncRuns')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.take(10),
|
||||
ctx.db
|
||||
.query('agentRequests')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.take(10),
|
||||
]);
|
||||
return { spoon, state, settings, latestReview, recentRuns, agentRequests };
|
||||
},
|
||||
});
|
||||
|
||||
export const getOwnedForAction = internalQuery({
|
||||
args: { spoonId: v.id('spoons'), ownerId: v.id('users') },
|
||||
handler: async (ctx, { spoonId, ownerId }) => {
|
||||
return await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
},
|
||||
});
|
||||
|
||||
export const createManual = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
@@ -103,12 +161,13 @@ export const createManual = mutation({
|
||||
const now = Date.now();
|
||||
const forkOwner = optionalText(args.forkOwner);
|
||||
const forkRepo = optionalText(args.forkRepo);
|
||||
const forkDefaultBranch = optionalText(args.forkDefaultBranch);
|
||||
const forkUrl = optionalText(args.forkUrl);
|
||||
const status = hasForkMetadata({ forkOwner, forkRepo, forkUrl })
|
||||
? 'draft'
|
||||
: 'needs_connection';
|
||||
|
||||
return await ctx.db.insert('spoons', {
|
||||
const spoonId = await ctx.db.insert('spoons', {
|
||||
ownerId,
|
||||
name: requireText(args.name, 'Spoon name'),
|
||||
description: optionalText(args.description),
|
||||
@@ -122,7 +181,7 @@ export const createManual = mutation({
|
||||
upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'),
|
||||
forkOwner,
|
||||
forkRepo,
|
||||
forkDefaultBranch: optionalText(args.forkDefaultBranch),
|
||||
forkDefaultBranch,
|
||||
forkUrl,
|
||||
visibility: args.visibility,
|
||||
maintenanceMode: args.maintenanceMode,
|
||||
@@ -130,9 +189,87 @@ export const createManual = mutation({
|
||||
productionRefStrategy: args.productionRefStrategy,
|
||||
tagPattern: optionalText(args.tagPattern),
|
||||
status,
|
||||
syncStatus: 'unknown',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert('spoonSettings', {
|
||||
spoonId,
|
||||
ownerId,
|
||||
autoRefreshEnabled: true,
|
||||
autoReviewEnabled: true,
|
||||
autoSyncEnabled: false,
|
||||
requireAiLowRiskForSync: true,
|
||||
requireCleanCompareForSync: true,
|
||||
ignoredFilePatterns: [],
|
||||
importantFilePatterns: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert('spoonAgentSettings', {
|
||||
spoonId,
|
||||
ownerId,
|
||||
enabled: true,
|
||||
defaultBaseBranch: forkDefaultBranch ?? args.upstreamDefaultBranch,
|
||||
branchPrefix: 'spoon/agent',
|
||||
agentModel: 'gpt-5.1-codex',
|
||||
reasoningEffort: 'high',
|
||||
maxJobDurationMs: 1_800_000,
|
||||
maxOutputBytes: 200_000,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return spoonId;
|
||||
},
|
||||
});
|
||||
|
||||
export const patchSyncFields = internalMutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
syncStatus: v.optional(spoonSyncStatus),
|
||||
upstreamAheadBy: v.optional(v.number()),
|
||||
forkAheadBy: v.optional(v.number()),
|
||||
lastMergeBaseCommit: v.optional(v.string()),
|
||||
lastUpstreamCommit: v.optional(v.string()),
|
||||
lastForkCommit: v.optional(v.string()),
|
||||
lastSyncRunId: v.optional(v.id('syncRuns')),
|
||||
lastAiReviewId: v.optional(v.id('aiReviews')),
|
||||
lastGithubRefreshAt: v.optional(v.number()),
|
||||
lastSuccessfulRefreshAt: v.optional(v.number()),
|
||||
lastCheckedAt: v.optional(v.number()),
|
||||
lastError: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const patch: Partial<Doc<'spoons'>> = { updatedAt: Date.now() };
|
||||
if (args.syncStatus !== undefined) patch.syncStatus = args.syncStatus;
|
||||
if (args.upstreamAheadBy !== undefined) {
|
||||
patch.upstreamAheadBy = args.upstreamAheadBy;
|
||||
}
|
||||
if (args.forkAheadBy !== undefined) patch.forkAheadBy = args.forkAheadBy;
|
||||
if (args.lastMergeBaseCommit !== undefined) {
|
||||
patch.lastMergeBaseCommit = args.lastMergeBaseCommit;
|
||||
}
|
||||
if (args.lastUpstreamCommit !== undefined) {
|
||||
patch.lastUpstreamCommit = args.lastUpstreamCommit;
|
||||
}
|
||||
if (args.lastForkCommit !== undefined) {
|
||||
patch.lastForkCommit = args.lastForkCommit;
|
||||
}
|
||||
if (args.lastSyncRunId !== undefined)
|
||||
patch.lastSyncRunId = args.lastSyncRunId;
|
||||
if (args.lastAiReviewId !== undefined)
|
||||
patch.lastAiReviewId = args.lastAiReviewId;
|
||||
if (args.lastGithubRefreshAt !== undefined) {
|
||||
patch.lastGithubRefreshAt = args.lastGithubRefreshAt;
|
||||
}
|
||||
if (args.lastSuccessfulRefreshAt !== undefined) {
|
||||
patch.lastSuccessfulRefreshAt = args.lastSuccessfulRefreshAt;
|
||||
}
|
||||
if (args.lastCheckedAt !== undefined)
|
||||
patch.lastCheckedAt = args.lastCheckedAt;
|
||||
if (args.lastError !== undefined) patch.lastError = args.lastError;
|
||||
await ctx.db.patch(args.spoonId, patch);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import { query } from './_generated/server';
|
||||
import type { Doc, Id } from './_generated/dataModel';
|
||||
import { internalMutation, query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId } from './model';
|
||||
|
||||
const syncKind = v.union(
|
||||
v.literal('scheduled_check'),
|
||||
v.literal('manual_check'),
|
||||
v.literal('upstream_update'),
|
||||
v.literal('merge_attempt'),
|
||||
v.literal('ai_review'),
|
||||
);
|
||||
|
||||
const syncStatus = v.union(
|
||||
v.literal('queued'),
|
||||
v.literal('running'),
|
||||
v.literal('clean'),
|
||||
v.literal('conflict'),
|
||||
v.literal('needs_review'),
|
||||
v.literal('failed'),
|
||||
v.literal('merged'),
|
||||
);
|
||||
|
||||
export const listRecent = query({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { limit }) => {
|
||||
@@ -28,3 +47,55 @@ export const listForSpoon = query({
|
||||
.take(limit ?? 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const createInternal = internalMutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
kind: syncKind,
|
||||
status: syncStatus,
|
||||
upstreamFrom: v.optional(v.string()),
|
||||
upstreamTo: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
aiAssessment: v.optional(v.string()),
|
||||
mergeRequestUrl: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<'syncRuns'>> => {
|
||||
const now = Date.now();
|
||||
return await ctx.db.insert('syncRuns', {
|
||||
...args,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const patchInternal = internalMutation({
|
||||
args: {
|
||||
syncRunId: v.id('syncRuns'),
|
||||
status: v.optional(syncStatus),
|
||||
upstreamFrom: v.optional(v.string()),
|
||||
upstreamTo: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
aiAssessment: v.optional(v.string()),
|
||||
mergeRequestUrl: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const patch: Partial<Doc<'syncRuns'>> = { updatedAt: Date.now() };
|
||||
if (args.status !== undefined) patch.status = args.status;
|
||||
if (args.upstreamFrom !== undefined) patch.upstreamFrom = args.upstreamFrom;
|
||||
if (args.upstreamTo !== undefined) patch.upstreamTo = args.upstreamTo;
|
||||
if (args.summary !== undefined) patch.summary = args.summary;
|
||||
if (args.aiAssessment !== undefined) {
|
||||
patch.aiAssessment = args.aiAssessment;
|
||||
}
|
||||
if (args.mergeRequestUrl !== undefined) {
|
||||
patch.mergeRequestUrl = args.mergeRequestUrl;
|
||||
}
|
||||
if (args.error !== undefined) patch.error = args.error;
|
||||
await ctx.db.patch(args.syncRunId, patch);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"],
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
"./types": "./types/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun with-env convex dev",
|
||||
"dev:tunnel": "bun with-env convex dev",
|
||||
"dev:web": "bun with-env convex dev",
|
||||
"setup": "bun with-env convex dev --until-success",
|
||||
"sync-env": "sh ../../scripts/sync-convex-env ${INFISICAL_ENV:-dev}",
|
||||
"dev": "bun sync-env && bun with-env convex dev",
|
||||
"dev:tunnel": "bun sync-env && bun with-env convex dev",
|
||||
"dev:web": "bun sync-env && bun with-env convex dev",
|
||||
"setup": "bun sync-env && bun with-env convex dev --until-success",
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
@@ -26,10 +27,13 @@
|
||||
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "^8.2.0",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@react-email/components": "1.0.10",
|
||||
"@react-email/render": "^2.0.4",
|
||||
"convex": "catalog:convex",
|
||||
"openai": "^6.44.0",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
"usesend-js": "^1.6.3",
|
||||
|
||||
Reference in New Issue
Block a user