477 lines
15 KiB
TypeScript
477 lines
15 KiB
TypeScript
'use node';
|
|
|
|
import { getAuthUserId } from '@convex-dev/auth/server';
|
|
import { ConvexError, v } from 'convex/values';
|
|
|
|
import type { Doc, Id } from './_generated/dataModel';
|
|
import type { ActionCtx } from './_generated/server';
|
|
import 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',
|
|
allowAutoSync = true,
|
|
): 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).`,
|
|
});
|
|
|
|
if (status === 'behind' && forkCompare.aheadBy === 0 && allowAutoSync) {
|
|
try {
|
|
await syncForkBranch(octokit, {
|
|
forkOwner,
|
|
forkRepo,
|
|
branch: resolvedForkBranch,
|
|
});
|
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
|
syncRunId,
|
|
status: 'merged',
|
|
decision: 'auto_synced',
|
|
summary:
|
|
'Fork had no custom commits, so Spoon synced it with upstream automatically.',
|
|
});
|
|
return await refreshOwnedSpoon(ctx, ownerId, spoonId, kind, false);
|
|
} catch (syncError) {
|
|
const message =
|
|
syncError instanceof Error ? syncError.message : String(syncError);
|
|
const threadId = await ctx.runMutation(
|
|
internal.threads.createMaintenanceThread,
|
|
{
|
|
spoonId,
|
|
ownerId,
|
|
source: 'merge_conflict',
|
|
title: `Resolve upstream sync conflict for ${spoon.name}`,
|
|
summary: `GitHub refused the automatic upstream sync: ${message}`,
|
|
upstreamFrom: upstreamCompare.mergeBaseSha,
|
|
upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`,
|
|
forkHeadAtCreation: forkCompare.headSha,
|
|
mergeBaseAtCreation:
|
|
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
|
|
relatedSyncRunId: syncRunId,
|
|
jobType: 'conflict_resolution',
|
|
},
|
|
);
|
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
|
syncRunId,
|
|
threadId,
|
|
status: 'conflict',
|
|
decision: 'thread_created',
|
|
error: message,
|
|
});
|
|
await ctx.runMutation(internal.spoons.patchSyncFields, {
|
|
spoonId,
|
|
syncStatus: 'conflict',
|
|
lastError: message,
|
|
});
|
|
return {
|
|
success: true,
|
|
status: 'unknown' as const,
|
|
upstreamAheadBy: upstreamCompare.aheadBy,
|
|
forkAheadBy: forkCompare.aheadBy,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (status === 'diverged') {
|
|
const threadId = await ctx.runMutation(
|
|
internal.threads.createMaintenanceThread,
|
|
{
|
|
spoonId,
|
|
ownerId,
|
|
source: 'upstream_update',
|
|
title: `Review upstream changes for ${spoon.name}`,
|
|
summary: `Upstream has ${upstreamCompare.aheadBy} commit(s) and the fork has ${forkCompare.aheadBy} custom commit(s). Review whether upstream should be merged, ignored, or resolved in a draft PR.`,
|
|
upstreamFrom: upstreamCompare.mergeBaseSha,
|
|
upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`,
|
|
forkHeadAtCreation: forkCompare.headSha,
|
|
mergeBaseAtCreation:
|
|
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
|
|
relatedSyncRunId: syncRunId,
|
|
jobType: 'maintenance_review',
|
|
},
|
|
);
|
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
|
syncRunId,
|
|
threadId,
|
|
decision: 'thread_created',
|
|
});
|
|
}
|
|
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');
|
|
if (conflict) {
|
|
const threadId = await ctx.runMutation(
|
|
internal.threads.createMaintenanceThread,
|
|
{
|
|
spoonId,
|
|
ownerId,
|
|
source: 'merge_conflict',
|
|
title: `Resolve upstream sync conflict for ${spoon.name}`,
|
|
summary: `GitHub reported a conflict while syncing upstream into this fork: ${message}`,
|
|
upstreamTo:
|
|
state.upstreamHeadSha ??
|
|
spoon.lastUpstreamCommit ??
|
|
`${Date.now()}`,
|
|
forkHeadAtCreation: state.forkHeadSha ?? spoon.lastForkCommit,
|
|
mergeBaseAtCreation:
|
|
state.mergeBaseSha ?? spoon.lastMergeBaseCommit,
|
|
relatedSyncRunId: syncRunId,
|
|
jobType: 'conflict_resolution',
|
|
},
|
|
);
|
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
|
syncRunId,
|
|
threadId,
|
|
decision: 'thread_created',
|
|
});
|
|
}
|
|
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;
|
|
},
|
|
});
|