Skip to content
Merged
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
28 changes: 28 additions & 0 deletions cloud-agent-next/src/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,34 @@ describe('manageBranch', () => {
});

describe('and it is an upstream branch', () => {
it('should fetch and checkout GitHub pull refs', async () => {
mockExec
.mockResolvedValueOnce({ exitCode: 0 }) // git fetch
.mockResolvedValueOnce({ exitCode: 1 }) // local check (does not exist)
.mockResolvedValueOnce({ exitCode: 1 }) // remote check (does not exist)
.mockResolvedValueOnce({ exitCode: 0 }) // fetch pull ref
.mockResolvedValueOnce({ exitCode: 0 }); // checkout from FETCH_HEAD

const result = await manageBranch(fakeSession, '/workspace', 'refs/pull/42/head', true);

const execCalls = mockExec.mock.calls;
expect(execCalls[3]?.[0]).toContain("git fetch origin 'refs/pull/42/head'");
expect(execCalls[4]?.[0]).toContain("git checkout -B 'refs/pull/42/head' FETCH_HEAD");
expect(result).toBe('refs/pull/42/head');
});

it('should throw when pull ref fetch fails', async () => {
mockExec
.mockResolvedValueOnce({ exitCode: 0 }) // git fetch
.mockResolvedValueOnce({ exitCode: 1 }) // local check (does not exist)
.mockResolvedValueOnce({ exitCode: 1 }) // remote check (does not exist)
.mockResolvedValueOnce({ exitCode: 1, stderr: 'fetch pull ref error' }); // fetch pull ref fails

await expect(
manageBranch(fakeSession, '/workspace', 'refs/pull/42/head', true)
).rejects.toThrow('Failed to fetch pull ref refs/pull/42/head');
});

it('should throw error', async () => {
mockExec
.mockResolvedValueOnce({ exitCode: 0 }) // git fetch
Expand Down
35 changes: 35 additions & 0 deletions cloud-agent-next/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,34 @@ async function createNewBranch(
}
}

const GITHUB_PULL_REF_PATTERN = /^refs\/pull\/\d+\/head$/;

async function fetchPullRefAndCheckout(
session: ExecutionSession,
workspacePath: string,
pullRef: string
): Promise<void> {
if (!GITHUB_PULL_REF_PATTERN.test(pullRef)) {
throw new Error(`Invalid pull ref format: ${pullRef}`);
}

const fetchResult = await session.exec(`cd ${workspacePath} && git fetch origin '${pullRef}'`);
if (fetchResult.exitCode !== 0) {
throw new Error(
`Failed to fetch pull ref ${pullRef}: ${fetchResult.stderr || fetchResult.stdout}`
);
}

const checkoutResult = await session.exec(
`cd ${workspacePath} && git checkout -B '${pullRef}' FETCH_HEAD`
);
if (checkoutResult.exitCode !== 0) {
throw new Error(
`Failed to checkout pull ref ${pullRef}: ${checkoutResult.stderr || checkoutResult.stdout}`
);
}
}

/**
* Manage branch checkout/creation.
*
Expand Down Expand Up @@ -542,6 +570,13 @@ export async function manageBranch(
} else {
// Case 4: Doesn't exist anywhere
if (isUpstreamBranch) {
if (GITHUB_PULL_REF_PATTERN.test(branchName)) {
await fetchPullRefAndCheckout(session, workspacePath, branchName);
logger.withTags({ pullRef: branchName }).info('Checked out GitHub pull ref');
logger.debug('Successfully on branch');
return branchName;
}

throw new Error(
`Branch "${branchName}" not found in repository. Please ensure the branch exists remotely.`
);
Expand Down
28 changes: 28 additions & 0 deletions cloud-agent/src/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,34 @@ describe('manageBranch', () => {
});

describe('and it is an upstream branch', () => {
it('should fetch and checkout GitHub pull refs', async () => {
mockExec
.mockResolvedValueOnce({ exitCode: 0 }) // git fetch
.mockResolvedValueOnce({ exitCode: 1 }) // local check (does not exist)
.mockResolvedValueOnce({ exitCode: 1 }) // remote check (does not exist)
.mockResolvedValueOnce({ exitCode: 0 }) // fetch pull ref
.mockResolvedValueOnce({ exitCode: 0 }); // checkout from FETCH_HEAD

const result = await manageBranch(fakeSession, '/workspace', 'refs/pull/42/head', true);

const execCalls = mockExec.mock.calls;
expect(execCalls[3]?.[0]).toContain("git fetch origin 'refs/pull/42/head'");
expect(execCalls[4]?.[0]).toContain("git checkout -B 'refs/pull/42/head' FETCH_HEAD");
expect(result).toBe('refs/pull/42/head');
});

it('should throw when pull ref fetch fails', async () => {
mockExec
.mockResolvedValueOnce({ exitCode: 0 }) // git fetch
.mockResolvedValueOnce({ exitCode: 1 }) // local check (does not exist)
.mockResolvedValueOnce({ exitCode: 1 }) // remote check (does not exist)
.mockResolvedValueOnce({ exitCode: 1, stderr: 'fetch pull ref error' }); // fetch pull ref fails

await expect(
manageBranch(fakeSession, '/workspace', 'refs/pull/42/head', true)
).rejects.toThrow('Failed to fetch pull ref refs/pull/42/head');
});

it('should throw error', async () => {
mockExec
.mockResolvedValueOnce({ exitCode: 0 }) // git fetch
Expand Down
35 changes: 35 additions & 0 deletions cloud-agent/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,34 @@ async function createNewBranch(
}
}

const GITHUB_PULL_REF_PATTERN = /^refs\/pull\/\d+\/head$/;

async function fetchPullRefAndCheckout(
session: ExecutionSession,
workspacePath: string,
pullRef: string
): Promise<void> {
if (!GITHUB_PULL_REF_PATTERN.test(pullRef)) {
throw new Error(`Invalid pull ref format: ${pullRef}`);
}

const fetchResult = await session.exec(`cd ${workspacePath} && git fetch origin '${pullRef}'`);
if (fetchResult.exitCode !== 0) {
throw new Error(
`Failed to fetch pull ref ${pullRef}: ${fetchResult.stderr || fetchResult.stdout}`
);
}

const checkoutResult = await session.exec(
`cd ${workspacePath} && git checkout -B '${pullRef}' FETCH_HEAD`
);
if (checkoutResult.exitCode !== 0) {
throw new Error(
`Failed to checkout pull ref ${pullRef}: ${checkoutResult.stderr || checkoutResult.stdout}`
);
}
}

/**
* Manage branch checkout/creation.
*
Expand Down Expand Up @@ -642,6 +670,13 @@ export async function manageBranch(
} else {
// Case 4: Doesn't exist anywhere
if (isUpstreamBranch) {
if (GITHUB_PULL_REF_PATTERN.test(branchName)) {
await fetchPullRefAndCheckout(session, workspacePath, branchName);
logger.withTags({ pullRef: branchName }).info('Checked out GitHub pull ref');
logger.debug('Successfully on branch');
return branchName;
}

throw new Error(
`Branch "${branchName}" not found in repository. Please ensure the branch exists remotely.`
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export type PullRequestCheckoutRef = {
checkoutRef: string;
isForkPr: boolean;
headRepoFullName: string | null;
};

export type PullRequestCheckoutRefInput = {
pull_request: {
number: number;
head: {
ref: string;
repo?: {
full_name: string;
} | null;
};
};
repository: {
full_name: string;
};
};

/**
* 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")
Copy link
Contributor

Choose a reason for hiding this comment

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

why not always use this ref? doesnt this work for regular 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;

return {
checkoutRef,
isForkPr,
headRepoFullName,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { resolvePullRequestCheckoutRef } from '@/lib/integrations/platforms/github/webhook-handlers/pull-request-checkout-ref';

describe('resolvePullRequestCheckoutRef', () => {
it('uses head.ref for same-repo PRs', () => {
const result = resolvePullRequestCheckoutRef({
pull_request: {
number: 123,
head: {
ref: 'feature/same-repo',
repo: { full_name: 'acme/widgets' },
},
},
repository: {
full_name: 'acme/widgets',
},
});

expect(result).toEqual({
checkoutRef: 'feature/same-repo',
isForkPr: false,
headRepoFullName: 'acme/widgets',
});
});

it('uses refs/pull/<number>/head for fork PRs', () => {
const result = resolvePullRequestCheckoutRef({
pull_request: {
number: 456,
head: {
ref: 'feature/fork-branch',
repo: { full_name: 'external/widgets-fork' },
},
},
repository: {
full_name: 'acme/widgets',
},
});

expect(result).toEqual({
checkoutRef: 'refs/pull/456/head',
isForkPr: true,
headRepoFullName: 'external/widgets-fork',
});
});

it('falls back to head.ref when head.repo is missing', () => {
const result = resolvePullRequestCheckoutRef({
pull_request: {
number: 789,
head: {
ref: 'feature/missing-head-repo',
},
},
repository: {
full_name: 'acme/widgets',
},
});

expect(result).toEqual({
checkoutRef: 'feature/missing-head-repo',
isForkPr: false,
headRepoFullName: null,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getBotUserId } from '@/lib/bot-users/bot-user-service';
import type { CodeReviewAgentConfig } from '@/lib/agent-config/core/types';
import { addReactionToPR } from '../adapter';
import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-worker-client';
import { resolvePullRequestCheckoutRef } from './pull-request-checkout-ref';

/**
* GitHub Pull Request Event Handler
Expand All @@ -33,13 +34,22 @@ export async function handlePullRequestCodeReview(
const { pull_request, repository } = payload;

try {
const checkoutRef = resolvePullRequestCheckoutRef(payload);

logExceptInTest('Pull request event received:', {
action: payload.action,
pr_number: pull_request.number,
repo: repository.full_name,
title: pull_request.title,
author: pull_request.user?.login,
});
logExceptInTest('Resolved pull request checkout ref:', {
pr_number: pull_request.number,
repo: repository.full_name,
isForkPr: checkoutRef.isForkPr,
headRepoFullName: checkoutRef.headRepoFullName,
checkoutRef: checkoutRef.checkoutRef,
});

// Skip draft PRs - only trigger code review for ready PRs
if (pull_request.draft === true) {
Expand Down Expand Up @@ -184,7 +194,7 @@ export async function handlePullRequestCodeReview(
prAuthor: pull_request.user.login,
prAuthorGithubId: String(pull_request.user.id),
baseRef: pull_request.base.ref,
headRef: pull_request.head.ref,
headRef: checkoutRef.checkoutRef,
headSha: pull_request.head.sha,
platform: 'github',
});
Expand Down
6 changes: 6 additions & 0 deletions src/lib/integrations/platforms/github/webhook-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ export const PullRequestPayloadSchema = z.object({
head: z.object({
sha: z.string(),
ref: z.string(),
repo: z
.object({
full_name: z.string(),
})
.nullable()
.optional(),
}),
base: z.object({
sha: z.string(),
Expand Down