Skip to content

Commit d52e923

Browse files
committed
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.
1 parent 159b1c6 commit d52e923

5 files changed

Lines changed: 282 additions & 72 deletions

File tree

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { resolvePullRequestCheckoutRef } from '@/lib/integrations/platforms/github/webhook-handlers/pull-request-checkout-ref';
2+
import { shouldSkipSynchronizeForMergeCommit } from '@/lib/integrations/platforms/github/webhook-handlers/pull-request-handler';
23

34
describe('resolvePullRequestCheckoutRef', () => {
45
it('uses head.ref for same-repo PRs', () => {
@@ -63,3 +64,64 @@ describe('resolvePullRequestCheckoutRef', () => {
6364
});
6465
});
6566
});
67+
68+
describe('shouldSkipSynchronizeForMergeCommit', () => {
69+
const baseArgs = {
70+
installationId: 'inst-1',
71+
headOwner: 'acme',
72+
headRepoName: 'widgets',
73+
headSha: 'deadbeef',
74+
appType: 'standard' as const,
75+
};
76+
77+
it('returns false for non-synchronize actions without calling the check', async () => {
78+
for (const action of ['opened', 'reopened', 'ready_for_review']) {
79+
let called = false;
80+
const result = await shouldSkipSynchronizeForMergeCommit({
81+
...baseArgs,
82+
action,
83+
isMergeCommitFn: async () => {
84+
called = true;
85+
return true;
86+
},
87+
});
88+
89+
expect(result).toBe(false);
90+
expect(called).toBe(false);
91+
}
92+
});
93+
94+
it('returns true when synchronize head is a merge commit', async () => {
95+
const result = await shouldSkipSynchronizeForMergeCommit({
96+
...baseArgs,
97+
action: 'synchronize',
98+
isMergeCommitFn: async () => true,
99+
});
100+
101+
expect(result).toBe(true);
102+
});
103+
104+
it('returns false when synchronize head is not a merge commit', async () => {
105+
const result = await shouldSkipSynchronizeForMergeCommit({
106+
...baseArgs,
107+
action: 'synchronize',
108+
isMergeCommitFn: async () => false,
109+
});
110+
111+
expect(result).toBe(false);
112+
});
113+
114+
it('passes the expected arguments to the check function', async () => {
115+
const calls: Array<[string, string, string, string, string]> = [];
116+
await shouldSkipSynchronizeForMergeCommit({
117+
...baseArgs,
118+
action: 'synchronize',
119+
isMergeCommitFn: async (installationId, owner, repo, sha, appType) => {
120+
calls.push([installationId, owner, repo, sha, appType]);
121+
return false;
122+
},
123+
});
124+
125+
expect(calls).toEqual([['inst-1', 'acme', 'widgets', 'deadbeef', 'standard']]);
126+
});
127+
});

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

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ import type { PlatformIntegration } from '@kilocode/db/schema';
1414
import type { Owner } from '@/lib/code-reviews/core';
1515
import { getBotUserId } from '@/lib/bot-users/bot-user-service';
1616
import 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';
1824
import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-worker-client';
1925
import { updateCheckRunId } from '@/lib/code-reviews/db/code-reviews';
2026
import { 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
*/
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { shouldSkipUpdateForMergeCommit } from '@/lib/integrations/platforms/gitlab/webhook-handlers/merge-request-handler';
2+
3+
describe('shouldSkipUpdateForMergeCommit', () => {
4+
it('returns false for non-update actions without calling the check', async () => {
5+
const actions: Array<string | undefined> = ['open', 'reopen', 'close', 'merge', undefined];
6+
for (const action of actions) {
7+
let called = false;
8+
const result = await shouldSkipUpdateForMergeCommit({
9+
action,
10+
check: async () => {
11+
called = true;
12+
return true;
13+
},
14+
});
15+
16+
expect(result).toBe(false);
17+
expect(called).toBe(false);
18+
}
19+
});
20+
21+
it('returns true when update head is a merge commit', async () => {
22+
const result = await shouldSkipUpdateForMergeCommit({
23+
action: 'update',
24+
check: async () => true,
25+
});
26+
27+
expect(result).toBe(true);
28+
});
29+
30+
it('returns false when update head is not a merge commit', async () => {
31+
const result = await shouldSkipUpdateForMergeCommit({
32+
action: 'update',
33+
check: async () => false,
34+
});
35+
36+
expect(result).toBe(false);
37+
});
38+
39+
it('fails open when the check throws (review proceeds)', async () => {
40+
const result = await shouldSkipUpdateForMergeCommit({
41+
action: 'update',
42+
check: async () => {
43+
throw new Error('GitLab API unreachable');
44+
},
45+
});
46+
47+
expect(result).toBe(false);
48+
});
49+
});

0 commit comments

Comments
 (0)