fix(sync): use stable dedup key and skip threads when head unresolved

This commit is contained in:
Gabriel Brown
2026-07-11 14:00:16 -04:00
parent 0e894f8e67
commit 675d6b2316
+100 -45
View File
@@ -16,6 +16,7 @@ import {
listPullRequests, listPullRequests,
syncForkBranch, syncForkBranch,
} from './githubClient'; } from './githubClient';
import { maintenanceDedupKey } from './maintenanceDedup';
import { shouldAutoSync, shouldCreateReviewThread } from './syncGating'; import { shouldAutoSync, shouldCreateReviewThread } from './syncGating';
const getRequiredUserId = async (ctx: ActionCtx) => { const getRequiredUserId = async (ctx: ActionCtx) => {
@@ -244,6 +245,32 @@ const refreshOwnedSpoon = async (
} catch (syncError) { } catch (syncError) {
const message = const message =
syncError instanceof Error ? syncError.message : String(syncError); syncError instanceof Error ? syncError.message : String(syncError);
const dedupKey = maintenanceDedupKey({
mergeBaseSha: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
headSha: upstreamCompare.headSha,
});
if (!dedupKey) {
console.warn(
'Skipping maintenance thread: upstream head SHA unresolvable for spoon',
spoonId,
);
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'failed',
error: `${message} (skipped maintenance thread: could not resolve upstream head SHA)`,
});
await ctx.runMutation(internal.spoons.patchSyncFields, {
spoonId,
syncStatus: 'conflict',
lastError: message,
});
return {
success: true,
status: 'unknown' as const,
upstreamAheadBy: upstreamCompare.aheadBy,
forkAheadBy: forkCompare.aheadBy,
};
}
const threadId = await ctx.runMutation( const threadId = await ctx.runMutation(
internal.threads.createMaintenanceThread, internal.threads.createMaintenanceThread,
{ {
@@ -253,7 +280,8 @@ const refreshOwnedSpoon = async (
title: `Resolve upstream sync conflict for ${spoon.name}`, title: `Resolve upstream sync conflict for ${spoon.name}`,
summary: `GitHub refused the automatic upstream sync: ${message}`, summary: `GitHub refused the automatic upstream sync: ${message}`,
upstreamFrom: upstreamCompare.mergeBaseSha, upstreamFrom: upstreamCompare.mergeBaseSha,
upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`, upstreamTo: upstreamCompare.headSha,
dedupKey,
forkHeadAtCreation: forkCompare.headSha, forkHeadAtCreation: forkCompare.headSha,
mergeBaseAtCreation: mergeBaseAtCreation:
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha, upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
@@ -283,28 +311,46 @@ const refreshOwnedSpoon = async (
} }
if (shouldCreateReviewThread({ autoReviewEnabled }, { status })) { if (shouldCreateReviewThread({ autoReviewEnabled }, { status })) {
const threadId = await ctx.runMutation( const dedupKey = maintenanceDedupKey({
internal.threads.createMaintenanceThread, mergeBaseSha: upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
{ headSha: upstreamCompare.headSha,
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',
}); });
if (!dedupKey) {
console.warn(
'Skipping maintenance thread: upstream head SHA unresolvable for spoon',
spoonId,
);
await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId,
status: 'failed',
error:
'Could not resolve upstream head SHA; skipping maintenance thread.',
});
} else {
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,
dedupKey,
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 { return {
success: true, success: true,
@@ -408,30 +454,39 @@ export const syncForkWithUpstream = action({
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const conflict = message.toLowerCase().includes('conflict'); const conflict = message.toLowerCase().includes('conflict');
if (conflict) { if (conflict) {
const threadId = await ctx.runMutation( const dedupKey = maintenanceDedupKey({
internal.threads.createMaintenanceThread, mergeBaseSha: state.mergeBaseSha ?? spoon.lastMergeBaseCommit,
{ headSha: state.upstreamHeadSha ?? spoon.lastUpstreamCommit,
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',
}); });
if (!dedupKey) {
console.warn(
'Skipping maintenance thread: upstream head SHA unresolvable for spoon',
spoonId,
);
} else {
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,
dedupKey,
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, { await ctx.runMutation(internal.syncRuns.patchInternal, {
syncRunId, syncRunId,