From 6161236b82defaa35017f1410504712aa79943d8 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:23:55 +0000 Subject: [PATCH 1/2] fix: show review actions in web notifications --- .../src/jobs/pr-review-notification.test.ts | 13 +- .../bullmq/src/jobs/pr-review-notification.ts | 11 + .../[taskId]/messages/acp/AcpTextMessage.tsx | 143 +++++++- .../__tests__/AcpTextMessage.client.test.tsx | 103 +++++- .../trpc/commands/sandbox-session/index.ts | 319 ++++++++++++++++++ .../sandbox-session/send-prompt.test.ts | 232 ++++++++++++- apps/web/src/trpc/routers/_app.ts | 8 + .../pr-review-notification-delivery.test.ts | 36 ++ .../pr-review-notification-delivery.ts | 16 + packages/types/src/task-messages.ts | 58 ++++ 10 files changed, 934 insertions(+), 5 deletions(-) diff --git a/apps/bullmq/src/jobs/pr-review-notification.test.ts b/apps/bullmq/src/jobs/pr-review-notification.test.ts index ae09f2ecc..3b63e873d 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.test.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.test.ts @@ -779,7 +779,9 @@ describe('prReviewNotificationJob', () => { mockPrepareDelivery.mockResolvedValue({ post: true, route: null, - text: 'I reviewed owner/repo#42 on GitHub and found no issues.', + text: 'I reviewed owner/repo#42 on GitHub and found an issue.', + followUpQuestion: 'Would you like me to resolve this issue?', + followUpPrompt: 'Resolve the review feedback on owner/repo#42.', }); await prReviewNotificationJob(makeJob() as never); @@ -789,7 +791,14 @@ describe('prReviewNotificationJob', () => { runId: 1, taskId: 'task-1', route: null, - text: 'I reviewed owner/repo#42 on GitHub and found no issues.', + text: 'I reviewed owner/repo#42 on GitHub and found an issue.\nWould you like me to resolve this issue?', + action: { + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://github.com/owner/repo/pull/42', + question: 'Would you like me to resolve this issue?', + followUpPrompt: 'Resolve the review feedback on owner/repo#42.', + }, }); }); diff --git a/apps/bullmq/src/jobs/pr-review-notification.ts b/apps/bullmq/src/jobs/pr-review-notification.ts index 5d06a4f94..ac46e5332 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.ts @@ -458,6 +458,17 @@ ${delivery.text}`; taskId: data.taskId, route: delivery.route, text: textWithQuestion, + ...(followUp && !delivery.route + ? { + action: { + repository: data.repository, + prNumber: data.prNumber, + prUrl: data.prUrl, + question: followUp.question, + followUpPrompt: followUp.prompt, + }, + } + : {}), ...(messageTs ? { messageTs } : {}), }); await finalizePrReviewNotificationRequest(data); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx index 5caebd639..0ec911d48 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx @@ -1,8 +1,12 @@ -import { useState, type ComponentType } from 'react'; +import { useEffect, useState, type ComponentType } from 'react'; import Image from 'next/image'; +import { toast } from 'sonner'; import { ACP_ENVELOPE_EVENT_TYPES, + getPrReviewNotificationAction, + PR_REVIEW_ACTION_PROCESSING_LEASE_MS, type AcpRequestUserInputPayload, + type PrReviewNotificationActionStatus, getProviderRetryNoticeFromMessageData, getTerminalProviderErrorFromMessageData, parseLinkedReviewResults, @@ -10,11 +14,13 @@ import { } from '@roomote/types'; import { cn } from '@/lib/utils'; +import { useTRPCClient } from '@/trpc/client'; import { BasicTooltip, Button, ChevronDownIcon, + Loader2, MediaViewerDialog, MediaViewerImage, } from '@/components/system'; @@ -88,6 +94,138 @@ interface AcpTextMessageProps { msg: AcpUiMessage; } +function PrReviewNotificationActions({ msg }: { msg: AcpUiMessage }) { + const action = getPrReviewNotificationAction( + msg.data as Record, + ); + const trpcClient = useTRPCClient(); + const [status, setStatus] = useState( + action?.status === 'processing' && + (action.processingStartedAt ?? 0) <= + Date.now() - PR_REVIEW_ACTION_PROCESSING_LEASE_MS + ? 'pending' + : (action?.status ?? null), + ); + const [submittingAction, setSubmittingAction] = useState< + 'resolve' | 'auto_resolve' | 'dismiss' | null + >(null); + + useEffect(() => { + if (action?.status !== 'processing' || !action.processingStartedAt) { + return; + } + + const remainingMs = + action.processingStartedAt + + PR_REVIEW_ACTION_PROCESSING_LEASE_MS - + Date.now(); + + if (remainingMs <= 0) { + setStatus('pending'); + return; + } + + const timeout = window.setTimeout(() => setStatus('pending'), remainingMs); + return () => window.clearTimeout(timeout); + }, [action?.processingStartedAt, action?.status]); + + if (!action || status === null) { + return null; + } + + const submit = async ( + selectedAction: 'resolve' | 'auto_resolve' | 'dismiss', + ) => { + setSubmittingAction(selectedAction); + setStatus('processing'); + + try { + const result = + await trpcClient.sandboxSession.handlePrReviewNotificationAction.mutate( + { + taskId: action.taskId, + messageId: msg.id, + action: selectedAction, + }, + ); + setStatus(result.status as PrReviewNotificationActionStatus); + + if ( + selectedAction === 'auto_resolve' && + !result.currentFeedbackDispatched + ) { + toast.warning( + 'Future feedback will be resolved automatically, but this task could not resume for the current feedback.', + ); + } + } catch (error) { + setStatus('pending'); + toast.error( + error instanceof Error ? error.message : 'Failed to handle feedback.', + ); + } finally { + setSubmittingAction(null); + } + }; + + if (status !== 'pending' && status !== 'processing') { + const resolution = { + resolved: 'Resolution requested.', + auto_resolved: + 'Future feedback on this PR will be resolved automatically.', + dismissed: 'Dismissed.', + }[status]; + + return ( +

+ {resolution} +

+ ); + } + + const isSubmitting = status === 'processing'; + + return ( +
+ + + +
+ ); +} + function getUserTooltipContent(msg: AcpUiMessage): string { const userName = msg.userName ?? 'User'; @@ -333,6 +471,9 @@ export function AcpTextMessage({ msg }: AcpTextMessageProps) { ) : ( {content} )} + {!isUser && msg.kind === 'text' ? ( + + ) : null} {showPersistentTimestamp && !msg.partial && ( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx index 0983429ec..3d30f88a5 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx @@ -1,10 +1,21 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; const transcriptVisibilityState = vi.hoisted(() => ({ enabled: false, })); +const handlePrReviewNotificationActionMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/trpc/client', () => ({ + useTRPCClient: () => ({ + sandboxSession: { + handlePrReviewNotificationAction: { + mutate: handlePrReviewNotificationActionMock, + }, + }, + }), +})); vi.mock('@/components/ai-elements', () => ({ Attachment: ({ @@ -80,6 +91,7 @@ vi.mock('@/components/system', () => ({ GitPullRequestDraft: () => , Image: () => , ListChecks: () => , + Loader2: () => , MediaViewerDialog: ({ children, open, @@ -111,6 +123,95 @@ import { AcpTextMessage } from '../AcpTextMessage'; describe('AcpTextMessage', () => { beforeEach(() => { transcriptVisibilityState.enabled = false; + handlePrReviewNotificationActionMock.mockReset(); + }); + + it('renders and handles persisted PR review notification actions', async () => { + handlePrReviewNotificationActionMock.mockResolvedValue({ + status: 'resolved', + currentFeedbackDispatched: true, + }); + + render( + , + ); + + expect( + screen.getByRole('button', { name: 'Resolve these issues' }), + ).toBeVisible(); + expect( + screen.getByRole('button', { name: 'Auto-resolve on this PR' }), + ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible(); + + fireEvent.click( + screen.getByRole('button', { name: 'Resolve these issues' }), + ); + + await waitFor(() => { + expect(handlePrReviewNotificationActionMock).toHaveBeenCalledWith({ + taskId: 'task-1', + messageId: '10000000-0000-4000-8000-000000000001', + action: 'resolve', + }); + }); + expect(await screen.findByText('Resolution requested.')).toBeVisible(); + }); + + it('re-enables controls for an abandoned processing action', () => { + render( + , + ); + + expect( + screen.getByRole('button', { name: 'Resolve these issues' }), + ).toBeEnabled(); }); it('shows copy and new task actions for assistant completion text', () => { diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index 12016e250..a248470c0 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import { TaskModelSelectionError, applyTaskModelSelectionToRun, @@ -11,10 +13,14 @@ import { getCommunicationChannelFromTaskPayload, getCommunicationProviderFromTaskPayload, getEnvironmentDefinitionIdFromPayload, + getPrReviewNotificationAction, isBootingRunStatus, isExitedRunStatus, + PR_REVIEW_ACTION_PROCESSING_LEASE_MS, + PR_REVIEW_NOTIFICATION_TASK_MESSAGE_SOURCE, resolveSourceControlProviderFromPayload, taskToolDispatchPayloadSchema, + type PrReviewNotificationActionStatus, type TaskGoal, } from '@roomote/types'; import { createRunToken } from '@roomote/auth'; @@ -34,6 +40,7 @@ import { getTaskGoalForRun, not, resolveEffectivePreviewRuntimeConfig, + sql, taskMessages, taskRuns, tasks, @@ -41,6 +48,7 @@ import { import { httpBatchLink, TRPCClientError } from '@trpc/client'; import { TRPCError } from '@trpc/server'; import { createSandboxServerRpcClient } from '@roomote/sdk/sandbox-router'; +import { enableAutoHandlePrReviewFeedback } from '@roomote/sdk/server'; import superjson from 'superjson'; import { z } from 'zod'; @@ -159,6 +167,317 @@ export const answerSandboxUserInputRequestInputSchema = z.object({ answers: requestUserInputAnswersSchema, }); +export const handlePrReviewNotificationActionInputSchema = z.object({ + taskId: z.string(), + messageId: z.string().uuid(), + action: z.enum(['resolve', 'auto_resolve', 'dismiss']), +}); + +async function updatePrReviewNotificationActionStatus(input: { + taskId: string; + messageId: string; + payload: Record; + expectedStatus: PrReviewNotificationActionStatus; + expectedProcessingToken?: string; + status: PrReviewNotificationActionStatus; +}) { + const action = getPrReviewNotificationAction(input.payload); + + if (!action) { + return false; + } + + const settledAction = { ...action }; + delete settledAction.processingStartedAt; + delete settledAction.processingToken; + const [updated] = await db + .update(taskMessages) + .set({ + payload: { + ...input.payload, + prReviewAction: { ...settledAction, status: input.status }, + }, + }) + .where( + and( + eq(taskMessages.id, input.messageId), + eq(taskMessages.taskId, input.taskId), + sql`${taskMessages.payload}->'prReviewAction'->>'status' = ${input.expectedStatus}`, + ...(input.expectedProcessingToken + ? [ + sql`${taskMessages.payload}->'prReviewAction'->>'processingToken' = ${input.expectedProcessingToken}`, + ] + : []), + ), + ) + .returning({ id: taskMessages.id }); + + return Boolean(updated); +} + +async function claimPrReviewNotificationAction(input: { + taskId: string; + messageId: string; + payload: Record; +}) { + const action = getPrReviewNotificationAction(input.payload); + + if (!action) { + return null; + } + + const processingStartedAt = Date.now(); + const processingToken = randomUUID(); + const [updated] = await db + .update(taskMessages) + .set({ + payload: { + ...input.payload, + prReviewAction: { + ...action, + status: 'processing', + processingStartedAt, + processingToken, + }, + }, + }) + .where( + and( + eq(taskMessages.id, input.messageId), + eq(taskMessages.taskId, input.taskId), + sql`( + ${taskMessages.payload}->'prReviewAction'->>'status' = 'pending' + OR ( + ${taskMessages.payload}->'prReviewAction'->>'status' = 'processing' + AND coalesce((${taskMessages.payload}->'prReviewAction'->>'processingStartedAt')::bigint, 0) <= ${processingStartedAt - PR_REVIEW_ACTION_PROCESSING_LEASE_MS} + ) + )`, + ), + ) + .returning({ id: taskMessages.id }); + + return updated ? { processingStartedAt, processingToken } : null; +} + +async function renewPrReviewNotificationActionClaim(input: { + taskId: string; + messageId: string; + payload: Record; + processingToken: string; +}) { + const action = getPrReviewNotificationAction(input.payload); + + if (!action) { + return false; + } + + const [updated] = await db + .update(taskMessages) + .set({ + payload: { + ...input.payload, + prReviewAction: { + ...action, + processingStartedAt: Date.now(), + }, + }, + }) + .where( + and( + eq(taskMessages.id, input.messageId), + eq(taskMessages.taskId, input.taskId), + sql`${taskMessages.payload}->'prReviewAction'->>'status' = 'processing'`, + sql`${taskMessages.payload}->'prReviewAction'->>'processingToken' = ${input.processingToken}`, + ), + ) + .returning({ id: taskMessages.id }); + + return Boolean(updated); +} + +async function getPersistedPrReviewNotificationActionStatus(input: { + taskId: string; + messageId: string; +}) { + const [message] = await db + .select({ payload: taskMessages.payload }) + .from(taskMessages) + .where( + and( + eq(taskMessages.id, input.messageId), + eq(taskMessages.taskId, input.taskId), + ), + ) + .limit(1); + + return ( + getPrReviewNotificationAction(message?.payload)?.status ?? 'processing' + ); +} + +export async function handlePrReviewNotificationActionCommand( + auth: UserAuthSuccess, + input: z.input, +) { + const parsed = handlePrReviewNotificationActionInputSchema.parse(input); + const taskAccess = await resolveTaskByIdAccessCommand(auth, { + taskId: parsed.taskId, + }); + + if (taskAccess.kind !== 'resolved') { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Task not found.' }); + } + + const [message] = await db + .select({ + metadata: taskMessages.metadata, + payload: taskMessages.payload, + }) + .from(taskMessages) + .where( + and( + eq(taskMessages.id, parsed.messageId), + eq(taskMessages.taskId, parsed.taskId), + ), + ) + .limit(1); + const reviewAction = getPrReviewNotificationAction(message?.payload); + + if ( + message?.metadata?.source !== PR_REVIEW_NOTIFICATION_TASK_MESSAGE_SOURCE || + !reviewAction || + reviewAction.taskId !== parsed.taskId + ) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Review action not found.', + }); + } + + const processingLeaseExpired = + reviewAction.status === 'processing' && + (reviewAction.processingStartedAt ?? 0) <= + Date.now() - PR_REVIEW_ACTION_PROCESSING_LEASE_MS; + + if (reviewAction.status !== 'pending' && !processingLeaseExpired) { + return { status: reviewAction.status, currentFeedbackDispatched: false }; + } + + const claim = await claimPrReviewNotificationAction({ + taskId: parsed.taskId, + messageId: parsed.messageId, + payload: message.payload, + }); + + if (!claim) { + return { status: 'processing' as const, currentFeedbackDispatched: false }; + } + + const processingPayload = { + ...message.payload, + prReviewAction: { + ...reviewAction, + status: 'processing' as const, + ...claim, + }, + }; + + if (parsed.action === 'dismiss') { + const settled = await updatePrReviewNotificationActionStatus({ + taskId: parsed.taskId, + messageId: parsed.messageId, + payload: processingPayload, + expectedStatus: 'processing', + expectedProcessingToken: claim.processingToken, + status: 'dismissed', + }); + + if (!settled) { + return { + status: await getPersistedPrReviewNotificationActionStatus(parsed), + currentFeedbackDispatched: false, + }; + } + + return { status: 'dismissed' as const, currentFeedbackDispatched: false }; + } + + let autoHandleEnabled = false; + try { + const ownsClaim = await renewPrReviewNotificationActionClaim({ + taskId: parsed.taskId, + messageId: parsed.messageId, + payload: processingPayload, + processingToken: claim.processingToken, + }); + + if (!ownsClaim) { + return { + status: await getPersistedPrReviewNotificationActionStatus(parsed), + currentFeedbackDispatched: false, + }; + } + + if (parsed.action === 'auto_resolve') { + await enableAutoHandlePrReviewFeedback({ + taskId: parsed.taskId, + repository: reviewAction.repository, + prNumber: reviewAction.prNumber, + userId: auth.userId, + }); + autoHandleEnabled = true; + } + + await sendSandboxPromptCommand(auth, { + taskId: parsed.taskId, + prompt: reviewAction.followUpPrompt, + source: 'web', + autoSteerWhenQueued: true, + }); + } catch (error) { + const status = autoHandleEnabled ? 'auto_resolved' : 'pending'; + const settled = await updatePrReviewNotificationActionStatus({ + taskId: parsed.taskId, + messageId: parsed.messageId, + payload: processingPayload, + expectedStatus: 'processing', + expectedProcessingToken: claim.processingToken, + status, + }); + + if (autoHandleEnabled) { + return { + status: settled + ? status + : await getPersistedPrReviewNotificationActionStatus(parsed), + currentFeedbackDispatched: false, + }; + } + + throw error; + } + + const status = + parsed.action === 'auto_resolve' ? 'auto_resolved' : 'resolved'; + const settled = await updatePrReviewNotificationActionStatus({ + taskId: parsed.taskId, + messageId: parsed.messageId, + payload: processingPayload, + expectedStatus: 'processing', + expectedProcessingToken: claim.processingToken, + status, + }); + + if (!settled) { + return { + status: await getPersistedPrReviewNotificationActionStatus(parsed), + currentFeedbackDispatched: false, + }; + } + + return { status, currentFeedbackDispatched: true }; +} + /** * Trusted actor switch, applied BEFORE the prompt reaches the sandbox. * diff --git a/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts b/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts index 34fc94c8b..3ff2f8fc8 100644 --- a/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts +++ b/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts @@ -4,14 +4,27 @@ const { mockClaimOutOfBandContext, mockSetLatestUserMessageForReplyQuote, mockClearLatestUserMessageForReplyQuoteIfId, + mockEnableAutoHandlePrReviewFeedback, } = vi.hoisted(() => ({ mockCreateRunToken: vi.fn(), mockSendPromptMutate: vi.fn(), mockClaimOutOfBandContext: vi.fn(), mockSetLatestUserMessageForReplyQuote: vi.fn(), mockClearLatestUserMessageForReplyQuoteIfId: vi.fn(), + mockEnableAutoHandlePrReviewFeedback: vi.fn(), })); +vi.mock('@roomote/sdk/server', async () => { + const actual = await vi.importActual( + '@roomote/sdk/server', + ); + + return { + ...actual, + enableAutoHandlePrReviewFeedback: mockEnableAutoHandlePrReviewFeedback, + }; +}); + vi.mock('@roomote/communication/messages', async () => { const actual = await vi.importActual< typeof import('@roomote/communication/messages') @@ -67,14 +80,21 @@ import { eq, runFactory, taskFactory, + taskMessages, taskRuns, userFactory, } from '@roomote/db/server'; -import { RunStatus } from '@roomote/types'; +import { + ACP_ENVELOPE_EVENT_TYPES, + PR_REVIEW_NOTIFICATION_TASK_MESSAGE_SOURCE, + ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL, + RunStatus, +} from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; import { + handlePrReviewNotificationActionCommand, sendSandboxPromptCommand, sendSandboxPromptInputSchema, } from './index'; @@ -105,6 +125,45 @@ function buildMockAuth( return auth as UserAuthSuccess; } +async function createPendingReviewActionTestContext() { + const user = await userFactory.create({ name: 'DB User' }); + const task = await taskFactory.create({ initiatorUserId: user.id }); + const run = await runFactory.create({ + actingUserId: user.id, + taskId: task.id, + status: RunStatus.Running, + sandboxServerUrl: 'http://sandbox.example.test', + result: {}, + }); + const [message] = await db + .insert(taskMessages) + .values({ + runId: run.id, + taskId: task.id, + ts: Date.now(), + eventType: ACP_ENVELOPE_EVENT_TYPES.AssistantMessage, + role: 'assistant', + protocol: ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL, + contentBlocks: [{ type: 'text', text: 'Review feedback.' }], + metadata: { source: PR_REVIEW_NOTIFICATION_TASK_MESSAGE_SOURCE }, + payload: { + text: 'Review feedback.', + prReviewAction: { + taskId: task.id, + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://github.com/owner/repo/pull/42', + question: 'Would you like me to resolve this issue?', + followUpPrompt: 'Resolve the review feedback.', + status: 'pending', + }, + }, + }) + .returning({ id: taskMessages.id }); + + return { user, task, message: message! }; +} + describe('sendSandboxPromptCommand', () => { const fetchMock = vi.fn(); @@ -125,6 +184,7 @@ describe('sendSandboxPromptCommand', () => { userName: 'Test User', }); mockClearLatestUserMessageForReplyQuoteIfId.mockResolvedValue(true); + mockEnableAutoHandlePrReviewFeedback.mockResolvedValue(undefined); }); afterEach(() => { @@ -538,4 +598,174 @@ describe('sendSandboxPromptCommand', () => { expect(updatedRun?.actingUserId).toBeNull(); }); + + it('dispatches a persisted review notification action exactly once', async () => { + const user = await userFactory.create({ name: 'DB User' }); + const task = await taskFactory.create({ initiatorUserId: user.id }); + const run = await runFactory.create({ + actingUserId: user.id, + taskId: task.id, + status: RunStatus.Running, + sandboxServerUrl: 'http://sandbox.example.test', + result: {}, + }); + const [message] = await db + .insert(taskMessages) + .values({ + runId: run.id, + taskId: task.id, + ts: Date.now(), + eventType: ACP_ENVELOPE_EVENT_TYPES.AssistantMessage, + role: 'assistant', + protocol: ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL, + contentBlocks: [{ type: 'text', text: 'Review feedback.' }], + metadata: { source: PR_REVIEW_NOTIFICATION_TASK_MESSAGE_SOURCE }, + payload: { + text: 'Review feedback.', + prReviewAction: { + taskId: task.id, + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://github.com/owner/repo/pull/42', + question: 'Would you like me to resolve this issue?', + followUpPrompt: 'Resolve the review feedback.', + status: 'pending', + }, + }, + }) + .returning({ id: taskMessages.id }); + + const result = await handlePrReviewNotificationActionCommand( + buildMockAuth({ userId: user.id }), + { taskId: task.id, messageId: message!.id, action: 'resolve' }, + ); + const duplicate = await handlePrReviewNotificationActionCommand( + buildMockAuth({ userId: user.id }), + { taskId: task.id, messageId: message!.id, action: 'resolve' }, + ); + const persisted = await db.query.taskMessages.findFirst({ + where: eq(taskMessages.id, message!.id), + columns: { payload: true }, + }); + + expect(result).toEqual({ + status: 'resolved', + currentFeedbackDispatched: true, + }); + expect(duplicate).toEqual({ + status: 'resolved', + currentFeedbackDispatched: false, + }); + expect(mockSendPromptMutate).toHaveBeenCalledTimes(1); + expect(mockSendPromptMutate).toHaveBeenCalledWith( + expect.objectContaining({ quoteText: 'Resolve the review feedback.' }), + ); + expect(persisted?.payload).toMatchObject({ + prReviewAction: { status: 'resolved' }, + }); + }); + + it('reclaims an abandoned processing action before persisting dismissal', async () => { + const user = await userFactory.create({ name: 'DB User' }); + const task = await taskFactory.create({ initiatorUserId: user.id }); + const run = await runFactory.create({ + taskId: task.id, + status: RunStatus.Idle, + result: {}, + }); + const [message] = await db + .insert(taskMessages) + .values({ + runId: run.id, + taskId: task.id, + ts: Date.now(), + eventType: ACP_ENVELOPE_EVENT_TYPES.AssistantMessage, + role: 'assistant', + protocol: ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL, + contentBlocks: [{ type: 'text', text: 'Review feedback.' }], + metadata: { source: PR_REVIEW_NOTIFICATION_TASK_MESSAGE_SOURCE }, + payload: { + text: 'Review feedback.', + prReviewAction: { + taskId: task.id, + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://github.com/owner/repo/pull/42', + question: 'Would you like me to resolve this issue?', + followUpPrompt: 'Resolve the review feedback.', + status: 'processing', + processingStartedAt: Date.now() - 10 * 60 * 1000, + processingToken: 'abandoned-request', + }, + }, + }) + .returning({ id: taskMessages.id }); + + await expect( + handlePrReviewNotificationActionCommand( + buildMockAuth({ userId: user.id }), + { taskId: task.id, messageId: message!.id, action: 'dismiss' }, + ), + ).resolves.toEqual({ + status: 'dismissed', + currentFeedbackDispatched: false, + }); + expect(mockSendPromptMutate).not.toHaveBeenCalled(); + }); + + it('returns an auto-resolve action to pending when setup fails', async () => { + mockEnableAutoHandlePrReviewFeedback.mockRejectedValueOnce( + new Error('could not enable auto handling'), + ); + const { user, task, message } = + await createPendingReviewActionTestContext(); + + await expect( + handlePrReviewNotificationActionCommand( + buildMockAuth({ userId: user.id }), + { taskId: task.id, messageId: message.id, action: 'auto_resolve' }, + ), + ).rejects.toThrow('could not enable auto handling'); + const persisted = await db.query.taskMessages.findFirst({ + where: eq(taskMessages.id, message.id), + columns: { payload: true }, + }); + + expect(mockSendPromptMutate).not.toHaveBeenCalled(); + expect(persisted?.payload).toMatchObject({ + prReviewAction: { status: 'pending' }, + }); + }); + + it('keeps auto-resolve enabled when current feedback cannot dispatch', async () => { + mockSendPromptMutate.mockRejectedValueOnce( + new Error('sandbox unavailable'), + ); + const { user, task, message } = + await createPendingReviewActionTestContext(); + + await expect( + handlePrReviewNotificationActionCommand( + buildMockAuth({ userId: user.id }), + { taskId: task.id, messageId: message.id, action: 'auto_resolve' }, + ), + ).resolves.toEqual({ + status: 'auto_resolved', + currentFeedbackDispatched: false, + }); + const persisted = await db.query.taskMessages.findFirst({ + where: eq(taskMessages.id, message.id), + columns: { payload: true }, + }); + + expect(mockEnableAutoHandlePrReviewFeedback).toHaveBeenCalledWith({ + taskId: task.id, + repository: 'owner/repo', + prNumber: 42, + userId: user.id, + }); + expect(persisted?.payload).toMatchObject({ + prReviewAction: { status: 'auto_resolved' }, + }); + }); }); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index ecd12d954..70731d29f 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -194,6 +194,8 @@ import { answerSandboxUserInputRequestCommand, answerSandboxUserInputRequestInputSchema, getSandboxSessionByTaskIdCommand, + handlePrReviewNotificationActionCommand, + handlePrReviewNotificationActionInputSchema, saveDraftPromptCommand, sendSandboxPromptCommand, sendSandboxPromptInputSchema, @@ -2067,6 +2069,12 @@ export const appRouter = createRouter({ sendSandboxPromptCommand(auth, input), ), + handlePrReviewNotificationAction: protectedProcedure + .input(handlePrReviewNotificationActionInputSchema) + .mutation(({ ctx: { auth }, input }) => + handlePrReviewNotificationActionCommand(auth, input), + ), + answerUserInputRequest: protectedProcedure .input(answerSandboxUserInputRequestInputSchema) .mutation(({ ctx: { auth }, input }) => diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts index 8bbeadb58..280092a8a 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts @@ -1404,4 +1404,40 @@ describe('recordPrReviewNotificationDeliveryBestEffort', () => { expect(mockTrackSlackBotReply).not.toHaveBeenCalled(); expect(mockSetLatestSlackBotReply).not.toHaveBeenCalled(); }); + + it('persists actionable review state for web transcript controls', async () => { + await recordPrReviewNotificationDeliveryBestEffort({ + runId: 1, + taskId: 'task-1', + route: null, + text: 'formatted-message\nWould you like me to resolve this issue?', + action: { + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://github.com/owner/repo/pull/42', + question: 'Would you like me to resolve this issue?', + followUpPrompt: 'Resolve the review feedback.', + }, + }); + + expect(mockRecordTaskMessageEnvelope).toHaveBeenCalledWith({ + runId: 1, + taskId: 'task-1', + envelope: expect.objectContaining({ + payload: { + text: 'formatted-message\nWould you like me to resolve this issue?', + source: 'pr_review_notification', + prReviewAction: { + taskId: 'task-1', + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://github.com/owner/repo/pull/42', + question: 'Would you like me to resolve this issue?', + followUpPrompt: 'Resolve the review feedback.', + status: 'pending', + }, + }, + }), + }); + }); }); diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts index 8ae3f83bc..8571ed768 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts @@ -1124,6 +1124,13 @@ export async function recordPrReviewNotificationDeliveryBestEffort(params: { text: string; route?: PrReviewNotificationRoute | null; messageTs?: string | null; + action?: { + repository: string; + prNumber: number; + prUrl: string; + question: string; + followUpPrompt: string; + } | null; }): Promise { const route = params.route ?? null; const operations: Array<{ label: string; promise: Promise }> = [ @@ -1148,6 +1155,15 @@ export async function recordPrReviewNotificationDeliveryBestEffort(params: { payload: { text: params.text, source: PR_REVIEW_NOTIFICATION_TASK_MESSAGE_SOURCE, + ...(params.action + ? { + prReviewAction: { + taskId: params.taskId, + ...params.action, + status: 'pending', + }, + } + : {}), }, visibleInTranscript: true, }, diff --git a/packages/types/src/task-messages.ts b/packages/types/src/task-messages.ts index 1407b66c9..862209b8e 100644 --- a/packages/types/src/task-messages.ts +++ b/packages/types/src/task-messages.ts @@ -100,6 +100,64 @@ export const TRANSCRIPT_VISIBILITY_METADATA_KEY = 'visibleInTranscript'; export const PR_REVIEW_NOTIFICATION_TASK_MESSAGE_SOURCE = 'pr_review_notification'; +export type PrReviewNotificationActionStatus = + | 'pending' + | 'processing' + | 'resolved' + | 'auto_resolved' + | 'dismissed'; + +export const PR_REVIEW_ACTION_PROCESSING_LEASE_MS = 5 * 60 * 1000; + +export interface PrReviewNotificationAction { + taskId: string; + repository: string; + prNumber: number; + prUrl: string; + question: string; + followUpPrompt: string; + status: PrReviewNotificationActionStatus; + processingStartedAt?: number; + processingToken?: string; +} + +export function getPrReviewNotificationAction( + payload: Record | null | undefined, +): PrReviewNotificationAction | null { + const action = payload?.prReviewAction; + + if (!action || typeof action !== 'object' || Array.isArray(action)) { + return null; + } + + const value = action as Record; + const status = value.status; + + if ( + typeof value.taskId !== 'string' || + typeof value.repository !== 'string' || + typeof value.prNumber !== 'number' || + typeof value.prUrl !== 'string' || + typeof value.question !== 'string' || + typeof value.followUpPrompt !== 'string' || + (value.processingStartedAt !== undefined && + typeof value.processingStartedAt !== 'number') || + (value.processingToken !== undefined && + typeof value.processingToken !== 'string') || + ![ + 'pending', + 'processing', + 'resolved', + 'auto_resolved', + 'dismissed', + ].includes(typeof status === 'string' ? status : '') + ) { + return null; + } + + return value as unknown as PrReviewNotificationAction; +} + /** * `metadata.source` value for transcript messages that record a linked pull * request terminal status change (merged / closed). Written directly to task From e9570acb04443a432c36cbb7c2f9daf8470039a2 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:29:42 +0000 Subject: [PATCH 2/2] fix: scope review auto-resolve by provider --- .../src/handlers/discord/pr-review-action.ts | 1 + .../__tests__/pr-review-action.test.ts | 1 + .../slack/dispatch/pr-review-action.ts | 1 + .../src/handlers/telegram/pr-review-action.ts | 1 + .../src/jobs/pr-review-notification.test.ts | 1 + .../bullmq/src/jobs/pr-review-notification.ts | 5 ++ .../__tests__/AcpTextMessage.client.test.tsx | 2 + .../trpc/commands/sandbox-session/index.ts | 1 + .../sandbox-session/send-prompt.test.ts | 4 ++ .../pr-review-action-auto-handle.test.ts | 62 +++++++++++++++++++ .../pr-review-notification-delivery.test.ts | 2 + .../server/lib/task-runs/pr-review-action.ts | 5 ++ .../pr-review-notification-delivery.ts | 1 + packages/types/src/task-messages.ts | 7 +++ 14 files changed, 94 insertions(+) create mode 100644 packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action-auto-handle.test.ts diff --git a/apps/api/src/handlers/discord/pr-review-action.ts b/apps/api/src/handlers/discord/pr-review-action.ts index 5b0648836..70df005cf 100644 --- a/apps/api/src/handlers/discord/pr-review-action.ts +++ b/apps/api/src/handlers/discord/pr-review-action.ts @@ -88,6 +88,7 @@ export async function handleDiscordPrReviewActionCallback(input: { if (input.choice === 'auto') { await enableAutoHandlePrReviewFeedback({ taskId: pending.taskId, + sourceControlProvider: pending.sourceControlProvider ?? 'github', repository: pending.repository, prNumber: pending.prNumber, userId: mappedUserId!, diff --git a/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts b/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts index 8a029bb4e..5fd972ce3 100644 --- a/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts +++ b/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts @@ -245,6 +245,7 @@ describe('handleSlackPrReviewActionAuto', () => { expect(enableAutoHandleMock).toHaveBeenCalledWith({ taskId: 'task-1', + sourceControlProvider: 'github', repository: 'owner/repo', prNumber: 42, userId: 'user-1', diff --git a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts index 4b39ccc73..592fd2c0a 100644 --- a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts +++ b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts @@ -172,6 +172,7 @@ async function dispatchAcceptedPrReviewAction({ if (enableAutoHandle) { await enableAutoHandlePrReviewFeedback({ taskId: pending.taskId, + sourceControlProvider: pending.sourceControlProvider ?? 'github', repository: pending.repository, prNumber: pending.prNumber, userId, diff --git a/apps/api/src/handlers/telegram/pr-review-action.ts b/apps/api/src/handlers/telegram/pr-review-action.ts index bd23fc066..a90f2fc7f 100644 --- a/apps/api/src/handlers/telegram/pr-review-action.ts +++ b/apps/api/src/handlers/telegram/pr-review-action.ts @@ -84,6 +84,7 @@ export async function handleTelegramPrReviewActionCallback(params: { if (choice === 'auto') { await enableAutoHandlePrReviewFeedback({ taskId: pending.taskId, + sourceControlProvider: pending.sourceControlProvider ?? 'github', repository: pending.repository, prNumber: pending.prNumber, userId: senderUserId!, diff --git a/apps/bullmq/src/jobs/pr-review-notification.test.ts b/apps/bullmq/src/jobs/pr-review-notification.test.ts index 3b63e873d..2d451b600 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.test.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.test.ts @@ -793,6 +793,7 @@ describe('prReviewNotificationJob', () => { route: null, text: 'I reviewed owner/repo#42 on GitHub and found an issue.\nWould you like me to resolve this issue?', action: { + sourceControlProvider: 'github', repository: 'owner/repo', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', diff --git a/apps/bullmq/src/jobs/pr-review-notification.ts b/apps/bullmq/src/jobs/pr-review-notification.ts index ac46e5332..98827cf49 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.ts @@ -40,6 +40,7 @@ import { import { buildPrReviewActionCallbackData, isTaskExecutingTurn, + type SourceControlProvider, WORKER_HEARTBEAT_STALE_MS, } from '@roomote/types'; @@ -87,6 +88,7 @@ type PrReviewNotificationAction = { summaryText: string; question: string; followUpPrompt: string; + sourceControlProvider: SourceControlProvider; repository: string; prNumber: number; prUrl: string; @@ -126,6 +128,7 @@ async function postPrReviewNotification({ repository: action.repository, prNumber: action.prNumber, prUrl: action.prUrl, + sourceControlProvider: action.sourceControlProvider, channelId: route.channelId, threadId: route.threadId ?? null, followUpPrompt: action.followUpPrompt, @@ -441,6 +444,7 @@ ${delivery.text}`; summaryText: delivery.text, question: followUp.question, followUpPrompt: followUp.prompt, + sourceControlProvider: data.sourceControlProvider ?? 'github', repository: data.repository, prNumber: data.prNumber, prUrl: data.prUrl, @@ -461,6 +465,7 @@ ${delivery.text}`; ...(followUp && !delivery.route ? { action: { + sourceControlProvider: data.sourceControlProvider ?? 'github', repository: data.repository, prNumber: data.prNumber, prUrl: data.prUrl, diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx index 3d30f88a5..a8cf31d5b 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx @@ -149,6 +149,7 @@ describe('AcpTextMessage', () => { repository: 'owner/repo', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', + sourceControlProvider: 'github', question: 'Would you like me to resolve this issue?', followUpPrompt: 'Resolve the feedback.', status: 'pending', @@ -198,6 +199,7 @@ describe('AcpTextMessage', () => { repository: 'owner/repo', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', + sourceControlProvider: 'github', question: 'Would you like me to resolve this issue?', followUpPrompt: 'Resolve the feedback.', status: 'processing', diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index a248470c0..e0534c9d9 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -421,6 +421,7 @@ export async function handlePrReviewNotificationActionCommand( if (parsed.action === 'auto_resolve') { await enableAutoHandlePrReviewFeedback({ taskId: parsed.taskId, + sourceControlProvider: reviewAction.sourceControlProvider, repository: reviewAction.repository, prNumber: reviewAction.prNumber, userId: auth.userId, diff --git a/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts b/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts index 3ff2f8fc8..8fecdeb69 100644 --- a/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts +++ b/apps/web/src/trpc/commands/sandbox-session/send-prompt.test.ts @@ -153,6 +153,7 @@ async function createPendingReviewActionTestContext() { repository: 'owner/repo', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', + sourceControlProvider: 'github', question: 'Would you like me to resolve this issue?', followUpPrompt: 'Resolve the review feedback.', status: 'pending', @@ -627,6 +628,7 @@ describe('sendSandboxPromptCommand', () => { repository: 'owner/repo', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', + sourceControlProvider: 'github', question: 'Would you like me to resolve this issue?', followUpPrompt: 'Resolve the review feedback.', status: 'pending', @@ -691,6 +693,7 @@ describe('sendSandboxPromptCommand', () => { repository: 'owner/repo', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', + sourceControlProvider: 'github', question: 'Would you like me to resolve this issue?', followUpPrompt: 'Resolve the review feedback.', status: 'processing', @@ -760,6 +763,7 @@ describe('sendSandboxPromptCommand', () => { expect(mockEnableAutoHandlePrReviewFeedback).toHaveBeenCalledWith({ taskId: task.id, + sourceControlProvider: 'github', repository: 'owner/repo', prNumber: 42, userId: user.id, diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action-auto-handle.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action-auto-handle.test.ts new file mode 100644 index 000000000..a74878f12 --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-action-auto-handle.test.ts @@ -0,0 +1,62 @@ +import { + db, + eq, + taskFactory, + taskPullRequests, + userFactory, +} from '@roomote/db/server'; + +import { enableAutoHandlePrReviewFeedback } from '../pr-review-action'; + +describe('enableAutoHandlePrReviewFeedback', () => { + it('updates only the matching source-control provider', async () => { + const user = await userFactory.create(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await db.insert(taskPullRequests).values([ + { + taskId: task.id, + sourceControlProvider: 'github', + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://github.com/owner/repo/pull/42', + }, + { + taskId: task.id, + sourceControlProvider: 'gitlab', + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://gitlab.com/owner/repo/-/merge_requests/42', + }, + ]); + + await enableAutoHandlePrReviewFeedback({ + taskId: task.id, + sourceControlProvider: 'gitlab', + repository: 'owner/repo', + prNumber: 42, + userId: user.id, + }); + + const links = await db.query.taskPullRequests.findMany({ + where: eq(taskPullRequests.taskId, task.id), + columns: { + sourceControlProvider: true, + autoHandleFeedbackByUserId: true, + }, + }); + + expect(links).toEqual( + expect.arrayContaining([ + { + sourceControlProvider: 'github', + autoHandleFeedbackByUserId: null, + }, + { + sourceControlProvider: 'gitlab', + autoHandleFeedbackByUserId: user.id, + }, + ]), + ); + }); +}); diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts index 280092a8a..ce7ab1c9e 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts @@ -1412,6 +1412,7 @@ describe('recordPrReviewNotificationDeliveryBestEffort', () => { route: null, text: 'formatted-message\nWould you like me to resolve this issue?', action: { + sourceControlProvider: 'github', repository: 'owner/repo', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', @@ -1432,6 +1433,7 @@ describe('recordPrReviewNotificationDeliveryBestEffort', () => { repository: 'owner/repo', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', + sourceControlProvider: 'github', question: 'Would you like me to resolve this issue?', followUpPrompt: 'Resolve the review feedback.', status: 'pending', diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts index b18eb01b8..2e7ea8977 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-action.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-action.ts @@ -6,6 +6,7 @@ import { taskPullRequests, } from '@roomote/db/server'; import { getRedis } from '@roomote/redis'; +import type { SourceControlProvider } from '@roomote/types'; /** Conversation providers that can render PR review action buttons. */ export type PrReviewActionProvider = 'slack' | 'discord' | 'telegram'; @@ -30,6 +31,8 @@ export interface PendingPrReviewAction { repository: string; prNumber: number; prUrl: string; + /** Absent only on legacy pending records, which originated from GitHub. */ + sourceControlProvider?: SourceControlProvider; channelId: string; /** * Slack thread_ts, Discord thread channel id, or Telegram topic id; null @@ -271,6 +274,7 @@ export async function claimPendingPrReviewActionsForThread(input: { */ export async function enableAutoHandlePrReviewFeedback(input: { taskId: string; + sourceControlProvider: SourceControlProvider; repository: string; prNumber: number; userId: string; @@ -281,6 +285,7 @@ export async function enableAutoHandlePrReviewFeedback(input: { .where( and( eq(taskPullRequests.taskId, input.taskId), + eq(taskPullRequests.sourceControlProvider, input.sourceControlProvider), eq(taskPullRequests.repository, input.repository), eq(taskPullRequests.prNumber, input.prNumber), ), diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts index 8571ed768..9ff03ffd4 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts @@ -1125,6 +1125,7 @@ export async function recordPrReviewNotificationDeliveryBestEffort(params: { route?: PrReviewNotificationRoute | null; messageTs?: string | null; action?: { + sourceControlProvider: SourceControlProvider; repository: string; prNumber: number; prUrl: string; diff --git a/packages/types/src/task-messages.ts b/packages/types/src/task-messages.ts index 862209b8e..b006d4dc2 100644 --- a/packages/types/src/task-messages.ts +++ b/packages/types/src/task-messages.ts @@ -1,4 +1,5 @@ import { asBoolean } from './primitives'; +import type { SourceControlProvider } from './source-control'; export const ROOMOTE_RUNTIME_TASK_MESSAGE_PROTOCOL = 'roomote_runtime' as const; @@ -114,6 +115,7 @@ export interface PrReviewNotificationAction { repository: string; prNumber: number; prUrl: string; + sourceControlProvider: SourceControlProvider; question: string; followUpPrompt: string; status: PrReviewNotificationActionStatus; @@ -138,6 +140,11 @@ export function getPrReviewNotificationAction( typeof value.repository !== 'string' || typeof value.prNumber !== 'number' || typeof value.prUrl !== 'string' || + !['github', 'gitlab', 'gitea', 'ado', 'bitbucket'].includes( + typeof value.sourceControlProvider === 'string' + ? value.sourceControlProvider + : '', + ) || typeof value.question !== 'string' || typeof value.followUpPrompt !== 'string' || (value.processingStartedAt !== undefined &&