diff --git a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx index 86e8e635bd..6965a3b7bd 100644 --- a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx +++ b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx @@ -5,12 +5,13 @@ import { toast } from 'sonner'; import { ReviewConfigForm } from '@/components/code-reviews/ReviewConfigForm'; import { CodeReviewActionRequiredAlert } from '@/components/code-reviews/CodeReviewActionRequiredAlert'; import { CodeReviewJobsCard } from '@/components/code-reviews/CodeReviewJobsCard'; +import { ReviewMemoryPanel } from '@/components/code-reviews/ReviewMemoryPanel'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { SetPageTitle } from '@/components/SetPageTitle'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Rocket, ExternalLink, Settings2, ListChecks } from 'lucide-react'; +import { Brain, Rocket, ExternalLink, Settings2, ListChecks } from 'lucide-react'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; @@ -20,6 +21,7 @@ import { GitLabLogo } from '@/components/auth/GitLabLogo'; import { GitHubLogo } from '@/components/auth/GitHubLogo'; type Platform = 'github' | 'gitlab'; +type InnerTab = 'config' | 'jobs' | 'memory'; type ReviewAgentPageClientProps = { userId: string; @@ -27,12 +29,14 @@ type ReviewAgentPageClientProps = { successMessage?: string; errorMessage?: string; initialPlatform?: Platform; + initialTab?: InnerTab; }; export function ReviewAgentPageClient({ successMessage, errorMessage, initialPlatform = 'github', + initialTab = 'config', }: ReviewAgentPageClientProps) { const trpc = useTRPC(); const router = useRouter(); @@ -162,8 +166,8 @@ export function ReviewAgentPageClient({ )} {/* GitHub Configuration Tabs */} - - + + Config @@ -176,6 +180,14 @@ export function ReviewAgentPageClient({ Jobs + + + Memory + @@ -196,6 +208,10 @@ export function ReviewAgentPageClient({ )} + + + + @@ -226,8 +242,8 @@ export function ReviewAgentPageClient({ )} {/* GitLab Configuration Tabs */} - - + + Config @@ -240,6 +256,14 @@ export function ReviewAgentPageClient({ Jobs + + + Memory + @@ -275,6 +299,10 @@ export function ReviewAgentPageClient({ )} + + + + diff --git a/apps/web/src/app/(app)/code-reviews/page.tsx b/apps/web/src/app/(app)/code-reviews/page.tsx index eaf6912382..2e4319bf8a 100644 --- a/apps/web/src/app/(app)/code-reviews/page.tsx +++ b/apps/web/src/app/(app)/code-reviews/page.tsx @@ -2,13 +2,14 @@ import { getUserFromAuthOrRedirect } from '@/lib/user/server'; import { ReviewAgentPageClient } from './ReviewAgentPageClient'; type ReviewAgentPageProps = { - searchParams: Promise<{ success?: string; error?: string; platform?: string }>; + searchParams: Promise<{ success?: string; error?: string; platform?: string; tab?: string }>; }; export default async function PersonalReviewAgentPage({ searchParams }: ReviewAgentPageProps) { const search = await searchParams; const user = await getUserFromAuthOrRedirect('/users/sign_in?callbackPath=/code-reviews'); const platform = search.platform === 'gitlab' ? 'gitlab' : 'github'; + const tab = search.tab === 'memory' || search.tab === 'jobs' ? search.tab : 'config'; return ( ); } diff --git a/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx index 42fd1e1072..8152853c00 100644 --- a/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx @@ -5,12 +5,13 @@ import { toast } from 'sonner'; import { ReviewConfigForm } from '@/components/code-reviews/ReviewConfigForm'; import { CodeReviewActionRequiredAlert } from '@/components/code-reviews/CodeReviewActionRequiredAlert'; import { CodeReviewJobsCard } from '@/components/code-reviews/CodeReviewJobsCard'; +import { ReviewMemoryPanel } from '@/components/code-reviews/ReviewMemoryPanel'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { SetPageTitle } from '@/components/SetPageTitle'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Rocket, ExternalLink, Settings2, ListChecks } from 'lucide-react'; +import { Brain, Rocket, ExternalLink, Settings2, ListChecks } from 'lucide-react'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; @@ -19,6 +20,7 @@ import { GitLabLogo } from '@/components/auth/GitLabLogo'; import { GitHubLogo } from '@/components/auth/GitHubLogo'; type Platform = 'github' | 'gitlab'; +type InnerTab = 'config' | 'jobs' | 'memory'; type ReviewAgentPageClientProps = { organizationId: string; @@ -26,6 +28,7 @@ type ReviewAgentPageClientProps = { successMessage?: string; errorMessage?: string; initialPlatform?: Platform; + initialTab?: InnerTab; }; export function ReviewAgentPageClient({ @@ -34,6 +37,7 @@ export function ReviewAgentPageClient({ successMessage, errorMessage, initialPlatform = 'github', + initialTab = 'config', }: ReviewAgentPageClientProps) { const trpc = useTRPC(); const router = useRouter(); @@ -175,8 +179,8 @@ export function ReviewAgentPageClient({ )} {/* GitHub Configuration Tabs */} - - + + Config @@ -189,6 +193,14 @@ export function ReviewAgentPageClient({ Jobs + + + Memory + @@ -209,6 +221,10 @@ export function ReviewAgentPageClient({ )} + + + + @@ -242,8 +258,8 @@ export function ReviewAgentPageClient({ )} {/* GitLab Configuration Tabs */} - - + + Config @@ -256,6 +272,14 @@ export function ReviewAgentPageClient({ Jobs + + + Memory + @@ -292,6 +316,10 @@ export function ReviewAgentPageClient({ )} + + + + diff --git a/apps/web/src/app/(app)/organizations/[id]/code-reviews/page.tsx b/apps/web/src/app/(app)/organizations/[id]/code-reviews/page.tsx index 01b2b82ee4..d3c08667f5 100644 --- a/apps/web/src/app/(app)/organizations/[id]/code-reviews/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/code-reviews/page.tsx @@ -3,12 +3,13 @@ import { ReviewAgentPageClient } from './ReviewAgentPageClient'; type ReviewAgentPageProps = { params: Promise<{ id: string }>; - searchParams: Promise<{ success?: string; error?: string; platform?: string }>; + searchParams: Promise<{ success?: string; error?: string; platform?: string; tab?: string }>; }; export default async function ReviewAgentPage({ params, searchParams }: ReviewAgentPageProps) { const search = await searchParams; const platform = search.platform === 'gitlab' ? 'gitlab' : 'github'; + const tab = search.tab === 'memory' || search.tab === 'jobs' ? search.tab : 'config'; return ( )} /> diff --git a/apps/web/src/app/api/cron/cleanup-review-memory/route.test.ts b/apps/web/src/app/api/cron/cleanup-review-memory/route.test.ts new file mode 100644 index 0000000000..39b8c9b9d7 --- /dev/null +++ b/apps/web/src/app/api/cron/cleanup-review-memory/route.test.ts @@ -0,0 +1,70 @@ +import { NextRequest } from 'next/server'; + +const mockPruneExpiredReviewMemoryData = jest.fn(); + +jest.mock('@/lib/config.server', () => ({ + CRON_SECRET: 'cron-secret', +})); + +jest.mock('@/lib/code-reviews/review-memory/db', () => ({ + pruneExpiredReviewMemoryData: () => mockPruneExpiredReviewMemoryData(), +})); + +import { GET } from './route'; + +function makeRequest(headers?: Record) { + return new NextRequest('http://localhost:3000/api/cron/cleanup-review-memory', { + method: 'GET', + headers, + }); +} + +describe('GET /api/cron/cleanup-review-memory', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects requests without cron authorization', async () => { + const response = await GET(makeRequest()); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); + expect(mockPruneExpiredReviewMemoryData).not.toHaveBeenCalled(); + }); + + it('rejects requests with invalid cron authorization', async () => { + const response = await GET(makeRequest({ authorization: 'Bearer wrong-secret' })); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); + expect(mockPruneExpiredReviewMemoryData).not.toHaveBeenCalled(); + }); + + it('prunes review memory data when authorized', async () => { + mockPruneExpiredReviewMemoryData.mockResolvedValue({ + cutoff: '2026-05-14T00:00:00.000Z', + proposalsDeleted: 1, + aggregationRunsDeleted: 2, + feedbackEventsDeleted: 3, + subjectsDeleted: 4, + aggregationStatesDeleted: 5, + }); + + const response = await GET(makeRequest({ authorization: 'Bearer cron-secret' })); + + expect(response.status).toBe(200); + expect(mockPruneExpiredReviewMemoryData).toHaveBeenCalledTimes(1); + await expect(response.json()).resolves.toEqual({ + success: true, + summary: { + cutoff: '2026-05-14T00:00:00.000Z', + proposalsDeleted: 1, + aggregationRunsDeleted: 2, + feedbackEventsDeleted: 3, + subjectsDeleted: 4, + aggregationStatesDeleted: 5, + }, + timestamp: expect.any(String), + }); + }); +}); diff --git a/apps/web/src/app/api/cron/cleanup-review-memory/route.ts b/apps/web/src/app/api/cron/cleanup-review-memory/route.ts new file mode 100644 index 0000000000..f0c6b59b23 --- /dev/null +++ b/apps/web/src/app/api/cron/cleanup-review-memory/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; + +import { CRON_SECRET } from '@/lib/config.server'; +import { pruneExpiredReviewMemoryData } from '@/lib/code-reviews/review-memory/db'; +import { sentryLogger } from '@/lib/utils.server'; + +if (!CRON_SECRET) { + throw new Error('CRON_SECRET is not configured in environment variables'); +} + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + const expectedAuth = `Bearer ${CRON_SECRET}`; + if (authHeader !== expectedAuth) { + sentryLogger( + 'cron', + 'warning' + )( + 'SECURITY: Invalid CRON job authorization attempt: ' + + (authHeader ? 'Invalid authorization header' : 'Missing authorization header') + ); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const summary = await pruneExpiredReviewMemoryData(); + + return NextResponse.json( + { + success: true, + summary, + timestamp: new Date().toISOString(), + }, + { status: 200 } + ); +} diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts index 05ebde1ad1..09a4d584bb 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts @@ -75,6 +75,10 @@ const mockAppendReviewSummaryFooter = jest.fn(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockRetryReviewFresh = jest.fn(); // eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockSyncGitHubReviewMemorySubjects = jest.fn(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockSyncGitLabReviewMemorySubjects = jest.fn(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any const mockDisableCodeReviewForActionRequiredFailure = jest.fn(); // --- Module mocks --- @@ -144,6 +148,13 @@ jest.mock('@/lib/code-reviews/summary/usage-footer', () => ({ appendReviewSummaryFooter: (...args: unknown[]) => mockAppendReviewSummaryFooter(...args), })); +jest.mock('@/lib/code-reviews/review-memory/sync-subjects', () => ({ + syncGitHubReviewMemorySubjects: (...args: unknown[]) => + mockSyncGitHubReviewMemorySubjects(...args), + syncGitLabReviewMemorySubjects: (...args: unknown[]) => + mockSyncGitLabReviewMemorySubjects(...args), +})); + jest.mock('@/lib/code-reviews/action-required', () => { const actual = jest.requireActual>('@/lib/code-reviews/action-required'); return { @@ -345,6 +356,8 @@ beforeEach(async () => { mockUpdateCodeReviewUsage.mockResolvedValue(undefined); mockUpdateCodeReviewStatusIfNonTerminal.mockResolvedValue(true); mockAppendReviewSummaryFooter.mockReturnValue('body with footer'); + mockSyncGitHubReviewMemorySubjects.mockResolvedValue({ summarySynced: true, inlineSynced: 0 }); + mockSyncGitLabReviewMemorySubjects.mockResolvedValue({ summarySynced: true, inlineSynced: 0 }); mockDisableCodeReviewForActionRequiredFailure.mockResolvedValue(undefined); ({ POST } = await import('./route')); }); @@ -1874,6 +1887,11 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { 'body with footer', 'standard' ); + expect(mockSyncGitHubReviewMemorySubjects).toHaveBeenCalledWith({ + review, + installationId: 'inst-1', + appType: 'standard', + }); }); it('updates completed GitLab summary with REVIEW.md guidance metadata when used', async () => { @@ -1910,6 +1928,11 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { 'body with footer', 'https://gitlab.com' ); + expect(mockSyncGitLabReviewMemorySubjects).toHaveBeenCalledWith({ + review, + accessToken: 'mock-token', + instanceUrl: 'https://gitlab.com', + }); }); it('updates guidance footer when usage data is unavailable', async () => { @@ -1947,6 +1970,11 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { expect(mockAppendReviewSummaryFooter).not.toHaveBeenCalled(); expect(mockUpdateKiloReviewComment).not.toHaveBeenCalled(); + expect(mockSyncGitHubReviewMemorySubjects).toHaveBeenCalledWith({ + review, + installationId: 'inst-1', + appType: 'standard', + }); }); }); }); diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts index ede6a3a0e8..a77d5fa156 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts @@ -60,6 +60,14 @@ import { CALLBACK_TOKEN_SECRET } from '@/lib/config.server'; import { verifyCallbackToken } from '@kilocode/worker-utils/callback-token'; import { PLATFORM } from '@/lib/integrations/core/constants'; import { appendReviewSummaryFooter } from '@/lib/code-reviews/summary/usage-footer'; +import { + countActionableProposals, + type ReviewMemoryOwner, +} from '@/lib/code-reviews/review-memory/db'; +import { + syncGitHubReviewMemorySubjects, + syncGitLabReviewMemorySubjects, +} from '@/lib/code-reviews/review-memory/sync-subjects'; import { APP_URL } from '@/lib/constants'; import type { CloudAgentCodeReview, PlatformIntegration } from '@kilocode/db/schema'; import type { GitHubAppType } from '@/lib/integrations/platforms/github/app-selector'; @@ -103,6 +111,37 @@ type CloudAgentNextCallbackPayload = { gateResult?: 'pass' | 'fail'; }; +function reviewMemoryOwnerFromReview(review: CloudAgentCodeReview): ReviewMemoryOwner | null { + if (review.owned_by_organization_id) { + return { type: 'org', id: review.owned_by_organization_id }; + } + if (review.owned_by_user_id) { + return { type: 'user', id: review.owned_by_user_id }; + } + return null; +} + +async function getReviewMemoryFooterData(review: CloudAgentCodeReview) { + const owner = reviewMemoryOwnerFromReview(review); + if (!owner) return undefined; + + const platform = review.platform === PLATFORM.GITLAB ? 'gitlab' : 'github'; + const proposalCount = await countActionableProposals({ + owner, + platform, + repoFullName: review.repo_full_name, + }); + if (proposalCount === 0) return undefined; + + const params = new URLSearchParams({ + platform, + tab: 'memory', + repo: review.repo_full_name, + }); + const path = owner.type === 'org' ? `/organizations/${owner.id}/code-reviews` : '/code-reviews'; + return { proposalCount, url: `${APP_URL}${path}?${params.toString()}` }; +} + type StatusUpdatePayload = OrchestratorPayload | CloudAgentNextCallbackPayload; type TerminalOwnerResolution = { @@ -1183,8 +1222,9 @@ export async function POST( ? { model, tokensIn, tokensOut } : undefined; const reviewGuidance = getReviewGuidanceFooterData(review); + const reviewMemory = await getReviewMemoryFooterData(review); - if (usage || reviewGuidance.used) { + if (usage || reviewGuidance.used || reviewMemory) { const existing = await findKiloReviewComment( integration.platform_installation_id, repoOwner, @@ -1193,10 +1233,12 @@ export async function POST( appType ); if (existing) { - const updatedBody = appendReviewSummaryFooter(existing.body, { + const footer = { usage, reviewGuidance, - }); + ...(reviewMemory ? { reviewMemory } : {}), + }; + const updatedBody = appendReviewSummaryFooter(existing.body, footer); await updateKiloReviewComment( integration.platform_installation_id, repoOwner, @@ -1220,6 +1262,23 @@ export async function POST( } ); } + + try { + await syncGitHubReviewMemorySubjects({ + review, + installationId: integration.platform_installation_id, + appType, + }); + } catch (subjectSyncError) { + logExceptInTest('[code-review-status] Failed to sync GitHub review subjects:', { + reviewId, + error: subjectSyncError, + }); + captureException(subjectSyncError, { + tags: { source: 'code-review-status-review-memory-sync' }, + extra: { reviewId, platform }, + }); + } } } else if (platform === PLATFORM.GITLAB) { const instanceUrl = getGitLabInstanceUrl(integration); @@ -1274,8 +1333,9 @@ export async function POST( ? { model, tokensIn, tokensOut } : undefined; const reviewGuidance = getReviewGuidanceFooterData(review); + const reviewMemory = await getReviewMemoryFooterData(review); - if (usage || reviewGuidance.used) { + if (usage || reviewGuidance.used || reviewMemory) { const existing = await findKiloReviewNote( accessToken, review.repo_full_name, @@ -1283,10 +1343,12 @@ export async function POST( instanceUrl ); if (existing) { - const updatedBody = appendReviewSummaryFooter(existing.body, { + const footer = { usage, reviewGuidance, - }); + ...(reviewMemory ? { reviewMemory } : {}), + }; + const updatedBody = appendReviewSummaryFooter(existing.body, footer); await updateKiloReviewNote( accessToken, review.repo_full_name, @@ -1310,6 +1372,23 @@ export async function POST( } ); } + + try { + await syncGitLabReviewMemorySubjects({ + review, + accessToken, + instanceUrl, + }); + } catch (subjectSyncError) { + logExceptInTest('[code-review-status] Failed to sync GitLab review subjects:', { + reviewId, + error: subjectSyncError, + }); + captureException(subjectSyncError, { + tags: { source: 'code-review-status-review-memory-sync' }, + extra: { reviewId, platform }, + }); + } } } } catch (postCompletionError) { diff --git a/apps/web/src/app/api/webhooks/gitlab/route.test.ts b/apps/web/src/app/api/webhooks/gitlab/route.test.ts new file mode 100644 index 0000000000..1f43711c7d --- /dev/null +++ b/apps/web/src/app/api/webhooks/gitlab/route.test.ts @@ -0,0 +1,258 @@ +import type { NextRequest } from 'next/server'; + +const mockVerifyGitLabWebhookToken = jest.fn((_token: string, _expected?: string) => true); +const mockFindGitLabIntegrationByWebhookToken = jest.fn(); +const mockHandleMergeRequest = jest.fn(); +const mockLogWebhookEvent = jest.fn(); +const mockUpdateWebhookEvent = jest.fn(); +const mockHandleGitLabEmojiFeedback = jest.fn(); +const mockHandleGitLabMergeRequestFeedback = jest.fn(); +const mockHandleGitLabNoteFeedback = jest.fn(); + +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + verifyGitLabWebhookToken: (token: string, expected?: string) => + mockVerifyGitLabWebhookToken(token, expected), +})); + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + findGitLabIntegrationByWebhookToken: (token: string) => + mockFindGitLabIntegrationByWebhookToken(token), +})); + +jest.mock('@/lib/integrations/platforms/gitlab/webhook-handlers', () => ({ + handleMergeRequest: (payload: unknown, integration: unknown) => + mockHandleMergeRequest(payload, integration), +})); + +jest.mock('@/lib/integrations/db/webhook-events', () => ({ + logWebhookEvent: (data: unknown) => mockLogWebhookEvent(data), + updateWebhookEvent: (eventId: string, updates: unknown) => + mockUpdateWebhookEvent(eventId, updates), +})); + +jest.mock('@/lib/code-reviews/review-memory/gitlab-feedback', () => ({ + handleGitLabEmojiFeedback: (input: unknown) => mockHandleGitLabEmojiFeedback(input), + handleGitLabMergeRequestFeedback: (input: unknown) => mockHandleGitLabMergeRequestFeedback(input), + handleGitLabNoteFeedback: (input: unknown) => mockHandleGitLabNoteFeedback(input), +})); + +import { POST } from './route'; + +const integration = { + id: 'pi_gitlab', + owned_by_organization_id: 'org_1', + owned_by_user_id: null, + suspended_at: null, + metadata: { webhook_secret: 'secret' }, +}; + +function gitLabRequest(eventType: string, payload: unknown): NextRequest { + return new Request('https://app.example.com/api/webhooks/gitlab', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-gitlab-token': 'secret', + 'x-gitlab-event': eventType, + 'x-gitlab-event-uuid': `delivery-${eventType}`, + }, + body: JSON.stringify(payload), + }) as NextRequest; +} + +function user() { + return { id: 7, name: 'Maintainer', username: 'maintainer' }; +} + +function project() { + return { + id: 123, + name: 'widgets', + web_url: 'https://gitlab.example.com/acme/widgets', + namespace: 'acme', + path_with_namespace: 'acme/widgets', + default_branch: 'main', + }; +} + +function mergeRequestAttributes(overrides: Record = {}) { + return { + id: 900, + iid: 42, + title: 'Add widgets', + state: 'opened', + action: 'update', + source_branch: 'feature/widgets', + target_branch: 'main', + source_project_id: 123, + target_project_id: 123, + author_id: 7, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:01:00.000Z', + url: 'https://gitlab.example.com/acme/widgets/-/merge_requests/42', + last_commit: { + id: 'abc123', + message: 'Add widgets', + }, + ...overrides, + }; +} + +function mergeRequestPayload(overrides: Record = {}) { + return { + object_kind: 'merge_request', + event_type: 'merge_request', + user: user(), + project: project(), + object_attributes: mergeRequestAttributes(), + ...overrides, + }; +} + +function notePayload(overrides: Record = {}) { + return { + object_kind: 'note', + event_type: 'note', + user: user(), + project_id: 123, + project: project(), + object_attributes: { + id: 501, + note: 'This is a false positive.', + action: 'create', + discussion_id: 'discussion-1', + noteable_type: 'MergeRequest', + author_id: 7, + created_at: '2026-01-01T00:02:00.000Z', + updated_at: '2026-01-01T00:02:00.000Z', + project_id: 123, + system: false, + url: 'https://gitlab.example.com/acme/widgets/-/merge_requests/42#note_501', + }, + merge_request: mergeRequestAttributes(), + ...overrides, + }; +} + +function emojiPayload(overrides: Record = {}) { + return { + object_kind: 'emoji', + event_type: 'emoji', + user: user(), + project_id: 123, + project: project(), + object_attributes: { + id: 777, + name: 'thumbsdown', + action: 'award', + awardable_type: 'Note', + awardable_id: 500, + created_at: '2026-01-01T00:02:00.000Z', + }, + merge_request: mergeRequestAttributes(), + note: { + id: 500, + note: '**WARNING**: Avoid this pattern', + url: 'https://gitlab.example.com/acme/widgets/-/merge_requests/42#note_500', + noteable_type: 'MergeRequest', + }, + ...overrides, + }; +} + +describe('GitLab webhook route', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockVerifyGitLabWebhookToken.mockReturnValue(true); + mockFindGitLabIntegrationByWebhookToken.mockResolvedValue(integration); + mockHandleMergeRequest.mockResolvedValue(Response.json({ message: 'Event received' })); + mockLogWebhookEvent.mockResolvedValue({ id: 'we_1', isDuplicate: false }); + mockUpdateWebhookEvent.mockResolvedValue(undefined); + mockHandleGitLabEmojiFeedback.mockResolvedValue({ recorded: true, eventIds: ['event_1'] }); + mockHandleGitLabMergeRequestFeedback.mockResolvedValue({ + recorded: true, + eventIds: ['event_1'], + }); + mockHandleGitLabNoteFeedback.mockResolvedValue({ recorded: true, eventIds: ['event_1'] }); + }); + + it('routes merge request events to code review and review memory feedback', async () => { + const response = await POST(gitLabRequest('Merge Request Hook', mergeRequestPayload())); + + expect(response.status).toBe(200); + expect(mockHandleMergeRequest).toHaveBeenCalledWith( + expect.objectContaining({ object_kind: 'merge_request' }), + integration + ); + expect(mockHandleGitLabMergeRequestFeedback).toHaveBeenCalledWith({ + payload: expect.objectContaining({ object_kind: 'merge_request' }), + integration, + deliveryId: 'delivery-Merge Request Hook', + }); + expect(mockUpdateWebhookEvent).toHaveBeenCalledWith( + 'we_1', + expect.objectContaining({ handlers_triggered: ['code_review', 'review_memory_feedback'] }) + ); + }); + + it('routes note events to review memory feedback', async () => { + const response = await POST(gitLabRequest('Note Hook', notePayload())); + + expect(response.status).toBe(200); + expect(mockHandleMergeRequest).not.toHaveBeenCalled(); + expect(mockHandleGitLabNoteFeedback).toHaveBeenCalledWith({ + payload: expect.objectContaining({ object_kind: 'note' }), + integration, + deliveryId: 'delivery-Note Hook', + }); + expect(mockUpdateWebhookEvent).toHaveBeenCalledWith( + 'we_1', + expect.objectContaining({ handlers_triggered: ['review_memory_feedback'] }) + ); + }); + + it('does not persist ignored note events', async () => { + mockHandleGitLabNoteFeedback.mockResolvedValueOnce({ + recorded: false, + eventIds: [], + reason: 'not-kilo-subject', + }); + + const response = await POST(gitLabRequest('Note Hook', notePayload())); + + expect(response.status).toBe(200); + expect(mockHandleGitLabNoteFeedback).toHaveBeenCalled(); + expect(mockLogWebhookEvent).not.toHaveBeenCalled(); + expect(mockUpdateWebhookEvent).not.toHaveBeenCalled(); + }); + + it('routes emoji events to review memory feedback', async () => { + const response = await POST(gitLabRequest('Emoji Hook', emojiPayload())); + + expect(response.status).toBe(200); + expect(mockHandleMergeRequest).not.toHaveBeenCalled(); + expect(mockHandleGitLabEmojiFeedback).toHaveBeenCalledWith({ + payload: expect.objectContaining({ object_kind: 'emoji' }), + integration, + deliveryId: 'delivery-Emoji Hook', + }); + expect(mockUpdateWebhookEvent).toHaveBeenCalledWith( + 'we_1', + expect.objectContaining({ handlers_triggered: ['review_memory_feedback'] }) + ); + }); + + it('does not persist ignored emoji events', async () => { + mockHandleGitLabEmojiFeedback.mockResolvedValueOnce({ + recorded: false, + eventIds: [], + reason: 'unsupported-emoji', + }); + + const response = await POST(gitLabRequest('Emoji Hook', emojiPayload())); + + expect(response.status).toBe(200); + expect(mockHandleGitLabEmojiFeedback).toHaveBeenCalled(); + expect(mockLogWebhookEvent).not.toHaveBeenCalled(); + expect(mockUpdateWebhookEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/webhooks/gitlab/route.ts b/apps/web/src/app/api/webhooks/gitlab/route.ts index 50d20c5118..e2a3b051d9 100644 --- a/apps/web/src/app/api/webhooks/gitlab/route.ts +++ b/apps/web/src/app/api/webhooks/gitlab/route.ts @@ -2,7 +2,11 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { captureException, captureMessage } from '@sentry/nextjs'; import { verifyGitLabWebhookToken } from '@/lib/integrations/platforms/gitlab/adapter'; -import { MergeRequestPayloadSchema } from '@/lib/integrations/platforms/gitlab/webhook-schemas'; +import { + EmojiEventPayloadSchema, + MergeRequestPayloadSchema, + NoteEventPayloadSchema, +} from '@/lib/integrations/platforms/gitlab/webhook-schemas'; import { findGitLabIntegrationByWebhookToken } from '@/lib/integrations/db/platform-integrations'; import { handleMergeRequest } from '@/lib/integrations/platforms/gitlab/webhook-handlers'; import { PLATFORM, GITLAB_EVENT, GITLAB_ACTION } from '@/lib/integrations/core/constants'; @@ -10,6 +14,25 @@ import { logExceptInTest } from '@/lib/utils.server'; import { logWebhookEvent, updateWebhookEvent } from '@/lib/integrations/db/webhook-events'; import type { Owner } from '@/lib/integrations/core/types'; import { redactSensitiveHeaders } from '@kilocode/worker-utils/redact-headers'; +import { + handleGitLabEmojiFeedback, + handleGitLabMergeRequestFeedback, + handleGitLabNoteFeedback, +} from '@/lib/code-reviews/review-memory/gitlab-feedback'; + +type WebhookHandlerError = { + message: string; + handler: string; + stack?: string; +}; + +function toWebhookHandlerError(handler: string, error: unknown): WebhookHandlerError { + return { + message: error instanceof Error ? error.message : String(error), + handler, + stack: error instanceof Error ? error.stack : undefined, + }; +} /** * GitLab Webhook Handler @@ -143,16 +166,31 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: 'Duplicate event' }, { status: 200 }); } + const errors: WebhookHandlerError[] = []; const result = await handleMergeRequest(parseResult.data, integration); + try { + await handleGitLabMergeRequestFeedback({ + payload: parseResult.data, + integration, + deliveryId: eventSignature, + }); + } catch (error) { + logExceptInTest('Error recording GitLab merge request feedback:', error); + captureException(error, { + tags: { source: 'gitlab_webhook_review_memory_feedback' }, + }); + errors.push(toWebhookHandlerError('review_memory_feedback', error)); + } + // Mark webhook event as processed if (logResult.webhookEventId) { try { await updateWebhookEvent(logResult.webhookEventId, { processed: true, processed_at: new Date().toISOString(), - handlers_triggered: ['code_review'], - errors: null, + handlers_triggered: ['code_review', 'review_memory_feedback'], + errors: errors.length > 0 ? errors : null, }); } catch (error) { logExceptInTest('Error updating webhook event:', error); @@ -168,9 +206,115 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: 'Event received' }, { status: 200 }); } - // Handle Note (comment) events (for future use - e.g., responding to review comments) + // Handle Note (comment) events for review memory feedback if (eventType === GITLAB_EVENT.NOTE) { - logExceptInTest('Note event received, not yet implemented'); + const parseResult = NoteEventPayloadSchema.safeParse(payload); + if (!parseResult.success) { + logExceptInTest('Invalid note payload:', parseResult.error); + captureMessage('Invalid GitLab webhook payload structure', { + level: 'error', + tags: { source: 'gitlab_webhook_validation', event: 'note' }, + extra: { errors: parseResult.error.issues }, + }); + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + } + + const action = parseResult.data.object_attributes.action ?? 'note'; + const errors: WebhookHandlerError[] = []; + let shouldLogWebhook = false; + try { + const feedbackResult = await handleGitLabNoteFeedback({ + payload: parseResult.data, + integration, + deliveryId: eventSignature, + }); + shouldLogWebhook = feedbackResult.recorded; + } catch (error) { + logExceptInTest('Error recording GitLab note feedback:', error); + captureException(error, { + tags: { source: 'gitlab_webhook_review_memory_feedback' }, + }); + errors.push(toWebhookHandlerError('review_memory_feedback', error)); + } + + if (!shouldLogWebhook && errors.length === 0) { + return NextResponse.json({ message: 'Event received' }, { status: 200 }); + } + + const logResult = await logWebhook(action); + if (logResult.isDuplicate) { + return NextResponse.json({ message: 'Duplicate event' }, { status: 200 }); + } + + if (logResult.webhookEventId) { + try { + await updateWebhookEvent(logResult.webhookEventId, { + processed: true, + processed_at: new Date().toISOString(), + handlers_triggered: ['review_memory_feedback'], + errors: errors.length > 0 ? errors : null, + }); + } catch (error) { + logExceptInTest('Error updating webhook event:', error); + } + } + + return NextResponse.json({ message: 'Event received' }, { status: 200 }); + } + + // Handle Emoji events for review memory feedback + if (eventType === GITLAB_EVENT.EMOJI) { + const parseResult = EmojiEventPayloadSchema.safeParse(payload); + if (!parseResult.success) { + logExceptInTest('Invalid emoji payload:', parseResult.error); + captureMessage('Invalid GitLab webhook payload structure', { + level: 'error', + tags: { source: 'gitlab_webhook_validation', event: 'emoji' }, + extra: { errors: parseResult.error.issues }, + }); + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + } + + const action = parseResult.data.object_attributes.action ?? 'emoji'; + const errors: WebhookHandlerError[] = []; + let shouldLogWebhook = false; + try { + const feedbackResult = await handleGitLabEmojiFeedback({ + payload: parseResult.data, + integration, + deliveryId: eventSignature, + }); + shouldLogWebhook = feedbackResult.recorded; + } catch (error) { + logExceptInTest('Error recording GitLab emoji feedback:', error); + captureException(error, { + tags: { source: 'gitlab_webhook_review_memory_feedback' }, + }); + errors.push(toWebhookHandlerError('review_memory_feedback', error)); + } + + if (!shouldLogWebhook && errors.length === 0) { + return NextResponse.json({ message: 'Event received' }, { status: 200 }); + } + + const logResult = await logWebhook(action); + if (logResult.isDuplicate) { + return NextResponse.json({ message: 'Duplicate event' }, { status: 200 }); + } + + if (logResult.webhookEventId) { + try { + await updateWebhookEvent(logResult.webhookEventId, { + processed: true, + processed_at: new Date().toISOString(), + handlers_triggered: ['review_memory_feedback'], + errors: errors.length > 0 ? errors : null, + }); + } catch (error) { + logExceptInTest('Error updating webhook event:', error); + } + } + return NextResponse.json({ message: 'Event received' }, { status: 200 }); } diff --git a/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx b/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx new file mode 100644 index 0000000000..51a9191060 --- /dev/null +++ b/apps/web/src/components/code-reviews/ReviewMemoryPanel.tsx @@ -0,0 +1,363 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Brain, ExternalLink, RefreshCw, Sparkles } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; +import { + REVIEW_MEMORY_RETENTION_COPY, + REVIEW_MEMORY_RETENTION_DAYS, + REVIEW_MEMORY_RETENTION_LABEL, +} from '@/lib/code-reviews/review-memory/retention'; +import { useTRPC } from '@/lib/trpc/utils'; +import { cn } from '@/lib/utils'; +import type { ReviewMemoryProposalStatus } from '@kilocode/db/schema-types'; + +type Platform = 'github' | 'gitlab'; + +type ReviewMemoryPanelProps = { + organizationId?: string; + platform: Platform; +}; + +const ACTIVE_PROPOSAL_STATUSES: ReviewMemoryProposalStatus[] = [ + 'open', + 'edited', + 'approved', + 'opening_change_request', + 'change_request_opened', + 'change_request_failed', +]; + +function readable(value: string) { + return value.replace(/_/g, ' '); +} + +export function ReviewMemoryPanel({ organizationId, platform }: ReviewMemoryPanelProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [selectedProposalId, setSelectedProposalId] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(''); + const [rationale, setRationale] = useState(''); + const [proposedMarkdown, setProposedMarkdown] = useState(''); + + const ownerInput = organizationId ? { organizationId, platform } : { platform }; + const proposalInput = { ...ownerInput, statuses: ACTIVE_PROPOSAL_STATUSES, limit: 50 }; + const summaryQuery = useQuery(trpc.reviewMemory.getDashboardSummary.queryOptions(ownerInput)); + const proposalsQuery = useQuery(trpc.reviewMemory.listProposals.queryOptions(proposalInput)); + const proposals = proposalsQuery.data ?? []; + const selectedProposal = + proposals.find(proposal => proposal.id === selectedProposalId) ?? proposals[0]; + const analysisRepo = + summaryQuery.data?.repositories[0]?.repoFullName ?? selectedProposal?.repo_full_name; + + useEffect(() => { + if (!selectedProposal) return; + setSelectedProposalId(selectedProposal.id); + setTitle(selectedProposal.title); + setRationale(selectedProposal.rationale); + setProposedMarkdown(selectedProposal.proposed_markdown); + setIsEditing(false); + }, [selectedProposal?.id]); + + const invalidateReviewMemory = async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.reviewMemory.getDashboardSummary.queryKey(ownerInput), + }), + queryClient.invalidateQueries({ + queryKey: trpc.reviewMemory.listProposals.queryKey(proposalInput), + }), + ]); + }; + + const triggerAnalysis = useMutation( + trpc.reviewMemory.triggerAnalysis.mutationOptions({ + onSuccess: async () => { + toast.success('Review memory analysis queued'); + await invalidateReviewMemory(); + }, + onError: error => toast.error('Could not analyze feedback', { description: error.message }), + }) + ); + const updateProposal = useMutation( + trpc.reviewMemory.updateProposal.mutationOptions({ + onSuccess: async () => { + toast.success('Memory proposal updated'); + setIsEditing(false); + await invalidateReviewMemory(); + }, + onError: error => toast.error('Could not update proposal', { description: error.message }), + }) + ); + const rejectProposal = useMutation( + trpc.reviewMemory.rejectProposal.mutationOptions({ + onSuccess: async () => { + toast.success('Memory proposal dismissed'); + await invalidateReviewMemory(); + }, + onError: error => toast.error('Could not dismiss proposal', { description: error.message }), + }) + ); + const approveAndOpenChangeRequest = useMutation( + trpc.reviewMemory.approveAndOpenChangeRequest.mutationOptions({ + onSuccess: async proposal => { + toast.success(platform === 'github' ? 'Pull request opened' : 'Merge request opened', { + description: proposal.change_request_url ?? undefined, + }); + await invalidateReviewMemory(); + }, + onError: error => + toast.error('Could not open REVIEW.md change request', { description: error.message }), + }) + ); + + const isLoading = summaryQuery.isLoading || proposalsQuery.isLoading; + const changeRequestLabel = platform === 'github' ? 'PR' : 'MR'; + + return ( +
+ + +
+ + + + Review memory + + + {REVIEW_MEMORY_RETENTION_LABEL} + + + + Turn recurring reviewer feedback into proposed REVIEW.md guidance. + {` ${REVIEW_MEMORY_RETENTION_COPY}`} + +
+ +
+ + + + sum + state.fresh_event_count, 0) ?? + 0 + } + /> + +
+ +
+ + + Proposals + + Review, edit, or dismiss learned guidance before applying it. + + + + {isLoading ? ( +
+ Loading memory... +
+ ) : proposals.length === 0 ? ( +
+ No proposals from retained feedback yet. Kilo waits for repeated, high-signal + feedback before suggesting memory changes. +
+ ) : ( + proposals.map(proposal => ( + + )) + )} +
+
+ + + + {selectedProposal ? selectedProposal.title : 'Proposal details'} + + {selectedProposal + ? `${selectedProposal.negative_count} negative, ${selectedProposal.positive_count} positive, ${selectedProposal.neutral_count} neutral signals` + : 'Select a proposal to review its guidance.'} + + + + {selectedProposal ? ( + <> + {isEditing ? ( +
+