@@ -14,7 +14,13 @@ import type { PlatformIntegration } from '@kilocode/db/schema';
1414import type { Owner } from '@/lib/code-reviews/core' ;
1515import { getBotUserId } from '@/lib/bot-users/bot-user-service' ;
1616import type { CodeReviewAgentConfig } from '@/lib/agent-config/core/types' ;
17- import { addReactionToPR , createCheckRun , isMergeCommit , updateCheckRun } from '../adapter' ;
17+ import type { GitHubAppType } from '@/lib/integrations/platforms/github/adapter' ;
18+ import {
19+ addReactionToPR ,
20+ createCheckRun ,
21+ isMergeCommit ,
22+ updateCheckRun ,
23+ } from '@/lib/integrations/platforms/github/adapter' ;
1824import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-worker-client' ;
1925import { updateCheckRunId } from '@/lib/code-reviews/db/code-reviews' ;
2026import { resolvePullRequestCheckoutRef } from './pull-request-checkout-ref' ;
@@ -141,7 +147,34 @@ export async function handlePullRequestCodeReview(
141147 ) ;
142148 }
143149
144- // 4. Cancel any existing reviews for this PR (different SHA)
150+ const appType = integration . github_app_type ?? 'standard' ;
151+ const headFullName = checkoutRef . headRepoFullName ?? repository . full_name ;
152+ const [ headOwner , headRepoName ] = headFullName . split ( '/' ) ;
153+
154+ // 4. Skip merge commits on synchronize (e.g. merging base branch into feature branch).
155+ // Runs before cancellation so that an in-flight review at an earlier SHA is preserved:
156+ // a merge commit introduces no new feature work and should not supersede the existing review.
157+ if (
158+ headOwner &&
159+ headRepoName &&
160+ ( await shouldSkipSynchronizeForMergeCommit ( {
161+ action : payload . action ,
162+ installationId : integration . platform_installation_id as string ,
163+ headOwner,
164+ headRepoName,
165+ headSha : pull_request . head . sha ,
166+ appType,
167+ } ) )
168+ ) {
169+ logExceptInTest ( 'Skipping merge commit:' , {
170+ pr_number : pull_request . number ,
171+ repo : repository . full_name ,
172+ head_sha : pull_request . head . sha ,
173+ } ) ;
174+ return NextResponse . json ( { message : 'Skipped merge commit' } , { status : 200 } ) ;
175+ }
176+
177+ // 5. Cancel any existing reviews for this PR (different SHA)
145178 // This prevents spam when user pushes multiple commits quickly
146179 const oldReviewIds = await findActiveReviewsForPR (
147180 repository . full_name ,
@@ -165,29 +198,7 @@ export async function handlePullRequestCodeReview(
165198 ) ;
166199 }
167200
168- // 4b. Skip merge commits on synchronize (e.g. merging base branch into feature branch)
169- // Placed after step 4 so old reviews are still cancelled before we bail out.
170- if ( payload . action === GITHUB_ACTION . SYNCHRONIZE ) {
171- const headRepo = checkoutRef . headRepoFullName ?? repository . full_name ;
172- const [ headOwner , headRepoName ] = headRepo . split ( '/' ) ;
173- const mergeCommit = await isMergeCommit (
174- integration . platform_installation_id as string ,
175- headOwner ,
176- headRepoName ,
177- pull_request . head . sha ,
178- integration . github_app_type ?? 'standard'
179- ) ;
180- if ( mergeCommit ) {
181- logExceptInTest ( 'Skipping merge commit:' , {
182- pr_number : pull_request . number ,
183- repo : repository . full_name ,
184- head_sha : pull_request . head . sha ,
185- } ) ;
186- return NextResponse . json ( { message : 'Skipped merge commit' } , { status : 200 } ) ;
187- }
188- }
189-
190- // 5. Check for duplicate review (same repo, PR, SHA)
201+ // 6. Check for duplicate review (same repo, PR, SHA)
191202 const existingReview = await findExistingReview (
192203 repository . full_name ,
193204 pull_request . number ,
@@ -208,7 +219,7 @@ export async function handlePullRequestCodeReview(
208219 ) ;
209220 }
210221
211- // 6 . Create review record (session_id will be updated async)
222+ // 7 . Create review record (session_id will be updated async)
212223 const reviewId = await createCodeReview ( {
213224 owner,
214225 platformIntegrationId : integration . id ,
@@ -230,8 +241,7 @@ export async function handlePullRequestCodeReview(
230241
231242 const [ repoOwner , repoName ] = repository . full_name . split ( '/' ) ;
232243
233- // 7. Create GitHub Check Run (PR gate) — skip for lite (read-only) app, skip when flag is off
234- const appType = integration . github_app_type ?? 'standard' ;
244+ // 8. Create GitHub Check Run (PR gate) — skip for lite (read-only) app, skip when flag is off
235245 const isPrGateEnabled =
236246 process . env . NODE_ENV === 'development' ||
237247 ( await isFeatureFlagEnabled ( 'code-review-pr-gate' , owner . userId ) ) ;
@@ -283,7 +293,7 @@ export async function handlePullRequestCodeReview(
283293 }
284294 }
285295
286- // 8 . Post 👀 reaction to show Kilo is reviewing
296+ // 9 . Post 👀 reaction to show Kilo is reviewing
287297 try {
288298 await addReactionToPR (
289299 integration . platform_installation_id as string ,
@@ -298,7 +308,7 @@ export async function handlePullRequestCodeReview(
298308 logExceptInTest ( 'Failed to add eyes reaction:' , reactionError ) ;
299309 }
300310
301- // 9 . Try to dispatch pending reviews (including this new one)
311+ // 10 . Try to dispatch pending reviews (including this new one)
302312 // Review is created with status='pending' and dispatch will pick it up if slots available
303313 try {
304314 const dispatchResult = await tryDispatchPendingReviews ( owner ) ;
@@ -323,7 +333,7 @@ export async function handlePullRequestCodeReview(
323333 // Don't throw - review record created as pending, will be picked up later
324334 }
325335
326- // 10 . Return 202 Accepted (always succeeds, review queued as pending)
336+ // 11 . Return 202 Accepted (always succeeds, review queued as pending)
327337 return NextResponse . json (
328338 {
329339 message : 'Code review queued' ,
@@ -351,6 +361,34 @@ export async function handlePullRequestCodeReview(
351361 }
352362}
353363
364+ /**
365+ * Decides whether a pull_request webhook should bail out because its head
366+ * commit is a merge commit (e.g. produced by GitHub's "Update branch" button
367+ * or a manual `git merge main`). Only applies to synchronize events.
368+ *
369+ * Extracted so tests can inject a fake `isMergeCommitFn` without mocking the
370+ * GitHub adapter module.
371+ */
372+ export async function shouldSkipSynchronizeForMergeCommit ( args : {
373+ action : string ;
374+ installationId : string ;
375+ headOwner : string ;
376+ headRepoName : string ;
377+ headSha : string ;
378+ appType : GitHubAppType ;
379+ isMergeCommitFn ?: (
380+ installationId : string ,
381+ owner : string ,
382+ repo : string ,
383+ sha : string ,
384+ appType : GitHubAppType
385+ ) => Promise < boolean > ;
386+ } ) : Promise < boolean > {
387+ if ( args . action !== GITHUB_ACTION . SYNCHRONIZE ) return false ;
388+ const check = args . isMergeCommitFn ?? isMergeCommit ;
389+ return check ( args . installationId , args . headOwner , args . headRepoName , args . headSha , args . appType ) ;
390+ }
391+
354392/**
355393 * Main router for pull request events
356394 */
0 commit comments