Skip to content
Closed
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 @@ -237,6 +244,7 @@ function makeReview(overrides: Partial<CloudAgentCodeReview> = {}): CloudAgentCo
repository_review_instructions_truncated: false,
previous_summary_body: null,
previous_summary_head_sha: null,
github_review_thread_resolution_candidates: [],
model: null,
total_tokens_in: null,
total_tokens_out: null,
Expand Down Expand Up @@ -401,6 +409,7 @@ beforeEach(async () => {
footer.usage || footer.reviewGuidance?.used ? 'body with footer' : body
);
mockDisableCodeReviewForActionRequiredFailure.mockResolvedValue(undefined);
mockResolveAddressedGitHubReviewThreads.mockResolvedValue(0);
({ POST } = await import('./route'));
});

Expand Down Expand Up @@ -2701,6 +2710,93 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
});
});

describe('GitHub addressed review-thread resolution', () => {
const persistedCandidates = [{ threadId: 'PRRT_thread_1', rootBodySha256: 'a'.repeat(64) }];
const structuredOutput = { addressedReviewThreadIds: ['PRRT_thread_1'] };

it('passes native structured output and persisted candidates directly to the resolver', async () => {
mockResolveAddressedGitHubReviewThreads.mockResolvedValue(1);
mockGetCodeReviewById.mockResolvedValue(
makeReview({ github_review_thread_resolution_candidates: persistedCandidates })
);

const response = await POST(
makeRequest({ status: 'completed', lastAssistantMessageStructured: structuredOutput }),
makeParams(REVIEW_ID)
);

expect(response.status).toBe(200);
expect(mockResolveAddressedGitHubReviewThreads).toHaveBeenCalledWith({
installationId: 'inst-1',
owner: 'owner',
repo: 'repo',
prNumber: 1,
expectedHeadSha: 'abc123',
persistedCandidates,
structuredOutput,
});
});

it.each([
{
name: 'empty persisted candidates',
review: makeReview(),
integration: makeIntegration(),
},
{
name: 'GitLab review',
review: makeReview({
platform: 'gitlab',
platform_project_id: 42,
check_run_id: null,
github_review_thread_resolution_candidates: persistedCandidates,
}),
integration: makeIntegration(),
},
{
name: 'lite GitHub app',
review: makeReview({ github_review_thread_resolution_candidates: persistedCandidates }),
integration: makeIntegration({ github_app_type: 'lite' }),
},
{
name: 'missing GitHub installation',
review: makeReview({ github_review_thread_resolution_candidates: persistedCandidates }),
integration: makeIntegration({ platform_installation_id: null }),
},
])('skips resolver for $name', async ({ review, integration }) => {
mockGetCodeReviewById.mockResolvedValue(review);
mockGetIntegrationById.mockResolvedValue(integration);

await POST(
makeRequest({ status: 'completed', lastAssistantMessageStructured: structuredOutput }),
makeParams(REVIEW_ID)
);

expect(mockResolveAddressedGitHubReviewThreads).not.toHaveBeenCalled();
});

it('keeps resolver failures non-blocking', async () => {
const resolutionError = new Error('GitHub GraphQL failed');
mockResolveAddressedGitHubReviewThreads.mockRejectedValue(resolutionError);
mockGetCodeReviewById.mockResolvedValue(
makeReview({ github_review_thread_resolution_candidates: persistedCandidates })
);

const response = await POST(
makeRequest({ status: 'completed', lastAssistantMessageStructured: structuredOutput }),
makeParams(REVIEW_ID)
);

expect(response.status).toBe(200);
expect(mockCaptureException).toHaveBeenCalledWith(
resolutionError,
expect.objectContaining({
tags: { source: 'code-review-status-thread-resolution' },
})
);
});
});

describe('summary footer guidance', () => {
it('appends captured history to a completed GitHub summary', async () => {
const review = makeReview({
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,8 @@ type CloudAgentNextCallbackPayload = {
terminalReason?: CodeReviewTerminalReason;
modelNotFoundRuntimeDiagnostics?: unknown;
failure?: unknown;
lastAssistantMessageText?: string;
lastAssistantMessageStructured?: unknown;
lastSeenBranch?: string;
gateResult?: 'pass' | 'fail';
};
Expand Down Expand Up @@ -984,6 +987,10 @@ export async function POST(
const attemptId = callbackAttemptId || undefined;
const { status, sessionId, cliSessionId, errorMessage, terminalReason, gateResult, failure } =
normalizePayload(rawPayload);
const lastAssistantMessageStructured =
'lastAssistantMessageStructured' in rawPayload
? rawPayload.lastAssistantMessageStructured
: undefined;
const executionId = 'executionId' in rawPayload ? rawPayload.executionId : undefined;

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

if (
status === 'completed' &&
integration &&
!isGitLab &&
integration.platform_installation_id &&
(integration.github_app_type || 'standard') === 'standard' &&
review.github_review_thread_resolution_candidates.length > 0
) {
try {
const [repoOwner, repoName] = review.repo_full_name.split('/');
if (repoOwner && repoName) {
const resolvedCount = await resolveAddressedGitHubReviewThreads({
installationId: integration.platform_installation_id,
owner: repoOwner,
repo: repoName,
prNumber: review.pr_number,
expectedHeadSha: review.head_sha,
persistedCandidates: review.github_review_thread_resolution_candidates,
structuredOutput: lastAssistantMessageStructured,
});

if (resolvedCount > 0) {
logExceptInTest('[code-review-status] Resolved addressed GitHub review threads', {
reviewId,
repoFullName: review.repo_full_name,
prNumber: review.pr_number,
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
23 changes: 23 additions & 0 deletions apps/web/src/lib/code-reviews/db/code-reviews.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
updateCodeReviewAttemptForCallback,
findPreviousCompletedReview,
updateCodeReviewStatus,
updateGitHubReviewThreadResolutionCandidates,
} from './code-reviews';

const REPO = `test-org/session-continuation-${Date.now()}`;
Expand Down Expand Up @@ -438,6 +439,28 @@ describe('findPreviousCompletedReview', () => {
expect(review?.agentVersion).toBe('v2');
});

it('replaces GitHub review-thread resolution candidates with the exact snapshot', async () => {
const id = await createReview('sha-review-thread-candidates');

await updateGitHubReviewThreadResolutionCandidates(id, [
{ threadId: 'thread-1', rootBodySha256: 'a'.repeat(64) },
{ threadId: 'thread-2', rootBodySha256: 'b'.repeat(64) },
]);
await updateGitHubReviewThreadResolutionCandidates(id, [
{ threadId: 'thread-3', rootBodySha256: 'c'.repeat(64) },
]);

const [review] = await db
.select({
candidates: cloud_agent_code_reviews.github_review_thread_resolution_candidates,
})
.from(cloud_agent_code_reviews)
.where(eq(cloud_agent_code_reviews.id, id))
.limit(1);

expect(review?.candidates).toEqual([{ threadId: 'thread-3', rootBodySha256: 'c'.repeat(64) }]);
});

it('creates, links, lists, and updates code review attempts', async () => {
const reviewId = await createReview('sha-attempts');
const firstAttempt = await createCodeReviewAttempt({
Expand Down
26 changes: 25 additions & 1 deletion apps/web/src/lib/code-reviews/db/code-reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import { eq, and, asc, desc, count, ne, inArray, sql, sum, gte, isNull } from 'd
import { captureException } from '@sentry/nextjs';
import type { CreateReviewParams, CodeReviewStatus, ListReviewsParams, Owner } from '../core';
import type { CloudAgentCodeReview, CloudAgentCodeReviewAttempt } from '@kilocode/db/schema';
import type { CodeReviewTerminalReason } from '@kilocode/db/schema-types';
import type {
CodeReviewTerminalReason,
GitHubReviewThreadResolutionCandidateState,
} from '@kilocode/db/schema-types';
import { isCodeReviewActionRequiredReason } from '../action-required-shared';
import {
activeCodeReviewWorkCondition,
Expand Down Expand Up @@ -1126,6 +1129,27 @@ export async function updatePreviousReviewSummary(
}
}

export async function updateGitHubReviewThreadResolutionCandidates(
reviewId: string,
candidates: GitHubReviewThreadResolutionCandidateState[]
): Promise<void> {
try {
await db
.update(cloud_agent_code_reviews)
.set({
github_review_thread_resolution_candidates: candidates,
updated_at: new Date().toISOString(),
})
.where(eq(cloud_agent_code_reviews.id, reviewId));
} catch (error) {
captureException(error, {
tags: { operation: 'updateGitHubReviewThreadResolutionCandidates' },
extra: { reviewId, candidateCount: candidates.length },
});
throw error;
}
}

/**
* Updates REVIEW.md usage metadata for a code review.
*/
Expand Down
Loading