Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { db } from '@/lib/drizzle';
import type { CodeReviewAgentConfig } from '@/lib/agent-config/core/types';
import { insertTestUser } from '@/tests/helpers/user.helper';
import { cloud_agent_code_reviews, kilocode_users, type User } from '@kilocode/db/schema';
import { eq } from 'drizzle-orm';
import { prepareReviewPayload } from './prepare-review-payload';

const REPO = `test-org/prepare-review-payload-${Date.now()}`;

const codeReviewConfig = {
review_style: 'balanced',
focus_areas: ['bugs'],
custom_instructions: null,
max_review_time_minutes: 20,
model_slug: 'anthropic/claude-sonnet-4.5',
repository_selection_mode: 'all',
gate_threshold: 'off',
} satisfies CodeReviewAgentConfig;

describe('prepareReviewPayload', () => {
let testUser: User;

beforeAll(async () => {
testUser = await insertTestUser();
});

afterEach(async () => {
await db
.delete(cloud_agent_code_reviews)
.where(eq(cloud_agent_code_reviews.owned_by_user_id, testUser.id));
});

afterAll(async () => {
await db.delete(kilocode_users).where(eq(kilocode_users.id, testUser.id));
});

it('uses the stable GitHub pull ref for agent checkout when the stored head_ref is a branch name', async () => {
const prNumber = 1234;
const [review] = await db
.insert(cloud_agent_code_reviews)
.values({
owned_by_user_id: testUser.id,
repo_full_name: REPO,
pr_number: prNumber,
pr_url: `https://github.com/${REPO}/pull/${prNumber}`,
pr_title: 'Test PR with deleted source branch',
pr_author: 'octocat',
base_ref: 'main',
head_ref: 'feature/deleted-after-merge',
head_sha: 'sha-current',
platform: 'github',
status: 'pending',
})
.returning({ id: cloud_agent_code_reviews.id });

if (!review) {
throw new Error('Expected inserted review');
}

const payload = await prepareReviewPayload({
reviewId: review.id,
owner: {
type: 'user',
id: testUser.id,
userId: testUser.id,
},
agentConfig: {
config: codeReviewConfig,
},
platform: 'github',
});

expect(payload.sessionInput).toMatchObject({
githubRepo: REPO,
platform: 'github',
upstreamBranch: 'refs/pull/1234/head',
});
});

it('does not continue previous cloud-agent sessions for GitHub pull-ref reviews', async () => {
const prNumber = 1235;
const repo = `${REPO}-session-continuation`;

await db.insert(cloud_agent_code_reviews).values({
owned_by_user_id: testUser.id,
repo_full_name: repo,
pr_number: prNumber,
pr_url: `https://github.com/${repo}/pull/${prNumber}`,
pr_title: 'Previous completed review',
pr_author: 'octocat',
base_ref: 'main',
head_ref: 'feature/old-head',
head_sha: 'sha-previous',
platform: 'github',
status: 'completed',
session_id: 'previous-cloud-agent-session',
});

const [review] = await db
.insert(cloud_agent_code_reviews)
.values({
owned_by_user_id: testUser.id,
repo_full_name: repo,
pr_number: prNumber,
pr_url: `https://github.com/${repo}/pull/${prNumber}`,
pr_title: 'Current review',
pr_author: 'octocat',
base_ref: 'main',
head_ref: 'feature/current-head',
head_sha: 'sha-current',
platform: 'github',
status: 'pending',
})
.returning({ id: cloud_agent_code_reviews.id });

if (!review) {
throw new Error('Expected inserted review');
}

const payload = await prepareReviewPayload({
reviewId: review.id,
owner: {
type: 'user',
id: testUser.id,
userId: testUser.id,
},
agentConfig: {
config: codeReviewConfig,
},
platform: 'github',
});

expect(payload.previousCloudAgentSessionId).toBeUndefined();
expect(payload.sessionInput).toMatchObject({
githubRepo: repo,
platform: 'github',
upstreamBranch: 'refs/pull/1235/head',
});
});
});
27 changes: 22 additions & 5 deletions apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type { CodeReviewAgentConfig } from '@/lib/agent-config/core/types';
import { logExceptInTest, errorExceptInTest } from '@/lib/utils.server';
import type { CodeReviewPlatform } from '../core/schemas';
import { PLATFORM } from '@/lib/integrations/core/constants';
import { getGitHubPullRequestCheckoutRef } from '@/lib/integrations/platforms/github/webhook-handlers/pull-request-checkout-ref';

export type PreparePayloadParams = {
reviewId: string;
Expand Down Expand Up @@ -309,9 +310,9 @@ export async function prepareReviewPayload(
}
}

// 4. Check for previous completed review (incremental review optimization)
// Both previousHeadSha (for diff base) and previousCloudAgentSessionId (for session
// continuation) are derived from the same review row to avoid mismatches.
// 4. Check for previous completed review (incremental review optimization).
// Keep previousHeadSha for prompt diff context, but disable GitHub session
// continuation because sendMessageV2 does not refetch refs/pull/<n>/head.
let previousHeadSha: string | null = null;
let previousCloudAgentSessionId: string | undefined;
try {
Expand All @@ -322,7 +323,21 @@ export async function prepareReviewPayload(
platform
);
previousHeadSha = previousReview?.head_sha ?? null;
previousCloudAgentSessionId = previousReview?.session_id ?? undefined;

if (previousReview?.session_id) {
if (platform === PLATFORM.GITHUB) {
logExceptInTest(
'[prepareReviewPayload] Disabling GitHub session continuation for pull-ref checkout safety',
{
reviewId,
previousCloudAgentSessionId: previousReview.session_id,
upstreamBranch: getGitHubPullRequestCheckoutRef(review.pr_number),
}
);
} else {
previousCloudAgentSessionId = previousReview.session_id;
}
}

if (previousHeadSha) {
logExceptInTest(
Expand All @@ -331,6 +346,7 @@ export async function prepareReviewPayload(
reviewId,
previousHeadSha: previousHeadSha.substring(0, 8),
currentHeadSha: review.head_sha.substring(0, 8),
previousSessionIdAvailable: !!previousReview?.session_id,
previousCloudAgentSessionId,
}
);
Expand Down Expand Up @@ -385,6 +401,7 @@ export async function prepareReviewPayload(
// GitLab: uses gitUrl (full HTTPS URL) + gitToken
const variant = config.thinking_effort ?? undefined;
const gateThreshold = config.gate_threshold ?? 'off';
const githubCheckoutRef = getGitHubPullRequestCheckoutRef(review.pr_number);
const sessionInput: SessionInput =
platform === PLATFORM.GITLAB
? {
Expand All @@ -410,7 +427,7 @@ export async function prepareReviewPayload(
mode: DEFAULT_CODE_REVIEW_MODE as 'code',
model: config.model_slug || DEFAULT_CODE_REVIEW_MODEL,
variant,
upstreamBranch: review.head_ref,
upstreamBranch: githubCheckoutRef,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Follow-up reviews can run against a stale checkout

upstreamBranch is now the synthetic refs/pull/<number>/head ref. The cloud-agent checkout path fetches that ref into a local branch without configuring an upstream, while v2 incremental reviews reuse previousCloudAgentSessionId and only send a follow-up message. On a later push, the incremental prompt's git pull cannot update this branch, so the agent can read files from the previous commit while reviewing the current PR diff. Please make the follow-up path fetch/checkout this pull ref again, or pin the checkout to head_sha, before starting the review.

...(gateThreshold !== 'off' ? { gateThreshold } : {}),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,22 @@ export type PullRequestCheckoutRefInput = {
};
};

export function getGitHubPullRequestCheckoutRef(prNumber: number): string {
return `refs/pull/${prNumber}/head`;
}

/**
* Resolve which git ref should be checked out for a PR review.
*
* - Same-repo PRs: use head.ref (e.g. "feature/my-change")
* - Fork PRs: use GitHub's synthetic pull ref (e.g. "refs/pull/123/head")
* GitHub keeps refs/pull/<number>/head available even when the source
* branch is deleted after merge/close, so use it for both same-repo and fork PRs.
*/
export function resolvePullRequestCheckoutRef(
payload: PullRequestCheckoutRefInput
): PullRequestCheckoutRef {
const headRepoFullName = payload.pull_request.head.repo?.full_name ?? null;
const isForkPr = headRepoFullName !== null && headRepoFullName !== payload.repository.full_name;
const checkoutRef = isForkPr
? `refs/pull/${payload.pull_request.number}/head`
: payload.pull_request.head.ref;
const checkoutRef = getGitHubPullRequestCheckoutRef(payload.pull_request.number);

return {
checkoutRef,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { resolvePullRequestCheckoutRef } from '@/lib/integrations/platforms/github/webhook-handlers/pull-request-checkout-ref';
import {
getGitHubPullRequestCheckoutRef,
resolvePullRequestCheckoutRef,
} from '@/lib/integrations/platforms/github/webhook-handlers/pull-request-checkout-ref';
import { shouldSkipSynchronizeForMergeCommit } from '@/lib/integrations/platforms/github/webhook-handlers/pull-request-handler';

describe('resolvePullRequestCheckoutRef', () => {
it('uses head.ref for same-repo PRs', () => {
it('builds GitHub synthetic pull refs', () => {
expect(getGitHubPullRequestCheckoutRef(123)).toBe('refs/pull/123/head');
});

it('uses refs/pull/<number>/head for same-repo PRs', () => {
const result = resolvePullRequestCheckoutRef({
pull_request: {
number: 123,
Expand All @@ -17,7 +24,7 @@ describe('resolvePullRequestCheckoutRef', () => {
});

expect(result).toEqual({
checkoutRef: 'feature/same-repo',
checkoutRef: 'refs/pull/123/head',
isForkPr: false,
headRepoFullName: 'acme/widgets',
});
Expand All @@ -44,7 +51,7 @@ describe('resolvePullRequestCheckoutRef', () => {
});
});

it('falls back to head.ref when head.repo is missing', () => {
it('uses refs/pull/<number>/head when head.repo is missing', () => {
const result = resolvePullRequestCheckoutRef({
pull_request: {
number: 789,
Expand All @@ -58,7 +65,7 @@ describe('resolvePullRequestCheckoutRef', () => {
});

expect(result).toEqual({
checkoutRef: 'feature/missing-head-repo',
checkoutRef: 'refs/pull/789/head',
isForkPr: false,
headRepoFullName: null,
});
Expand Down