From 7bac70b7b880765939996e24c8f52724f97871c4 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 16 Jun 2026 18:38:57 +0300 Subject: [PATCH 1/2] fix(review): launch fix sessions --- .../review/[reviewId]/route.test.ts | 304 ++++++++++++++++++ .../review/[reviewId]/route.ts | 105 ++++-- .../code-reviews/prompts/fix-review-prompt.ts | 90 ++++++ 3 files changed, 464 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts create mode 100644 apps/web/src/lib/code-reviews/prompts/fix-review-prompt.ts diff --git a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts new file mode 100644 index 000000000..48609b3a7 --- /dev/null +++ b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts @@ -0,0 +1,304 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { NextRequest } from 'next/server'; +import { TRPCError } from '@trpc/server'; +import { + DEFAULT_CODE_REVIEW_MODE, + DEFAULT_CODE_REVIEW_MODEL, +} from '@/lib/code-reviews/core/constants'; +import { buildFixReviewPrompt } from '@/lib/code-reviews/prompts/fix-review-prompt'; + +type TrpcContextFixture = { + user: { + id: string; + }; +}; + +type ReviewFixture = { + id: string; + owned_by_organization_id: string | null; + repo_full_name: string; + pr_url: string; + platform: string; + model: string | null; +}; + +type ReviewResult = + | { + success: true; + review: ReviewFixture; + attempts: unknown[]; + } + | { + success: false; + error: string; + }; + +type PrepareSessionInput = { + githubRepo: string; + prompt: string; + mode: string; + model: string; + autoInitiate: boolean; + autoCommit: boolean; +}; + +type PrepareSessionOutput = { + kiloSessionId: string; + cloudAgentSessionId: string; +}; + +const mockCreateTRPCContext = jest.fn<() => Promise>(); +const mockCodeReviewsGet = jest.fn<(input: { reviewId: string }) => Promise>(); +const mockPersonalPrepareSession = + jest.fn<(input: PrepareSessionInput) => Promise>(); +const mockOrganizationPrepareSession = + jest.fn< + (input: PrepareSessionInput & { organizationId: string }) => Promise + >(); + +const mockCaller = { + codeReviews: { + get: mockCodeReviewsGet, + }, + cloudAgentNext: { + prepareSession: mockPersonalPrepareSession, + }, + organizations: { + cloudAgentNext: { + prepareSession: mockOrganizationPrepareSession, + }, + }, +}; +const mockCreateCaller = jest.fn((_: TrpcContextFixture) => mockCaller); +const mockCreateCallerFactory = jest.fn(() => mockCreateCaller); + +jest.mock('@/lib/trpc/init', () => ({ + createTRPCContext: () => mockCreateTRPCContext(), + createCallerFactory: () => mockCreateCallerFactory(), +})); + +jest.mock('@/routers/root-router', () => ({ + rootRouter: {}, +})); + +let getRoute: typeof import('./route').GET; + +const REVIEW_ID = '00000000-0000-4000-8000-000000000001'; +const ORG_ID = '11111111-1111-4111-8111-111111111111'; +const PR_URL = 'https://github.com/owner/repo/pull/123'; +const PERSONAL_KILO_SESSION_ID = 'ses_12345678901234567890123456'; +const ORG_KILO_SESSION_ID = 'ses_abcdefabcdefabcdefabcdefab'; + +function makeReview(overrides: Partial = {}): ReviewFixture { + return { + id: REVIEW_ID, + owned_by_organization_id: null, + repo_full_name: 'owner/repo', + pr_url: PR_URL, + platform: 'github', + model: 'anthropic/custom-model', + ...overrides, + }; +} + +function mockSuccessfulReview(overrides: Partial = {}) { + mockCodeReviewsGet.mockResolvedValue({ + success: true, + review: makeReview(overrides), + attempts: [], + }); +} + +function makeRequest(reviewId = REVIEW_ID): NextRequest { + return new NextRequest(`https://kilo.test/cloud-agent-fork/review/${reviewId}`); +} + +function makeContext(reviewId = REVIEW_ID) { + return { params: Promise.resolve({ reviewId }) }; +} + +async function requestReview(reviewId = REVIEW_ID) { + return getRoute(makeRequest(reviewId), makeContext(reviewId)); +} + +function getRedirectUrl(response: Response): URL { + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location ?? ''); +} + +function expectErrorRedirect(response: Response, error: string) { + const redirectUrl = getRedirectUrl(response); + expect(`${redirectUrl.pathname}${redirectUrl.search}`).toBe(`/code-reviews?error=${error}`); +} + +function expectNoSessionCreation() { + expect(mockPersonalPrepareSession).not.toHaveBeenCalled(); + expect(mockOrganizationPrepareSession).not.toHaveBeenCalled(); +} + +describe('GET /cloud-agent-fork/review/[reviewId]', () => { + beforeAll(async () => { + ({ GET: getRoute } = await import('./route')); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockCreateTRPCContext.mockResolvedValue({ user: { id: 'user_1' } }); + mockSuccessfulReview(); + mockPersonalPrepareSession.mockResolvedValue({ + kiloSessionId: PERSONAL_KILO_SESSION_ID, + cloudAgentSessionId: 'agent_personal', + }); + mockOrganizationPrepareSession.mockResolvedValue({ + kiloSessionId: ORG_KILO_SESSION_ID, + cloudAgentSessionId: 'agent_org', + }); + }); + + it('rejects invalid UUIDs without authenticating, loading reviews, or creating sessions', async () => { + const response = await requestReview('not-a-uuid'); + + expectErrorRedirect(response, 'invalid_review_id'); + expect(mockCreateTRPCContext).not.toHaveBeenCalled(); + expect(mockCodeReviewsGet).not.toHaveBeenCalled(); + expectNoSessionCreation(); + }); + + it('redirects signed-out requests to sign in with the compatibility callback path', async () => { + mockCreateTRPCContext.mockRejectedValue( + new TRPCError({ code: 'UNAUTHORIZED', message: 'not signed in' }) + ); + + const response = await requestReview(); + const redirectUrl = getRedirectUrl(response); + + expect(response.status).toBe(307); + expect(redirectUrl.pathname).toBe('/users/sign_in'); + expect(redirectUrl.searchParams.get('callbackPath')).toBe( + `/cloud-agent-fork/review/${REVIEW_ID}` + ); + expect(mockCodeReviewsGet).not.toHaveBeenCalled(); + expectNoSessionCreation(); + }); + + it('starts personal review fix sessions with a free-text Cloud Agent Next prompt', async () => { + const response = await requestReview(); + const redirectUrl = getRedirectUrl(response); + + expect(mockCodeReviewsGet).toHaveBeenCalledWith({ reviewId: REVIEW_ID }); + expect(mockPersonalPrepareSession).toHaveBeenCalledWith({ + githubRepo: 'owner/repo', + prompt: buildFixReviewPrompt(PR_URL), + mode: DEFAULT_CODE_REVIEW_MODE, + model: 'anthropic/custom-model', + autoInitiate: true, + autoCommit: false, + }); + expect(mockOrganizationPrepareSession).not.toHaveBeenCalled(); + + const input = mockPersonalPrepareSession.mock.calls[0][0] as Record; + expect(input).not.toHaveProperty('upstreamBranch'); + expect(input).not.toHaveProperty('initialPayload'); + + expect(response.status).toBe(303); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(`${redirectUrl.pathname}${redirectUrl.search}`).toBe( + `/cloud/chat?sessionId=${PERSONAL_KILO_SESSION_ID}` + ); + }); + + it('starts organization review fix sessions on the organization chat path with model fallback', async () => { + mockSuccessfulReview({ owned_by_organization_id: ORG_ID, model: null }); + + const response = await requestReview(); + const redirectUrl = getRedirectUrl(response); + + expect(mockPersonalPrepareSession).not.toHaveBeenCalled(); + expect(mockOrganizationPrepareSession).toHaveBeenCalledWith({ + githubRepo: 'owner/repo', + prompt: buildFixReviewPrompt(PR_URL), + mode: DEFAULT_CODE_REVIEW_MODE, + model: DEFAULT_CODE_REVIEW_MODEL, + autoInitiate: true, + autoCommit: false, + organizationId: ORG_ID, + }); + + const input = mockOrganizationPrepareSession.mock.calls[0][0] as Record; + expect(input).not.toHaveProperty('upstreamBranch'); + expect(input).not.toHaveProperty('initialPayload'); + + expect(response.status).toBe(303); + expect(`${redirectUrl.pathname}${redirectUrl.search}`).toBe( + `/organizations/${ORG_ID}/cloud/chat?sessionId=${ORG_KILO_SESSION_ID}` + ); + }); + + it('expands the fix-review workflow into ordinary prompt text', () => { + const prompt = buildFixReviewPrompt(PR_URL); + + expect(prompt).toContain(`GitHub PR URL: ${PR_URL}`); + expect(prompt).toContain(`gh pr checkout "${PR_URL}"`); + expect(prompt).toContain('gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate'); + expect(prompt).toContain('gh api user --jq'); + expect(prompt).toContain("reactions -f content='+1'"); + expect(prompt).toContain('Create one commit per fixed review comment'); + expect(prompt).toContain('git push'); + expect(prompt).toContain('summary table'); + expect(prompt).toContain('in_reply_to'); + expect(prompt).not.toContain('$ARGUMENTS'); + expect(prompt).not.toContain('/fix-review'); + }); + + it('redirects missing reviews to review_not_found without creating a session', async () => { + mockCodeReviewsGet.mockRejectedValue( + new TRPCError({ code: 'NOT_FOUND', message: 'review not found' }) + ); + + const response = await requestReview(); + + expectErrorRedirect(response, 'review_not_found'); + expectNoSessionCreation(); + }); + + it.each(['UNAUTHORIZED', 'FORBIDDEN'] as const)( + 'redirects authenticated lookup %s errors to access_denied', + async code => { + mockCodeReviewsGet.mockRejectedValue(new TRPCError({ code, message: 'denied' })); + + const response = await requestReview(); + + expectErrorRedirect(response, 'access_denied'); + expectNoSessionCreation(); + } + ); + + it('redirects failed review result envelopes without creating a session', async () => { + mockCodeReviewsGet.mockResolvedValue({ success: false, error: 'lookup failed' }); + + const response = await requestReview(); + + expectErrorRedirect(response, 'fix_session_failed'); + expectNoSessionCreation(); + }); + + it('rejects non-GitHub reviews without creating a session', async () => { + mockSuccessfulReview({ platform: 'gitlab' }); + + const response = await requestReview(); + + expectErrorRedirect(response, 'unsupported_platform'); + expectNoSessionCreation(); + }); + + it('redirects generic preparation failures to fix_session_failed', async () => { + mockPersonalPrepareSession.mockRejectedValue(new Error('worker unavailable')); + + const response = await requestReview(); + + expectErrorRedirect(response, 'fix_session_failed'); + expect(mockPersonalPrepareSession).toHaveBeenCalledTimes(1); + expect(mockOrganizationPrepareSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts index b400fbf70..9ec6c9634 100644 --- a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts +++ b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts @@ -1,5 +1,10 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { buildFixReviewPrompt } from '@/lib/code-reviews/prompts/fix-review-prompt'; +import { + DEFAULT_CODE_REVIEW_MODE, + DEFAULT_CODE_REVIEW_MODEL, +} from '@/lib/code-reviews/core/constants'; import { createCallerFactory, createTRPCContext } from '@/lib/trpc/init'; import { rootRouter } from '@/routers/root-router'; import { TRPCError } from '@trpc/server'; @@ -11,46 +16,23 @@ type RouteContext = { params: Promise<{ reviewId: string }>; }; -/** - * Fork a code review session and redirect to cloud chat. - * This route: - * 1. Validates the reviewId is a valid UUID - * 2. Calls forkForReview to create a new session from the review's CLI session - * 3. Redirects to /cloud/chat (or org variant) with the new session ID - */ +function redirectToError(origin: string, error: string) { + return NextResponse.redirect(new URL(`/code-reviews?error=${error}`, origin)); +} + export async function GET(request: NextRequest, context: RouteContext) { const url = new URL(request.url); const { reviewId } = await context.params; - // Validate UUID format const parseResult = z.uuid().safeParse(reviewId); if (!parseResult.success) { - return NextResponse.redirect(new URL('/code-reviews?error=invalid_review_id', url.origin)); + return redirectToError(url.origin, 'invalid_review_id'); } + let ctx: Awaited>; + try { - const ctx = await createTRPCContext(); - const caller = createCaller(ctx); - - // Use the forkForReview procedure which handles: - // - Review access validation (personal or org) - // - CLI session lookup from review - // - Blob copying - // - New session creation with correct org context - const newSession = await caller.cliSessions.forkForReview({ - review_id: reviewId, - created_on_platform: 'cloud-web-review-fix', - }); - - // Redirect to cloud chat with new session - // Use org path if session belongs to an org - const basePath = newSession.organization_id - ? `/organizations/${newSession.organization_id}/cloud/chat` - : '/cloud/chat'; - - return NextResponse.redirect( - new URL(`${basePath}?sessionId=${newSession.session_id}`, url.origin) - ); + ctx = await createTRPCContext(); } catch (error) { if (error instanceof TRPCError) { if (error.code === 'UNAUTHORIZED') { @@ -58,13 +40,66 @@ export async function GET(request: NextRequest, context: RouteContext) { signInUrl.searchParams.set('callbackPath', `/cloud-agent-fork/review/${reviewId}`); return NextResponse.redirect(signInUrl); } + } + return redirectToError(url.origin, 'fix_session_failed'); + } + + const caller = createCaller(ctx); + + let reviewResult: Awaited>; + + try { + reviewResult = await caller.codeReviews.get({ reviewId }); + } catch (error) { + if (error instanceof TRPCError) { if (error.code === 'NOT_FOUND') { - return NextResponse.redirect(new URL('/code-reviews?error=session_not_found', url.origin)); + return redirectToError(url.origin, 'review_not_found'); } - if (error.code === 'FORBIDDEN') { - return NextResponse.redirect(new URL('/code-reviews?error=access_denied', url.origin)); + if (error.code === 'UNAUTHORIZED' || error.code === 'FORBIDDEN') { + return redirectToError(url.origin, 'access_denied'); } } - return NextResponse.redirect(new URL('/code-reviews?error=fork_failed', url.origin)); + return redirectToError(url.origin, 'fix_session_failed'); + } + + if (!reviewResult.success) { + return redirectToError(url.origin, 'fix_session_failed'); + } + + const { review } = reviewResult; + + if (review.platform !== 'github') { + return redirectToError(url.origin, 'unsupported_platform'); + } + + const sessionInput = { + githubRepo: review.repo_full_name, + prompt: buildFixReviewPrompt(review.pr_url), + mode: DEFAULT_CODE_REVIEW_MODE, + model: review.model ?? DEFAULT_CODE_REVIEW_MODEL, + autoInitiate: true, + autoCommit: false, + }; + + try { + const organizationId = review.owned_by_organization_id; + const session = organizationId + ? await caller.organizations.cloudAgentNext.prepareSession({ + ...sessionInput, + organizationId, + }) + : await caller.cloudAgentNext.prepareSession(sessionInput); + + const chatUrl = new URL( + organizationId ? `/organizations/${organizationId}/cloud/chat` : '/cloud/chat', + url.origin + ); + chatUrl.searchParams.set('sessionId', session.kiloSessionId); + + const response = NextResponse.redirect(chatUrl, 303); + response.headers.set('Cache-Control', 'no-store'); + return response; + } catch { + return redirectToError(url.origin, 'fix_session_failed'); } } diff --git a/apps/web/src/lib/code-reviews/prompts/fix-review-prompt.ts b/apps/web/src/lib/code-reviews/prompts/fix-review-prompt.ts new file mode 100644 index 000000000..952c305c0 --- /dev/null +++ b/apps/web/src/lib/code-reviews/prompts/fix-review-prompt.ts @@ -0,0 +1,90 @@ +function escapeForDoubleQuotedShell(value: string): string { + return value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('$', '\\$') + .replaceAll('`', '\\`'); +} + +export function buildFixReviewPrompt(prUrl: string): string { + const checkoutUrl = escapeForDoubleQuotedShell(prUrl); + + return [ + 'Process this GitHub PR review-fix workflow. Do not require input.', + '', + '## Target', + '', + `GitHub PR URL: ${prUrl}`, + '', + '## Steps', + '', + '1. Check out the target PR before reading or editing files:', + '', + ' ```', + ` gh pr checkout "${checkoutUrl}"`, + ' ```', + '', + '2. Determine the target PR number, URL, owner, and repo:', + '', + ' ```', + " gh pr view --json number,url --jq '{number: .number, url: .url}'", + " gh repo view --json owner,name --jq '{owner: .owner.login, repo: .name}'", + ' ```', + '', + '3. Fetch all review comments using pagination:', + '', + ' ```', + ' gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate', + ' ```', + '', + '4. Fetch PR details to know who the author is, and fetch the authenticated GitHub user:', + '', + ' ```', + " gh api repos/{owner}/{repo}/pulls/{number} --jq '.user.login'", + " gh api user --jq '.login'", + ' ```', + '', + '5. Filter qualifying review comments:', + '', + ' - Comments not written by the PR author qualify when they are either not resolved (`resolved` is not true in the review thread) or missing a reply from the PR author (check `in_reply_to_id` chains).', + ' - Comments written by the PR author qualify only when the authenticated GitHub user is that PR author and the review thread is still not resolved (`resolved` is not true).', + ' - Skip PR-author comments in every other case.', + '', + '6. For each qualifying review comment:', + '', + ' a. Read the referenced file and lines to understand the context.', + '', + ' b. Decide if the review feedback is valid (the reviewer has a point and a code change is warranted) or not valid (the code is already correct, or the suggestion is wrong or a matter of taste with no clear improvement).', + '', + ' c. If valid:', + ' - Apply only the relevant fix to the code.', + ' - Create one commit per fixed review comment.', + ' - Reply to the review comment using `gh api` with a very short message referencing the fix commit. Example: `Fixed in abc1234.` or `Done in abc1234.`', + ' - React with thumbs up on the review comment:', + ' ```', + " gh api repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions -f content='+1'", + ' ```', + '', + ' d. If not valid:', + ' - Reply to the review comment with a very short, polite explanation of why no change is needed. Keep it to 1-2 sentences with simple words. No jargon, no essays.', + ' - Do not react with thumbs down on the review comment.', + '', + '7. After processing all comments, push all commits at once:', + '', + ' ```', + ' git push', + ' ```', + '', + '8. Print a short summary table: comment, status (fixed / declined), commit or reason.', + '', + '## Rules', + '', + '- Commit messages: use conventional commit format with scope, e.g. `fix(review): remove unused var`, `refactor(auth): simplify check`, `feat(cloud-agent): add timeout`. Pick the right type (`fix`, `refactor`, `feat`, `style`, `docs`, `test`, `chore`) and a short scope matching the area of code changed. Keep the subject line under 50 characters total, lowercase, no period.', + '- Replies: max 2 sentences, plain english a 10 year old would understand.', + '- Do NOT batch unrelated fixes into one commit. One commit per review comment.', + '- Do NOT modify code that the review comment is not about.', + '- If you are unsure whether feedback is valid, treat it as valid and fix it.', + '- Use `gh api` for all GitHub interactions (replies, reactions). Do NOT use `gh pr review`.', + '- To reply to a review comment, POST to `repos/{owner}/{repo}/pulls/{number}/comments` with `in_reply_to` set to the original comment id and `body` set to your reply text.', + ].join('\n'); +} From a0a2b5e19deac2793993c0458c4577536e300c3c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 16 Jun 2026 18:51:01 +0300 Subject: [PATCH 2/2] fix(review): satisfy route lint --- .../cloud-agent-fork/review/[reviewId]/route.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts index 48609b3a7..5301a68b4 100644 --- a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts +++ b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts @@ -47,6 +47,12 @@ type PrepareSessionOutput = { cloudAgentSessionId: string; }; +type RouteContext = { + params: Promise<{ reviewId: string }>; +}; + +type RouteGet = (request: NextRequest, context: RouteContext) => Promise; + const mockCreateTRPCContext = jest.fn<() => Promise>(); const mockCodeReviewsGet = jest.fn<(input: { reviewId: string }) => Promise>(); const mockPersonalPrepareSession = @@ -81,7 +87,7 @@ jest.mock('@/routers/root-router', () => ({ rootRouter: {}, })); -let getRoute: typeof import('./route').GET; +let getRoute: RouteGet; const REVIEW_ID = '00000000-0000-4000-8000-000000000001'; const ORG_ID = '11111111-1111-4111-8111-111111111111'; @@ -113,7 +119,7 @@ function makeRequest(reviewId = REVIEW_ID): NextRequest { return new NextRequest(`https://kilo.test/cloud-agent-fork/review/${reviewId}`); } -function makeContext(reviewId = REVIEW_ID) { +function makeContext(reviewId = REVIEW_ID): RouteContext { return { params: Promise.resolve({ reviewId }) }; }