diff --git a/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts b/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts index aaf6ada5c..99dfe4c3f 100644 --- a/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts @@ -122,6 +122,7 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('@roomote/slack', () => ({ resolveSlackThreadLinkedPr: vi.fn(), + resolveSlackThreadLinkedPrs: vi.fn(), resolveSlackThreadLivePreviewUrl: vi.fn(), })); diff --git a/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts b/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts index 9097b1bcf..bda15fde5 100644 --- a/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts @@ -70,6 +70,7 @@ vi.mock('@roomote/slack', async (importOriginal) => ({ livePreviewUrl: null, }), resolveSlackThreadLinkedPr: vi.fn(), + resolveSlackThreadLinkedPrs: vi.fn(), resolveSlackThreadLivePreviewUrl: vi.fn(), ROOMOTE_THREAD_REPLY_QUOTE_BLOCK_ID: 'roomote_thread_reply_quote', setLatestSlackBotReply: vi.fn(), diff --git a/apps/api/src/handlers/mcp/slack.ts b/apps/api/src/handlers/mcp/slack.ts index 1790a7e74..d60520ebb 100644 --- a/apps/api/src/handlers/mcp/slack.ts +++ b/apps/api/src/handlers/mcp/slack.ts @@ -29,7 +29,7 @@ import { getSlackThreadReplyFooterMessageTs, removeSlackThreadReplyFooter, resolveSlackThreadFooterContext, - resolveSlackThreadLinkedPr, + resolveSlackThreadLinkedPrs, resolveSlackThreadLivePreviewUrl, setLatestSlackBotReply, setSlackThreadReplyFooterMessageTs, @@ -138,8 +138,8 @@ async function buildLateBoundSlackRootFooterText(params: { // The explicit-mention marker is per-thread, so a brand-new root message // can never carry it; only the linked PR and live preview need resolving // here. PR metadata lives in taskPullRequests and is resolved by task id. - const [linkedPr, livePreviewUrl] = await Promise.all([ - resolveSlackThreadLinkedPr({ + const [linkedPrs, livePreviewUrl] = await Promise.all([ + resolveSlackThreadLinkedPrs({ taskId: params.taskId, prRepo: null, prNumber: null, @@ -149,7 +149,8 @@ async function buildLateBoundSlackRootFooterText(params: { return buildSlackThreadFooterText({ taskUrl: params.taskUrl, - linkedPr, + linkedPr: linkedPrs[0] ?? null, + linkedPrs, livePreviewUrl, explicitMentionRequired: false, }); @@ -177,7 +178,7 @@ async function buildLateBoundAutomationRootFooterBlocks(params: { const automationLabel = getTriggerableBackgroundAutomationDescriptorByKey(workItem.automationKey) ?.label ?? workItem.automationKey.replaceAll('_', ' '); - const linkedPr = await resolveSlackThreadLinkedPr({ + const linkedPrs = await resolveSlackThreadLinkedPrs({ taskId: params.taskId, prRepo: null, prNumber: null, @@ -185,7 +186,7 @@ async function buildLateBoundAutomationRootFooterBlocks(params: { return buildAutomationRootFooterBlocks({ automationLabel, taskUrl: params.taskUrl, - linkedPrUrl: linkedPr?.prUrl ?? null, + linkedPrUrls: linkedPrs.map((pr) => pr.prUrl), }); } @@ -200,7 +201,7 @@ async function buildLateBoundCustomAutomationRootFooterBlocks(params: { return null; } - const linkedPr = await resolveSlackThreadLinkedPr({ + const linkedPrs = await resolveSlackThreadLinkedPrs({ taskId: params.taskId, prRepo: null, prNumber: null, @@ -208,7 +209,7 @@ async function buildLateBoundCustomAutomationRootFooterBlocks(params: { return buildAutomationRootFooterBlocks({ automationLabel: automation.name, taskUrl: params.taskUrl, - linkedPrUrl: linkedPr?.prUrl ?? null, + linkedPrUrls: linkedPrs.map((pr) => pr.prUrl), }); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index a0a2d35eb..fd3a1c69a 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -35,6 +35,7 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const repo = taskRun?.payload?.repo; const prRepo = taskRun?.prRepo; const prNumber = taskRun?.prNumber; + const pullRequests = taskRun?.pullRequests ?? []; const badges = [ (environmentId || repo) && ( @@ -45,14 +46,26 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { iconClassName="text-muted-foreground" /> ), - prRepo && prNumber && ( - - ), + ...(pullRequests.length > 0 + ? pullRequests.map((pullRequest) => ( + + )) + : prRepo && prNumber + ? [ + , + ] + : []), ].filter(Boolean); const updateTaskTitle = useMutation(trpc.tasks.updateTitle.mutationOptions()); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts index 655832f78..54c64a5db 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-task-session.ts @@ -47,6 +47,11 @@ export interface SandboxConnectionTarget { export type SessionTaskRun = TaskRunDetail & { prRepo: string | null; prNumber: number | null; + pullRequests?: Array<{ + repository: string; + prNumber: number; + prUrl?: string; + }>; previewProxyBaseUrl?: string; /** Server-derived: whether a failed start may be relaunched. */ canRetryFailedStart?: boolean; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx index 6850a3d71..39f27aaa6 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx @@ -421,7 +421,26 @@ export function TaskInfoPanel({ )} - {taskRun.prRepo && taskRun.prNumber && ( + {(taskRun.pullRequests?.length ?? 0) > 0 ? ( + + + Pull Requests + + +
+ {taskRun.pullRequests?.map((pullRequest) => ( + + ))} +
+ + + ) : taskRun.prRepo && taskRun.prNumber ? ( Pull Request @@ -434,7 +453,7 @@ export function TaskInfoPanel({ /> - )} + ) : null} {linkedWorkItems.length > 0 ? ( diff --git a/apps/web/src/components/sandbox/PullRequestBadge.tsx b/apps/web/src/components/sandbox/PullRequestBadge.tsx index 1b3e53629..7b3424ad9 100644 --- a/apps/web/src/components/sandbox/PullRequestBadge.tsx +++ b/apps/web/src/components/sandbox/PullRequestBadge.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'; interface PullRequestBadgeProps { repo: string; prNumber: number; + url?: string; className?: string; iconClassName?: string; } @@ -12,15 +13,16 @@ interface PullRequestBadgeProps { export function PullRequestBadge({ repo, prNumber, + url, className, iconClassName, }: PullRequestBadgeProps) { - const url = `https://github.com/${repo}/pull/${prNumber}`; + const pullRequestUrl = url ?? `https://github.com/${repo}/pull/${prNumber}`; const repoName = repo.split('/')[1] ?? repo; return ( > + > +> => { + if (taskIds.length === 0) { + return {}; + } + + const results = await db + .select({ + taskId: taskPullRequests.taskId, + repository: taskPullRequests.repository, + prNumber: taskPullRequests.prNumber, + prUrl: taskPullRequests.prUrl, + }) + .from(taskPullRequests) + .where( + and( + inArray(taskPullRequests.taskId, taskIds), + isNotNull(taskPullRequests.repository), + isNotNull(taskPullRequests.prNumber), + ), + ) + .orderBy(taskPullRequests.taskId, desc(taskPullRequests.detectedAt)); + + const pullRequestsByTask = new Map< + string, + Array> + >(); + + for (const row of results) { + if (!row.repository || row.prNumber === null) { + continue; + } + + const pullRequests = pullRequestsByTask.get(row.taskId) ?? []; + if (!pullRequests.some((pr) => pr.prUrl === row.prUrl)) { + pullRequests.push({ + repository: row.repository, + prNumber: row.prNumber, + prUrl: row.prUrl ?? undefined, + }); + pullRequestsByTask.set(row.taskId, pullRequests); + } + } + + return Object.fromEntries(pullRequestsByTask); +}; + export const getLatestTaskPullRequestsByTaskId = async ( taskIds: string[], ): Promise> => { diff --git a/apps/web/src/trpc/commands/sandbox-session/index.ts b/apps/web/src/trpc/commands/sandbox-session/index.ts index dc16ef54d..e84a54b9b 100644 --- a/apps/web/src/trpc/commands/sandbox-session/index.ts +++ b/apps/web/src/trpc/commands/sandbox-session/index.ts @@ -504,16 +504,28 @@ type ResolvedSandboxTaskAccess = Extract< type SandboxTaskRunDetail = TaskRunDetail & { prRepo: string | null; prNumber: number | null; + pullRequests?: Array<{ + repository: string; + prNumber: number; + prUrl?: string; + }>; }; function applyResolvedTaskPullRequestFallback( taskRun: T, taskTaskRun: ResolvedSandboxTaskAccess['task']['taskRun'], -): T & { prRepo: string | null; prNumber: number | null } { +): T & { + prRepo: string | null; + prNumber: number | null; + pullRequests: NonNullable< + ResolvedSandboxTaskAccess['task']['taskRun'] + >['pullRequests']; +} { return { ...taskRun, prRepo: taskTaskRun?.prRepo ?? null, prNumber: taskTaskRun?.prNumber ?? null, + pullRequests: taskTaskRun?.pullRequests ?? [], }; } diff --git a/apps/web/src/trpc/commands/tasks/by-id.ts b/apps/web/src/trpc/commands/tasks/by-id.ts index b01e51bb9..f2cf7fcde 100644 --- a/apps/web/src/trpc/commands/tasks/by-id.ts +++ b/apps/web/src/trpc/commands/tasks/by-id.ts @@ -19,6 +19,7 @@ import type { import { getArtifactsForTask, getLatestTaskPullRequestsByTaskId, + getTaskPullRequestsByTaskId, } from '@/lib/server'; import { resolveTaskCreatorDisplay } from '@/lib/server/tasks'; @@ -66,19 +67,24 @@ async function getTaskByIdForCurrentOrg( includeArtifacts = false, }: { taskId: string; includeArtifacts?: boolean }, ): Promise { - const [[result], taskPullRequestsByTaskId, inferenceUsage] = - await Promise.all([ - db - .select({ task: tasks, user: users, taskRun: taskRuns }) - .from(tasks) - .leftJoin(users, eq(tasks.initiatorUserId, users.id)) - .leftJoin(taskRuns, eq(taskRuns.taskId, tasks.id)) - .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) - .orderBy(desc(taskRuns.id)) - .limit(1), - getLatestTaskPullRequestsByTaskId([taskId]), - getTaskInferenceUsageByTaskId(taskId), - ]); + const [ + [result], + taskPullRequestsByTaskId, + allTaskPullRequestsByTaskId, + inferenceUsage, + ] = await Promise.all([ + db + .select({ task: tasks, user: users, taskRun: taskRuns }) + .from(tasks) + .leftJoin(users, eq(tasks.initiatorUserId, users.id)) + .leftJoin(taskRuns, eq(taskRuns.taskId, tasks.id)) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .orderBy(desc(taskRuns.id)) + .limit(1), + getLatestTaskPullRequestsByTaskId([taskId]), + getTaskPullRequestsByTaskId([taskId]), + getTaskInferenceUsageByTaskId(taskId), + ]); if (!result) { return null; @@ -87,6 +93,7 @@ async function getTaskByIdForCurrentOrg( const { task, user, taskRun } = result; const creator = resolveTaskCreatorDisplay(task, user); const latestPullRequest = taskPullRequestsByTaskId[taskId]; + const pullRequests = allTaskPullRequestsByTaskId[taskId] ?? []; const taskData: TaskWithAssociations = { ...task, @@ -98,6 +105,7 @@ async function getTaskByIdForCurrentOrg( ...taskRun, prRepo: latestPullRequest?.repository ?? null, prNumber: latestPullRequest?.prNumber ?? null, + pullRequests, } : null, inferenceUsage, diff --git a/apps/web/src/types/task.ts b/apps/web/src/types/task.ts index 920e1e469..0fd76097e 100644 --- a/apps/web/src/types/task.ts +++ b/apps/web/src/types/task.ts @@ -88,6 +88,11 @@ export type ArtifactWithContent = { export type TaskRunWithPullRequest = TaskRun & { prRepo: string | null; prNumber: number | null; + pullRequests?: Array<{ + repository: string; + prNumber: number; + prUrl?: string; + }>; }; export type TaskWithAssociations = Task & { diff --git a/packages/communication/src/__tests__/chat-messages.test.ts b/packages/communication/src/__tests__/chat-messages.test.ts index 6295edcf0..eef621c6a 100644 --- a/packages/communication/src/__tests__/chat-messages.test.ts +++ b/packages/communication/src/__tests__/chat-messages.test.ts @@ -259,6 +259,24 @@ describe('chat message copy builders', () => { '_Working on [PR #7](https://github.com/org/repo/pull/7), [live preview](https://preview.roomote.dev), reply or use the [web app](https://roomote.dev/task/123)._', ); + expect( + buildThreadReplyFooterText({ + taskUrl: 'https://roomote.dev/task/123', + linkedPrs: [ + { + prNumber: 7, + prUrl: 'https://github.com/org/repo/pull/7', + }, + { + prNumber: 8, + prUrl: 'https://github.com/org/other-repo/pull/8', + }, + ], + }), + ).toBe( + '_Working on [PR #7](https://github.com/org/repo/pull/7) and [PR #8](https://github.com/org/other-repo/pull/8), reply or use the [web app](https://roomote.dev/task/123)._', + ); + expect( buildThreadReplyFooterText({ taskUrl: 'https://roomote.dev/task/123', diff --git a/packages/communication/src/__tests__/thread-reply-footer-context.test.ts b/packages/communication/src/__tests__/thread-reply-footer-context.test.ts index 5f3663cee..9b83cc83d 100644 --- a/packages/communication/src/__tests__/thread-reply-footer-context.test.ts +++ b/packages/communication/src/__tests__/thread-reply-footer-context.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; const { findFirstMock, + findManyMock, taskRunFindFirstMock, environmentFindFirstMock, resolveEffectivePreviewRuntimeConfigMock, } = vi.hoisted(() => ({ findFirstMock: vi.fn(), + findManyMock: vi.fn(), taskRunFindFirstMock: vi.fn(), environmentFindFirstMock: vi.fn(), resolveEffectivePreviewRuntimeConfigMock: vi.fn(), @@ -17,6 +19,7 @@ vi.mock('@roomote/db/server', () => ({ query: { taskPullRequests: { findFirst: findFirstMock, + findMany: findManyMock, }, taskRuns: { findFirst: taskRunFindFirstMock, @@ -66,6 +69,7 @@ describe('thread reply footer context', () => { beforeEach(() => { vi.clearAllMocks(); findFirstMock.mockResolvedValue(null); + findManyMock.mockResolvedValue([]); taskRunFindFirstMock.mockResolvedValue(null); environmentFindFirstMock.mockResolvedValue(null); resolveEffectivePreviewRuntimeConfigMock.mockResolvedValue({ diff --git a/packages/communication/src/chat-messages.ts b/packages/communication/src/chat-messages.ts index 83835add2..268ee75dc 100644 --- a/packages/communication/src/chat-messages.ts +++ b/packages/communication/src/chat-messages.ts @@ -292,6 +292,7 @@ export type ThreadReplyLinkedPr = { export function buildThreadReplyFooterText({ taskUrl, linkedPr, + linkedPrs, livePreviewUrl, explicitMentionRequired = false, formatLink = formatMarkdownLink, @@ -299,6 +300,7 @@ export function buildThreadReplyFooterText({ }: { taskUrl: string; linkedPr?: ThreadReplyLinkedPr | null; + linkedPrs?: ThreadReplyLinkedPr[]; livePreviewUrl?: string | null; explicitMentionRequired?: boolean; formatLink?: LinkFormatter; @@ -312,8 +314,20 @@ export function buildThreadReplyFooterText({ : null; const webAppLink = formatLink('web app', taskUrl); - if (linkedPr) { - const prLink = formatLink(`PR #${linkedPr.prNumber}`, linkedPr.prUrl); + const activePullRequests = linkedPrs?.length + ? linkedPrs + : linkedPr + ? [linkedPr] + : []; + + if (activePullRequests.length > 0) { + const prLinks = activePullRequests.map((pr) => + formatLink(`PR #${pr.prNumber}`, pr.prUrl), + ); + const prLink = + prLinks.length === 1 + ? prLinks[0] + : `${prLinks.slice(0, -1).join(', ')} and ${prLinks.at(-1)}`; const workingOn = livePreviewLink ? `${prLink}, ${livePreviewLink}` : prLink; diff --git a/packages/communication/src/thread-reply-footer-context.ts b/packages/communication/src/thread-reply-footer-context.ts index 4c6d7aefc..9abec3635 100644 --- a/packages/communication/src/thread-reply-footer-context.ts +++ b/packages/communication/src/thread-reply-footer-context.ts @@ -88,6 +88,42 @@ export async function resolveThreadReplyLinkedPr(params: { return null; } +export async function resolveThreadReplyLinkedPrs(params: { + taskId: string | null | undefined; + prRepo: string | null | undefined; + prNumber: number | null | undefined; +}): Promise { + const linkedTaskPrs = params.taskId + ? await db.query.taskPullRequests.findMany({ + columns: { + prUrl: true, + prNumber: true, + status: true, + }, + where: eq(taskPullRequests.taskId, params.taskId), + orderBy: (table, { desc }) => [ + desc(table.detectedAt), + desc(table.createdAt), + ], + }) + : []; + + const activeTaskPrs = linkedTaskPrs.flatMap((pr) => + pr.status && TERMINAL_LINKED_TASK_PR_STATUSES.has(pr.status) + ? [] + : typeof pr.prNumber === 'number' && typeof pr.prUrl === 'string' + ? [{ prNumber: pr.prNumber, prUrl: pr.prUrl }] + : [], + ); + + if (activeTaskPrs.length > 0) { + return activeTaskPrs; + } + + const fallbackPr = await resolveThreadReplyLinkedPr(params); + return fallbackPr ? [fallbackPr] : []; +} + /** * Resolves the shareable live-preview URL for an environment-backed task. * diff --git a/packages/sdk/src/server/routers/task-runs.test.ts b/packages/sdk/src/server/routers/task-runs.test.ts index c46b45b8b..82d2011a3 100644 --- a/packages/sdk/src/server/routers/task-runs.test.ts +++ b/packages/sdk/src/server/routers/task-runs.test.ts @@ -431,6 +431,7 @@ describe('taskRunsRouter queue message guards', () => { taskId: 'task-1', prRepo: null, prNumber: null, + linkedPrs: [], channelId: 'C123', threadTs: '1710000000.123', }); diff --git a/packages/sdk/src/server/routers/task-runs.ts b/packages/sdk/src/server/routers/task-runs.ts index dae3efb45..0161cf0a3 100644 --- a/packages/sdk/src/server/routers/task-runs.ts +++ b/packages/sdk/src/server/routers/task-runs.ts @@ -611,9 +611,9 @@ export const taskRunsRouter = router({ }); } - // PR linkage lives on task_pull_requests; use the task's primary - // (earliest-detected) GitHub PR for the footer link. - const linkedPr = await db.query.taskPullRequests.findFirst({ + // PR linkage lives on task_pull_requests; include every active GitHub PR + // so the existing footer can point users to the full task split. + const linkedPrs = await db.query.taskPullRequests.findMany({ where: and( eq(taskPullRequests.taskId, taskRun.taskId), eq(taskPullRequests.sourceControlProvider, 'github'), @@ -624,14 +624,25 @@ export const taskRunsRouter = router({ columns: { repository: true, prNumber: true, + prUrl: true, + status: true, }, }); + const activeLinkedPrs = linkedPrs.filter( + (pr) => pr.status !== 'closed' && pr.status !== 'merged', + ); + return buildSlackThreadFooterText({ taskUrl: input.taskUrl, taskId: taskRun.taskId, - prRepo: linkedPr?.repository ?? null, - prNumber: linkedPr?.prNumber ?? null, + prRepo: activeLinkedPrs[0]?.repository ?? null, + prNumber: activeLinkedPrs[0]?.prNumber ?? null, + linkedPrs: activeLinkedPrs.flatMap((pr) => + pr.prNumber !== null && pr.prUrl + ? [{ prNumber: pr.prNumber, prUrl: pr.prUrl }] + : [], + ), channelId: input.slackChannelId, threadTs: input.threadTs, }); diff --git a/packages/slack/src/__tests__/automation-root-footer.test.ts b/packages/slack/src/__tests__/automation-root-footer.test.ts new file mode 100644 index 000000000..4bd5f6e59 --- /dev/null +++ b/packages/slack/src/__tests__/automation-root-footer.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAutomationRootFooterBlocks } from '../automation-root-footer'; + +function getActions( + blocks: ReturnType, +) { + const actionsBlock = blocks.find((block) => block.type === 'actions'); + + return actionsBlock?.type === 'actions' ? (actionsBlock.elements ?? []) : []; +} + +describe('buildAutomationRootFooterBlocks', () => { + it('uses unique action IDs for multiple pull requests', () => { + const actions = getActions( + buildAutomationRootFooterBlocks({ + automationLabel: 'Demo automation', + linkedPrUrls: [ + 'https://github.com/org/repo/pull/1', + 'https://github.com/org/repo/pull/2', + ], + taskUrl: 'https://roomote.dev/task/1', + }), + ); + + expect(actions.map((action) => action.action_id)).toEqual([ + 'late_bound_automation_view_pr_1', + 'late_bound_automation_view_pr_2', + 'late_bound_automation_view_task', + ]); + }); + + it('caps pull request buttons so the task action fits Slack limits', () => { + const actions = getActions( + buildAutomationRootFooterBlocks({ + automationLabel: 'Demo automation', + linkedPrUrls: Array.from( + { length: 30 }, + (_, index) => `https://github.com/org/repo/pull/${index + 1}`, + ), + taskUrl: 'https://roomote.dev/task/1', + }), + ); + + expect(actions).toHaveLength(25); + expect(actions.at(-1)?.action_id).toBe('late_bound_automation_view_task'); + }); +}); diff --git a/packages/slack/src/__tests__/thread-footer.test.ts b/packages/slack/src/__tests__/thread-footer.test.ts index 700308618..87c5ff306 100644 --- a/packages/slack/src/__tests__/thread-footer.test.ts +++ b/packages/slack/src/__tests__/thread-footer.test.ts @@ -1,11 +1,13 @@ const { findFirstMock, + findManyMock, taskRunFindFirstMock, environmentFindFirstMock, resolveEffectivePreviewRuntimeConfigMock, redisGetMock, } = vi.hoisted(() => ({ findFirstMock: vi.fn(), + findManyMock: vi.fn(), taskRunFindFirstMock: vi.fn(), environmentFindFirstMock: vi.fn(), resolveEffectivePreviewRuntimeConfigMock: vi.fn(), @@ -17,6 +19,7 @@ vi.mock('@roomote/db/server', () => ({ query: { taskPullRequests: { findFirst: findFirstMock, + findMany: findManyMock, }, taskRuns: { findFirst: taskRunFindFirstMock, @@ -68,6 +71,7 @@ describe('getSlackThreadFooterText', () => { beforeEach(() => { vi.clearAllMocks(); findFirstMock.mockResolvedValue(null); + findManyMock.mockResolvedValue([]); taskRunFindFirstMock.mockResolvedValue(null); environmentFindFirstMock.mockResolvedValue(null); resolveEffectivePreviewRuntimeConfigMock.mockResolvedValue({ diff --git a/packages/slack/src/automation-root-footer.ts b/packages/slack/src/automation-root-footer.ts index b540cfbbf..9bdce71a4 100644 --- a/packages/slack/src/automation-root-footer.ts +++ b/packages/slack/src/automation-root-footer.ts @@ -1,5 +1,5 @@ import type { SlackBlock } from '@roomote/types'; -import { resolveThreadReplyLinkedPr } from '@roomote/communication'; +import { resolveThreadReplyLinkedPrs } from '@roomote/communication'; import type { SlackNotifier } from './slack-notifier'; @@ -12,19 +12,27 @@ export function buildAutomationRootFooterBlocks(params: { automationLabel: string; taskUrl?: string | null; linkedPrUrl?: string | null; + linkedPrUrls?: string[]; }): SlackBlock[] { const actionElements: Record[] = []; - if (params.linkedPrUrl) { + const linkedPrUrls = params.linkedPrUrls?.length + ? params.linkedPrUrls + : params.linkedPrUrl + ? [params.linkedPrUrl] + : []; + + // Leave room for the task button in Slack's 25-element actions-block limit. + for (const [index, linkedPrUrl] of linkedPrUrls.slice(0, 24).entries()) { actionElements.push({ type: 'button', - action_id: 'late_bound_automation_view_pr', + action_id: `late_bound_automation_view_pr_${index + 1}`, text: { type: 'plain_text', - text: 'See PR', + text: linkedPrUrls.length === 1 ? 'See PR' : `See PR ${index + 1}`, emoji: false, }, - url: params.linkedPrUrl, + url: linkedPrUrl, }); } @@ -97,7 +105,7 @@ export async function refreshAutomationRootFooter(params: { return false; } - const linkedPr = await resolveThreadReplyLinkedPr({ + const linkedPrs = await resolveThreadReplyLinkedPrs({ taskId: params.taskId ?? null, prRepo: params.prRepo ?? null, prNumber: params.prNumber ?? null, @@ -115,7 +123,7 @@ export async function refreshAutomationRootFooter(params: { ...buildAutomationRootFooterBlocks({ automationLabel: params.automationLabel, taskUrl: params.taskUrl ?? null, - linkedPrUrl: linkedPr?.prUrl ?? null, + linkedPrUrls: linkedPrs.map((pr) => pr.prUrl), }), ], }, diff --git a/packages/slack/src/thread-footer.ts b/packages/slack/src/thread-footer.ts index a9ccc9554..76a50af8a 100644 --- a/packages/slack/src/thread-footer.ts +++ b/packages/slack/src/thread-footer.ts @@ -3,6 +3,7 @@ import { buildThreadReplyPrUrl, resolveThreadReplyFooterContext, resolveThreadReplyLinkedPr, + resolveThreadReplyLinkedPrs, resolveThreadReplyLivePreviewUrl, type ThreadReplyFooterContext, type ThreadReplyLinkedPr, @@ -14,11 +15,13 @@ export type SlackThreadLinkedPr = ThreadReplyLinkedPr; export interface SlackThreadFooterContext extends ThreadReplyFooterContext { explicitMentionRequired: boolean; + linkedPrs?: SlackThreadLinkedPr[]; } export { buildThreadReplyPrUrl as buildSlackThreadReplyPrUrl, resolveThreadReplyLinkedPr as resolveSlackThreadLinkedPr, + resolveThreadReplyLinkedPrs as resolveSlackThreadLinkedPrs, resolveThreadReplyLivePreviewUrl as resolveSlackThreadLivePreviewUrl, }; @@ -29,13 +32,15 @@ export async function resolveSlackThreadFooterContext(params: { channelId: string; threadTs: string; }): Promise { - const [context, explicitMentionRequired] = await Promise.all([ + const [context, linkedPrs, explicitMentionRequired] = await Promise.all([ resolveThreadReplyFooterContext(params), + resolveThreadReplyLinkedPrs(params), isSlackThreadExplicitMentionRequired(params.channelId, params.threadTs), ]); return { ...context, + linkedPrs, explicitMentionRequired, }; } @@ -43,12 +48,14 @@ export async function resolveSlackThreadFooterContext(params: { export function buildSlackThreadFooterText(params: { taskUrl: string; linkedPr: SlackThreadLinkedPr | null; + linkedPrs?: SlackThreadLinkedPr[]; livePreviewUrl?: string | null; explicitMentionRequired: boolean; }): string { return buildThreadReplyFooterText({ taskUrl: params.taskUrl, linkedPr: params.linkedPr, + linkedPrs: params.linkedPrs, livePreviewUrl: params.livePreviewUrl, explicitMentionRequired: params.explicitMentionRequired, formatLink: (label, url) => `<${url}|${label}>`, @@ -60,6 +67,7 @@ export async function getSlackThreadFooterText(params: { taskId: string | null | undefined; prRepo: string | null | undefined; prNumber: number | null | undefined; + linkedPrs?: SlackThreadLinkedPr[]; channelId: string; threadTs: string; }): Promise { @@ -68,6 +76,7 @@ export async function getSlackThreadFooterText(params: { return buildSlackThreadFooterText({ taskUrl: params.taskUrl, linkedPr: context.linkedPr, + linkedPrs: params.linkedPrs ?? context.linkedPrs, livePreviewUrl: context.livePreviewUrl, explicitMentionRequired: context.explicitMentionRequired, }); diff --git a/packages/slack/src/thread-reply-footer-ops.ts b/packages/slack/src/thread-reply-footer-ops.ts index e1678ab10..effcd1dc4 100644 --- a/packages/slack/src/thread-reply-footer-ops.ts +++ b/packages/slack/src/thread-reply-footer-ops.ts @@ -219,6 +219,7 @@ export async function postSlackThreadMessageWithStickyFooter(params: { const footerText = buildSlackThreadFooterText({ taskUrl, linkedPr: replyOnly ? null : footerContext.linkedPr, + linkedPrs: replyOnly ? undefined : footerContext.linkedPrs, livePreviewUrl: replyOnly ? null : footerContext.livePreviewUrl, explicitMentionRequired: footerContext.explicitMentionRequired, });