Skip to content

Commit 2fba9db

Browse files
authored
fix(code-review): preserve in-flight review on merge commit push (#2741)
* fix(code-review): skip merge commits before cancelling in-flight reviews When a pull_request.synchronize / merge_request.update event arrives with a merge-commit head (e.g. clicking GitHub's 'Update branch' button), the review was being cancelled before the merge-commit skip check ran, leaving the PR with no active review. Reorder so the merge-commit check runs first and bails out without touching existing reviews. Extract the decision into small pure helpers for unit testing. * fix(code-review): drop github check on merge-commit head * fix(code-review): set gitlab status on merge-commit head * fix(code-review): create check run on base repo
1 parent 8777905 commit 2fba9db

3 files changed

Lines changed: 213 additions & 0 deletions

File tree

apps/web/src/lib/code-reviews/db/code-reviews.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,39 @@ export async function updateCheckRunId(reviewId: string, checkRunId: number): Pr
494494
}
495495
}
496496

497+
/**
498+
* Repoints an in-flight review at a new head SHA (and optionally a new check
499+
* run). Used when a merge commit arrives for a PR with a preserved review:
500+
* the review keeps running on the prior feature-branch content, but its
501+
* eventual completion needs to update the gate on the new HEAD (which is
502+
* what branch-protection evaluates) rather than the abandoned prior SHA.
503+
*
504+
* Pass `checkRunId = null` for GitLab, whose commit statuses are keyed by
505+
* (sha, name) rather than by an opaque ID.
506+
*/
507+
export async function updateReviewHeadShaAndCheckRun(
508+
reviewId: string,
509+
headSha: string,
510+
checkRunId: number | null
511+
): Promise<void> {
512+
try {
513+
await db
514+
.update(cloud_agent_code_reviews)
515+
.set({
516+
head_sha: headSha,
517+
check_run_id: checkRunId,
518+
updated_at: new Date().toISOString(),
519+
})
520+
.where(eq(cloud_agent_code_reviews.id, reviewId));
521+
} catch (error) {
522+
captureException(error, {
523+
tags: { operation: 'updateReviewHeadShaAndCheckRun' },
524+
extra: { reviewId, headSha, checkRunId },
525+
});
526+
throw error;
527+
}
528+
}
529+
497530
/**
498531
* Verifies that a user owns (or is a member of the org that owns) a code review
499532
* Returns true if the user has access, false otherwise

apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createCodeReview,
88
findExistingReview,
99
findActiveReviewsForPR,
10+
updateReviewHeadShaAndCheckRun,
1011
} from '@/lib/code-reviews/db/code-reviews';
1112
import { tryDispatchPendingReviews } from '@/lib/code-reviews/dispatch/dispatch-pending-reviews';
1213
import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs';
@@ -170,6 +171,25 @@ export async function handlePullRequestCodeReview(
170171
repo: repository.full_name,
171172
head_sha: pull_request.head.sha,
172173
});
174+
175+
// The preserved review's check run still sits on the prior SHA, so
176+
// branch protection that requires the Kilo check would stay blocked
177+
// on the merge-commit head. Drop a fresh check run on the new HEAD
178+
// and repoint the review at it so its eventual completion callback
179+
// updates the gate on the commit GitHub actually evaluates. Note
180+
// the check run goes on the *base* repo (where branch protection
181+
// lives and where the app is installed), not the head/fork repo.
182+
const [baseOwner, baseRepoName] = repository.full_name.split('/');
183+
await migrateInFlightReviewsToMergeCommitHead({
184+
repoFullName: repository.full_name,
185+
prNumber: pull_request.number,
186+
newHeadSha: pull_request.head.sha,
187+
installationId: integration.platform_installation_id as string,
188+
baseOwner,
189+
baseRepoName,
190+
appType,
191+
});
192+
173193
return NextResponse.json({ message: 'Skipped merge commit' }, { status: 200 });
174194
}
175195

@@ -384,6 +404,88 @@ export async function shouldSkipSynchronizeForMergeCommit(args: {
384404
return check(args.installationId, args.headOwner, args.headRepoName, args.headSha, args.appType);
385405
}
386406

407+
/**
408+
* When a merge-commit synchronize arrives and we bail out to preserve the
409+
* in-flight review, the review is still pinned to the previous SHA's check
410+
* run. GitHub branch protection evaluates against the current HEAD, so the
411+
* required Kilo check would never appear on the merge commit. This helper
412+
* creates a fresh check run on the new HEAD and moves the review record
413+
* onto it, so the completion callback updates the visible gate. Best-effort:
414+
* any failure is logged but does not fail the webhook.
415+
*/
416+
async function migrateInFlightReviewsToMergeCommitHead(args: {
417+
repoFullName: string;
418+
prNumber: number;
419+
newHeadSha: string;
420+
installationId: string;
421+
// Owner/name of the *base* repo (where branch protection lives and the
422+
// GitHub App is installed). Fork PRs must not create check runs in the
423+
// contributor's fork — that repo often has no app installation, and
424+
// branch protection on the base wouldn't see the run anyway.
425+
baseOwner: string;
426+
baseRepoName: string;
427+
appType: GitHubAppType;
428+
}) {
429+
if (args.appType === 'lite') return;
430+
431+
try {
432+
const activeReviewIds = await findActiveReviewsForPR(
433+
args.repoFullName,
434+
args.prNumber,
435+
args.newHeadSha
436+
);
437+
if (activeReviewIds.length === 0) return;
438+
439+
// In practice a PR has at most one active review at a time; migrate the
440+
// first one to the new SHA. Any extras stay pinned to their old SHAs
441+
// and will be cancelled on the next non-merge push.
442+
const [reviewId] = activeReviewIds;
443+
const detailsUrl = `${APP_URL}/code-reviews/${reviewId}`;
444+
445+
let newCheckRunId: number | undefined;
446+
try {
447+
newCheckRunId = await createCheckRun(
448+
args.installationId,
449+
args.baseOwner,
450+
args.baseRepoName,
451+
args.newHeadSha,
452+
{
453+
detailsUrl,
454+
output: {
455+
title: 'Kilo Code Review in progress',
456+
summary: 'Continuing the review from the previous commit.',
457+
},
458+
},
459+
args.appType
460+
);
461+
await updateReviewHeadShaAndCheckRun(reviewId, args.newHeadSha, newCheckRunId);
462+
logExceptInTest(
463+
`Migrated review ${reviewId} to merge-commit head ${args.newHeadSha} (check run ${newCheckRunId})`
464+
);
465+
} catch (migrateError) {
466+
logExceptInTest('Failed to migrate in-flight review onto merge commit head:', migrateError);
467+
// If we created the new check run on GitHub but could not persist
468+
// the migration, cancel the new run so it does not stay 'queued'
469+
// forever and block branch-protection gating.
470+
if (newCheckRunId !== undefined) {
471+
try {
472+
await updateCheckRun(
473+
args.installationId,
474+
args.baseOwner,
475+
args.baseRepoName,
476+
newCheckRunId,
477+
{ status: 'completed', conclusion: 'cancelled' }
478+
);
479+
} catch (cancelError) {
480+
logExceptInTest('Failed to cancel orphaned merge-commit check run:', cancelError);
481+
}
482+
}
483+
}
484+
} catch (lookupError) {
485+
logExceptInTest('Failed to find active reviews for merge-commit migration:', lookupError);
486+
}
487+
}
488+
387489
/**
388490
* Main router for pull request events
389491
*/

apps/web/src/lib/integrations/platforms/gitlab/webhook-handlers/merge-request-handler.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createCodeReview,
1717
findExistingReview,
1818
findActiveReviewsForPR,
19+
updateReviewHeadShaAndCheckRun,
1920
} from '@/lib/code-reviews/db/code-reviews';
2021
import { tryDispatchPendingReviews } from '@/lib/code-reviews/dispatch/dispatch-pending-reviews';
2122
import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs';
@@ -177,6 +178,21 @@ export async function handleMergeRequestCodeReview(
177178
project: project.path_with_namespace,
178179
head_sha: headSha,
179180
});
181+
182+
// The preserved review is still pinned to the prior commit's status,
183+
// so any required 'kilo/code-review' gate would stay stuck on that
184+
// SHA while MR branch protection evaluates the new HEAD. Repoint the
185+
// review at the merge-commit SHA and drop a fresh pending status so
186+
// the eventual completion callback writes its final state to the
187+
// commit GitLab actually evaluates.
188+
await migrateInFlightReviewsToMergeCommitHead({
189+
integrationId: integration.id,
190+
projectId: project.id,
191+
repoFullName: project.path_with_namespace,
192+
mrIid: mr.iid,
193+
newHeadSha: headSha,
194+
});
195+
180196
return NextResponse.json({ message: 'Skipped merge commit' }, { status: 200 });
181197
}
182198

@@ -363,6 +379,68 @@ export async function shouldSkipUpdateForMergeCommit(args: {
363379
}
364380
}
365381

382+
/**
383+
* When a merge-commit update arrives and we bail out to preserve the
384+
* in-flight review, the review row still points at the previous SHA, so
385+
* the completion callback would post its final GitLab commit status on
386+
* the abandoned commit. Repoint the review at the merge-commit SHA and
387+
* drop a fresh pending status on it. Best-effort: any failure is logged
388+
* but does not fail the webhook.
389+
*/
390+
async function migrateInFlightReviewsToMergeCommitHead(args: {
391+
integrationId: string;
392+
projectId: number;
393+
repoFullName: string;
394+
mrIid: number;
395+
newHeadSha: string;
396+
}) {
397+
try {
398+
const activeReviewIds = await findActiveReviewsForPR(
399+
args.repoFullName,
400+
args.mrIid,
401+
args.newHeadSha
402+
);
403+
if (activeReviewIds.length === 0) return;
404+
405+
const fullIntegration = await getIntegrationById(args.integrationId);
406+
if (!fullIntegration) return;
407+
const metadata = fullIntegration.metadata as { gitlab_instance_url?: string } | null;
408+
const instanceUrl = metadata?.gitlab_instance_url || 'https://gitlab.com';
409+
410+
// In practice an MR has at most one active review; migrate the first.
411+
const [reviewId] = activeReviewIds;
412+
413+
// Update the DB row first. If this fails we never touched the new SHA,
414+
// so there's nothing to clean up. If it succeeds but setCommitStatus
415+
// fails below, the eventual completion callback will still target the
416+
// correct (new) SHA — we just miss the transient pending badge.
417+
await updateReviewHeadShaAndCheckRun(reviewId, args.newHeadSha, null);
418+
419+
try {
420+
const pratToken = await getOrCreateProjectAccessToken(fullIntegration, args.projectId);
421+
const detailsUrl = `${APP_URL}/code-reviews/${reviewId}`;
422+
await setCommitStatus(
423+
pratToken,
424+
args.projectId,
425+
args.newHeadSha,
426+
'pending',
427+
{
428+
targetUrl: detailsUrl,
429+
description: 'Kilo Code Review continuing from previous commit',
430+
},
431+
instanceUrl
432+
);
433+
logExceptInTest(
434+
`Migrated review ${reviewId} to merge-commit head ${args.newHeadSha} and set pending status`
435+
);
436+
} catch (statusError) {
437+
logExceptInTest('Failed to set pending status on merge-commit head:', statusError);
438+
}
439+
} catch (migrateError) {
440+
logExceptInTest('Failed to migrate in-flight review onto merge commit head:', migrateError);
441+
}
442+
}
443+
366444
/**
367445
* Main router for merge request events
368446
*/

0 commit comments

Comments
 (0)