406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
import { ConvexError, v } from 'convex/values';
|
|
|
|
import type { Doc } from './_generated/dataModel';
|
|
import {
|
|
internalMutation,
|
|
internalQuery,
|
|
mutation,
|
|
query,
|
|
} from './_generated/server';
|
|
import {
|
|
getOwnedSpoon,
|
|
getRequiredUserId,
|
|
optionalText,
|
|
requireText,
|
|
} from './model';
|
|
|
|
const provider = v.union(
|
|
v.literal('github'),
|
|
v.literal('gitea'),
|
|
v.literal('gitlab'),
|
|
v.literal('other'),
|
|
);
|
|
|
|
const visibility = v.union(
|
|
v.literal('public'),
|
|
v.literal('private'),
|
|
v.literal('internal'),
|
|
v.literal('unknown'),
|
|
);
|
|
|
|
const maintenanceMode = v.union(
|
|
v.literal('watch'),
|
|
v.literal('auto_pr'),
|
|
v.literal('paused'),
|
|
);
|
|
|
|
const syncCadence = v.union(
|
|
v.literal('daily'),
|
|
v.literal('weekly'),
|
|
v.literal('manual'),
|
|
);
|
|
|
|
const productionRefStrategy = v.union(
|
|
v.literal('default_branch'),
|
|
v.literal('latest_release'),
|
|
v.literal('tag_pattern'),
|
|
);
|
|
|
|
const spoonStatus = v.union(
|
|
v.literal('draft'),
|
|
v.literal('active'),
|
|
v.literal('needs_connection'),
|
|
v.literal('paused'),
|
|
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;
|
|
forkUrl?: string;
|
|
}) =>
|
|
Boolean(
|
|
args.forkOwner?.trim() && args.forkRepo?.trim() && args.forkUrl?.trim(),
|
|
);
|
|
|
|
export const listMine = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const spoons = await ctx.db
|
|
.query('spoons')
|
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
|
.order('desc')
|
|
.collect();
|
|
return spoons.filter((spoon) => spoon.status !== 'archived');
|
|
},
|
|
});
|
|
|
|
export const listMineWithState = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const spoons = (
|
|
await ctx.db
|
|
.query('spoons')
|
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
|
.order('desc')
|
|
.collect()
|
|
).filter((spoon) => spoon.status !== 'archived');
|
|
|
|
return await Promise.all(
|
|
spoons.map(async (spoon) => {
|
|
const [state, ignoredChanges, threads] = await Promise.all([
|
|
ctx.db
|
|
.query('spoonRepositoryStates')
|
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
|
.first(),
|
|
ctx.db
|
|
.query('ignoredUpstreamChanges')
|
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
|
.collect(),
|
|
ctx.db
|
|
.query('threads')
|
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
|
.order('desc')
|
|
.collect(),
|
|
]);
|
|
const ignoredShas = new Set(
|
|
ignoredChanges.flatMap((change) => change.commitShas),
|
|
);
|
|
const rawUpstreamAheadBy =
|
|
state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0;
|
|
const effectiveUpstreamAheadBy = Math.max(
|
|
0,
|
|
rawUpstreamAheadBy - ignoredShas.size,
|
|
);
|
|
const openThreads = threads.filter(
|
|
(thread) =>
|
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
|
thread.status,
|
|
),
|
|
);
|
|
return {
|
|
...spoon,
|
|
rawUpstreamAheadBy,
|
|
effectiveUpstreamAheadBy,
|
|
ignoredUpstreamCount: ignoredShas.size,
|
|
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
|
|
openThreadCount: openThreads.length,
|
|
latestThreadStatus: threads[0]?.status,
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
|
|
export const get = query({
|
|
args: { spoonId: v.id('spoons') },
|
|
handler: async (ctx, { spoonId }) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
return getOwnedSpoon(ctx, spoonId, ownerId);
|
|
},
|
|
});
|
|
|
|
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,
|
|
ignoredChanges,
|
|
] = 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),
|
|
ctx.db
|
|
.query('ignoredUpstreamChanges')
|
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
|
.collect(),
|
|
]);
|
|
const ignoredShas = new Set(
|
|
ignoredChanges.flatMap((change) => change.commitShas),
|
|
);
|
|
const effectiveUpstreamAheadBy = Math.max(
|
|
0,
|
|
(state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0) - ignoredShas.size,
|
|
);
|
|
return {
|
|
spoon,
|
|
state,
|
|
settings,
|
|
latestReview,
|
|
recentRuns,
|
|
agentRequests,
|
|
ignoredChanges,
|
|
effectiveUpstreamAheadBy,
|
|
};
|
|
},
|
|
});
|
|
|
|
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(),
|
|
description: v.optional(v.string()),
|
|
provider,
|
|
upstreamOwner: v.string(),
|
|
upstreamRepo: v.string(),
|
|
upstreamDefaultBranch: v.string(),
|
|
upstreamUrl: v.string(),
|
|
forkOwner: v.optional(v.string()),
|
|
forkRepo: v.optional(v.string()),
|
|
forkDefaultBranch: v.optional(v.string()),
|
|
forkUrl: v.optional(v.string()),
|
|
visibility,
|
|
maintenanceMode,
|
|
syncCadence,
|
|
productionRefStrategy,
|
|
tagPattern: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
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';
|
|
|
|
const spoonId = await ctx.db.insert('spoons', {
|
|
ownerId,
|
|
name: requireText(args.name, 'Spoon name'),
|
|
description: optionalText(args.description),
|
|
provider: args.provider,
|
|
upstreamOwner: requireText(args.upstreamOwner, 'Upstream owner'),
|
|
upstreamRepo: requireText(args.upstreamRepo, 'Upstream repository'),
|
|
upstreamDefaultBranch: requireText(
|
|
args.upstreamDefaultBranch,
|
|
'Upstream default branch',
|
|
),
|
|
upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'),
|
|
forkOwner,
|
|
forkRepo,
|
|
forkDefaultBranch,
|
|
forkUrl,
|
|
visibility: args.visibility,
|
|
maintenanceMode: args.maintenanceMode,
|
|
syncCadence: args.syncCadence,
|
|
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,
|
|
runtime: 'opencode',
|
|
defaultBaseBranch: forkDefaultBranch ?? args.upstreamDefaultBranch,
|
|
branchPrefix: 'spoon/agent',
|
|
agentModel: '',
|
|
reasoningEffort: 'medium',
|
|
maxJobDurationMs: 1_800_000,
|
|
maxOutputBytes: 200_000,
|
|
envFilePath: '.env.local',
|
|
materializeEnvFileByDefault: false,
|
|
autoDetectCommands: true,
|
|
allowUserFileEditing: true,
|
|
aiProviderProfileId: undefined,
|
|
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 };
|
|
},
|
|
});
|
|
|
|
export const updateSettings = mutation({
|
|
args: {
|
|
spoonId: v.id('spoons'),
|
|
maintenanceMode: v.optional(maintenanceMode),
|
|
syncCadence: v.optional(syncCadence),
|
|
productionRefStrategy: v.optional(productionRefStrategy),
|
|
tagPattern: v.optional(v.string()),
|
|
status: v.optional(spoonStatus),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
await getOwnedSpoon(ctx, args.spoonId, ownerId);
|
|
const patch: Partial<Doc<'spoons'>> = { updatedAt: Date.now() };
|
|
if (args.maintenanceMode) patch.maintenanceMode = args.maintenanceMode;
|
|
if (args.syncCadence) patch.syncCadence = args.syncCadence;
|
|
if (args.productionRefStrategy) {
|
|
patch.productionRefStrategy = args.productionRefStrategy;
|
|
}
|
|
if (args.tagPattern !== undefined)
|
|
patch.tagPattern = optionalText(args.tagPattern);
|
|
if (args.status) patch.status = args.status;
|
|
await ctx.db.patch(args.spoonId, patch);
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
export const archive = mutation({
|
|
args: { spoonId: v.id('spoons') },
|
|
handler: async (ctx, { spoonId }) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
|
|
if (spoon.status === 'archived')
|
|
throw new ConvexError('Spoon is archived.');
|
|
await ctx.db.patch(spoonId, {
|
|
status: 'archived',
|
|
updatedAt: Date.now(),
|
|
});
|
|
return { success: true };
|
|
},
|
|
});
|