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
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ const mockBuildReviewSummaryFooter = jest.fn<any>();
const mockRetryReviewFresh = jest.fn<any>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockDisableCodeReviewForActionRequiredFailure = jest.fn<any>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockResolveAddressedGitHubReviewThreads = jest.fn<any>();

// --- Module mocks ---

Expand Down Expand Up @@ -163,6 +165,11 @@ jest.mock('@/lib/code-reviews/action-required', () => {
};
});

jest.mock('@/lib/code-reviews/github-review-thread-resolution', () => ({
resolveAddressedGitHubReviewThreads: (...args: unknown[]) =>
mockResolveAddressedGitHubReviewThreads(...args),
}));

jest.mock('@/lib/constants', () => ({
APP_URL: 'https://test.kilo.ai',
}));
Expand Down Expand Up @@ -401,6 +408,11 @@ beforeEach(async () => {
footer.usage || footer.reviewGuidance?.used ? 'body with footer' : body
);
mockDisableCodeReviewForActionRequiredFailure.mockResolvedValue(undefined);
mockResolveAddressedGitHubReviewThreads.mockResolvedValue({
status: 'no-marker',
requestedCount: 0,
resolvedCount: 0,
});
({ POST } = await import('./route'));
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
type CodeReviewActionRequiredReason,
} from '@/lib/code-reviews/action-required';
import type { Owner } from '@/lib/code-reviews/core';
import { resolveAddressedGitHubReviewThreads } from '@/lib/code-reviews/github-review-thread-resolution';

/**
* Payload from the orchestrator DO (legacy format).
Expand All @@ -114,6 +115,7 @@ type CloudAgentNextCallbackPayload = {
terminalReason?: CodeReviewTerminalReason;
modelNotFoundRuntimeDiagnostics?: unknown;
failure?: unknown;
lastAssistantMessageText?: string;
lastSeenBranch?: string;
gateResult?: 'pass' | 'fail';
};
Expand Down Expand Up @@ -984,6 +986,8 @@ export async function POST(
const attemptId = callbackAttemptId || undefined;
const { status, sessionId, cliSessionId, errorMessage, terminalReason, gateResult, failure } =
normalizePayload(rawPayload);
const lastAssistantMessageText =
'lastAssistantMessageText' in rawPayload ? rawPayload.lastAssistantMessageText : undefined;
const executionId = 'executionId' in rawPayload ? rawPayload.executionId : undefined;

// Validate payload
Expand Down Expand Up @@ -1329,6 +1333,56 @@ export async function POST(
)
: undefined;

if (
status === 'completed' &&
integration &&
!isGitLab &&
integration.platform_installation_id &&
(integration.github_app_type || 'standard') === 'standard' &&
review.previous_summary_body &&
review.previous_summary_head_sha &&
CALLBACK_TOKEN_SECRET
) {
try {
const [repoOwner, repoName] = review.repo_full_name.split('/');
if (repoOwner && repoName) {
const result = await resolveAddressedGitHubReviewThreads({
installationId: integration.platform_installation_id,
appType: 'standard',
owner: repoOwner,
repo: repoName,
prNumber: review.pr_number,
reviewId,
expectedHeadSha: review.head_sha,
secret: CALLBACK_TOKEN_SECRET,
assistantMessageText: lastAssistantMessageText,
});

if (result.resolvedCount > 0) {
logExceptInTest('[code-review-status] Resolved addressed GitHub review threads', {
reviewId,
repoFullName: review.repo_full_name,
prNumber: review.pr_number,
resolvedCount: result.resolvedCount,
});
}
}
} catch (threadResolutionError) {
logExceptInTest(
'[code-review-status] Failed to resolve addressed GitHub review threads:',
threadResolutionError
);
captureException(threadResolutionError, {
tags: { source: 'code-review-status-thread-resolution' },
extra: {
reviewId,
repoFullName: review.repo_full_name,
prNumber: review.pr_number,
},
});
}
}

if (integration) {
try {
await updatePRGateCheck(
Expand Down
275 changes: 275 additions & 0 deletions apps/web/src/lib/code-reviews/github-review-thread-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
const mockGraphql = jest.fn();

jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => ({
graphql: mockGraphql,
})),
}));

import {
fetchGitHubReviewThreadResolutionCandidates,
GITHUB_REVIEW_THREAD_RESOLUTION_MARKER_PREFIX,
resolveAddressedGitHubReviewThreads,
} from './github-review-thread-resolution';

const SECRET = 'test-review-thread-resolution-secret';
const ROOT_BODY = [
'**WARNING:** The cache key omits the tenant id.',
'',
'This can leak cached data across tenants.',
'',
'---',
'Reply with `@kilocode-bot fix it` to have Kilo Code address this issue.',
].join('\n');

const BASE_PARAMS = {
appType: 'standard' as const,
owner: 'acme',
repo: 'widgets',
prNumber: 7,
reviewId: 'review-1',
expectedHeadSha: 'head-sha',
secret: SECRET,
};

beforeEach(() => {
mockGraphql.mockReset();
});

function threadNode(
overrides: Record<string, unknown> = {},
commentOverrides: Record<string, unknown> = {}
) {
return {
id: 'PRRT_thread_1',
isResolved: false,
isOutdated: false,
viewerCanResolve: true,
path: 'src/cache.ts',
line: 42,
comments: {
totalCount: 1,
nodes: [
{
id: 'PRRC_comment_1',
body: ROOT_BODY,
path: 'src/cache.ts',
line: 42,
viewerDidAuthor: true,
...commentOverrides,
},
],
},
...overrides,
};
}

function reviewThreadState(
options: {
state?: 'OPEN' | 'CLOSED' | 'MERGED';
headRefOid?: string;
nodes?: Array<Record<string, unknown> | null> | null;
hasNextPage?: boolean;
} = {}
) {
return {
repository: {
pullRequest: {
state: options.state ?? 'OPEN',
headRefOid: options.headRefOid ?? 'head-sha',
reviewThreads: {
pageInfo: { hasNextPage: options.hasNextPage ?? false },
nodes: options.nodes ?? [threadNode()],
},
},
},
};
}

function assistantMarker(entries: Array<{ id: string; token: string }>): string {
return `Review complete.\n${GITHUB_REVIEW_THREAD_RESOLUTION_MARKER_PREFIX}${JSON.stringify(entries)}`;
}

describe('github-review-thread-resolution', () => {
it('queries bounded candidates, validates the capability token, and resolves the exact thread', async () => {
mockGraphql
.mockResolvedValueOnce(reviewThreadState({ hasNextPage: true }))
.mockResolvedValueOnce(reviewThreadState())
.mockResolvedValueOnce({
resolveReviewThread: {
thread: {
id: 'PRRT_thread_1',
isResolved: true,
},
},
});

const candidates = await fetchGitHubReviewThreadResolutionCandidates({
...BASE_PARAMS,
token: 'installation-token',
});

expect(candidates).toHaveLength(1);
expect(candidates[0]).toMatchObject({
threadId: 'PRRT_thread_1',
path: 'src/cache.ts',
line: 42,
isOutdated: false,
body: ROOT_BODY,
});
expect(candidates[0].token).toMatch(/^[0-9a-f]{64}$/);
expect(mockGraphql.mock.calls[0][0]).toContain('reviewThreads(first: 100)');
expect(mockGraphql.mock.calls[0][1]).toEqual({
owner: 'acme',
repo: 'widgets',
number: 7,
});

const result = await resolveAddressedGitHubReviewThreads({
...BASE_PARAMS,
installationId: 'installation-1',
assistantMessageText: assistantMarker([
{ id: candidates[0].threadId, token: candidates[0].token },
]),
});

expect(result).toEqual({ status: 'resolved', requestedCount: 1, resolvedCount: 1 });
expect(mockGraphql.mock.calls[2][0]).toContain('resolveReviewThread');
expect(mockGraphql.mock.calls[2][1]).toEqual({ threadId: 'PRRT_thread_1' });
});

it.each([
{
name: 'rejects comments not authored by the viewer',
run: async () => {
mockGraphql.mockResolvedValueOnce(
reviewThreadState({ nodes: [threadNode({}, { viewerDidAuthor: false })] })
);
const candidates = await fetchGitHubReviewThreadResolutionCandidates({
...BASE_PARAMS,
token: 'installation-token',
});
expect(candidates).toEqual([]);
},
},
{
name: 'rejects threads with replies',
run: async () => {
mockGraphql.mockResolvedValueOnce(
reviewThreadState({ nodes: [threadNode({ comments: { totalCount: 2, nodes: [] } })] })
);
const candidates = await fetchGitHubReviewThreadResolutionCandidates({
...BASE_PARAMS,
token: 'installation-token',
});
expect(candidates).toEqual([]);
},
},
{
name: 'rejects closed pull requests',
run: async () => {
mockGraphql.mockResolvedValueOnce(reviewThreadState({ state: 'CLOSED' }));
const candidates = await fetchGitHubReviewThreadResolutionCandidates({
...BASE_PARAMS,
token: 'installation-token',
});
expect(candidates).toEqual([]);
},
},
{
name: 'rejects stale pull request heads before mutation',
run: async () => {
mockGraphql.mockResolvedValueOnce(reviewThreadState({ headRefOid: 'new-head' }));
const result = await resolveAddressedGitHubReviewThreads({
...BASE_PARAMS,
installationId: 'installation-1',
assistantMessageText: assistantMarker([{ id: 'PRRT_thread_1', token: '0'.repeat(64) }]),
});
expect(result).toEqual({
status: 'stale-pull-request',
requestedCount: 1,
resolvedCount: 0,
});
},
},
{
name: 'rejects stale comment bodies before mutation',
run: async () => {
mockGraphql.mockResolvedValueOnce(reviewThreadState());
const [candidate] = await fetchGitHubReviewThreadResolutionCandidates({
...BASE_PARAMS,
token: 'installation-token',
});
mockGraphql.mockResolvedValueOnce(
reviewThreadState({
nodes: [threadNode({}, { body: ROOT_BODY.replace('tenant id', 'workspace id') })],
})
);
const result = await resolveAddressedGitHubReviewThreads({
...BASE_PARAMS,
installationId: 'installation-1',
assistantMessageText: assistantMarker([
{ id: candidate.threadId, token: candidate.token },
]),
});
expect(result).toEqual({ status: 'invalid-request', requestedCount: 1, resolvedCount: 0 });
},
},
{
name: 'rejects malformed markers',
run: async () => {
const result = await resolveAddressedGitHubReviewThreads({
...BASE_PARAMS,
installationId: 'installation-1',
assistantMessageText: `Review complete.\n${GITHUB_REVIEW_THREAD_RESOLUTION_MARKER_PREFIX}{not-json}`,
});
expect(result).toEqual({ status: 'invalid-marker', requestedCount: 0, resolvedCount: 0 });
},
},
{
name: 'rejects tampered tokens before mutation',
run: async () => {
mockGraphql.mockResolvedValueOnce(reviewThreadState());
const result = await resolveAddressedGitHubReviewThreads({
...BASE_PARAMS,
installationId: 'installation-1',
assistantMessageText: assistantMarker([{ id: 'PRRT_thread_1', token: '0'.repeat(64) }]),
});
expect(result).toEqual({ status: 'invalid-request', requestedCount: 1, resolvedCount: 0 });
},
},
])('$name', async ({ run }) => {
await run();
const mutationCalls = mockGraphql.mock.calls.filter(call =>
String(call[0]).includes('resolveReviewThread')
);
expect(mutationCalls).toHaveLength(0);
});

it('throws when GitHub does not confirm a resolved mutation response', async () => {
mockGraphql
.mockResolvedValueOnce(reviewThreadState())
.mockResolvedValueOnce(reviewThreadState())
.mockResolvedValueOnce({
resolveReviewThread: {
thread: {
id: 'PRRT_thread_1',
isResolved: false,
},
},
});

const [candidate] = await fetchGitHubReviewThreadResolutionCandidates({
...BASE_PARAMS,
token: 'installation-token',
});

await expect(
resolveAddressedGitHubReviewThreads({
...BASE_PARAMS,
installationId: 'installation-1',
assistantMessageText: assistantMarker([{ id: candidate.threadId, token: candidate.token }]),
})
).rejects.toThrow('GitHub resolveReviewThread mutation did not confirm thread resolution');
});
});
Loading