From 18a6da391071152f3a43cfbf37a9e628d2bd3f41 Mon Sep 17 00:00:00 2001
From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com>
Date: Fri, 7 Aug 2026 22:13:36 +0000
Subject: [PATCH 1/3] feat: show all task pull requests
---
.../communication-thread-replies.test.ts | 1 +
.../slack-thread-reply-quotes.test.ts | 1 +
apps/api/src/handlers/mcp/slack.ts | 17 +++---
.../app/(sandbox)/task/[taskId]/Header.tsx | 29 +++++++---
.../task/[taskId]/hooks/use-task-session.ts | 5 ++
.../[taskId]/sidebar-panels/TaskInfoPanel.tsx | 23 +++++++-
.../components/sandbox/PullRequestBadge.tsx | 6 ++-
apps/web/src/lib/server/task-runs.ts | 54 +++++++++++++++++++
.../trpc/commands/sandbox-session/index.ts | 14 ++++-
apps/web/src/trpc/commands/tasks/by-id.ts | 34 +++++++-----
apps/web/src/types/task.ts | 5 ++
.../src/__tests__/chat-messages.test.ts | 18 +++++++
.../thread-reply-footer-context.test.ts | 4 ++
packages/communication/src/chat-messages.ts | 18 ++++++-
.../src/thread-reply-footer-context.ts | 36 +++++++++++++
packages/sdk/src/server/routers/task-runs.ts | 21 ++++++--
.../slack/src/__tests__/thread-footer.test.ts | 4 ++
packages/slack/src/automation-root-footer.ts | 19 ++++---
packages/slack/src/thread-footer.ts | 11 +++-
packages/slack/src/thread-reply-footer-ops.ts | 1 +
20 files changed, 273 insertions(+), 48 deletions(-)
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.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__/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..6f8fd40cb 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,26 @@ 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]
+ : [];
+
+ for (const [index, linkedPrUrl] of linkedPrUrls.entries()) {
actionElements.push({
type: 'button',
action_id: 'late_bound_automation_view_pr',
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 +104,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 +122,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,
});
From 6d1dc65505e2cd0387d3ab15438e4c7a48605dfb Mon Sep 17 00:00:00 2001
From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com>
Date: Fri, 7 Aug 2026 22:22:18 +0000
Subject: [PATCH 2/3] fix: keep multi-pr automation footers valid
---
.../__tests__/automation-root-footer.test.ts | 48 +++++++++++++++++++
packages/slack/src/automation-root-footer.ts | 5 +-
2 files changed, 51 insertions(+), 2 deletions(-)
create mode 100644 packages/slack/src/__tests__/automation-root-footer.test.ts
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/automation-root-footer.ts b/packages/slack/src/automation-root-footer.ts
index 6f8fd40cb..9bdce71a4 100644
--- a/packages/slack/src/automation-root-footer.ts
+++ b/packages/slack/src/automation-root-footer.ts
@@ -22,10 +22,11 @@ export function buildAutomationRootFooterBlocks(params: {
? [params.linkedPrUrl]
: [];
- for (const [index, linkedPrUrl] of linkedPrUrls.entries()) {
+ // 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: linkedPrUrls.length === 1 ? 'See PR' : `See PR ${index + 1}`,
From 00097954bfcec3f088f1a62927539cc6e9132f37 Mon Sep 17 00:00:00 2001
From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com>
Date: Sat, 8 Aug 2026 17:06:46 +0000
Subject: [PATCH 3/3] test: update Slack footer expectation
---
packages/sdk/src/server/routers/task-runs.test.ts | 1 +
1 file changed, 1 insertion(+)
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',
});