From 56d2e4e9e194fc4ceb2cefc00f1141aa30d6a192 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 13 Jul 2026 18:30:22 -0500 Subject: [PATCH 1/2] Post announcer and manager-stats reports via communication adapters The last two Slack-hardwired automation reporters now reach Teams and Telegram destinations. Slack destinations keep the existing SlackNotifier + Block Kit path byte-identical; non-Slack destinations post through getCommunicationProviderAdapter with the summary degraded from Slack mrkdwn to standard markdown (new degradeSlackMrkdwnToMarkdown helper), the settings-link footer becoming a cross-surface url button, detail messages threaded under the root, and tracked automation threads upserted with the destination surface. Deployment eligibility generalizes like the triage runner: Slack installations first, otherwise any connected communication provider (manager-stats resolves its GitHub actor from the installation row on Slack-less deployments). Registry declarations flip to slack/teams/telegram for both. ci_failure_triage and the suggester's advanced routing intentionally stay Slack-only. Co-Authored-By: Claude Fable 5 --- .../automations/__tests__/announcer.test.ts | 313 ++++++++++++++++++ .../sdk/src/server/automations/announcer.ts | 219 +++++++++--- .../src/server/automations/manager-stats.ts | 135 +++++++- .../lib/__tests__/manager-slack.test.ts | 19 ++ packages/sdk/src/server/lib/manager-slack.ts | 14 + .../src/background-automation-registry.ts | 4 +- 6 files changed, 638 insertions(+), 66 deletions(-) create mode 100644 packages/sdk/src/server/automations/__tests__/announcer.test.ts diff --git a/packages/sdk/src/server/automations/__tests__/announcer.test.ts b/packages/sdk/src/server/automations/__tests__/announcer.test.ts new file mode 100644 index 000000000..7bb00f173 --- /dev/null +++ b/packages/sdk/src/server/automations/__tests__/announcer.test.ts @@ -0,0 +1,313 @@ +const { + slackInstallationsTable, + taskPullRequestsTable, + mockSlackInstallationRows, + mockMergedPullRequestRows, + mockGetAutomationRuntime, + mockRecordAutomationRunOutcome, + mockUpsertBackgroundAutomationSlackThread, + mockResolveAutomationRuntimeDestination, + mockListConnectedCommunicationProviders, + mockHasAnyActiveRepository, + mockGetCommunicationProviderAdapter, + mockLoadAutomationThreadFeedbackContext, + mockGenerateTrackedNonTaskText, + mockSlackNotifier, + mockAdapterPostMessage, +} = vi.hoisted(() => ({ + slackInstallationsTable: { + botAccessToken: 'botAccessToken', + teamId: 'teamId', + isActive: 'isActive', + }, + taskPullRequestsTable: { + repository: 'repository', + prNumber: 'prNumber', + prTitle: 'prTitle', + prUrl: 'prUrl', + detectedAt: 'detectedAt', + status: 'status', + taskId: 'taskId', + }, + mockSlackInstallationRows: vi.fn(), + mockMergedPullRequestRows: vi.fn(), + mockGetAutomationRuntime: vi.fn(), + mockRecordAutomationRunOutcome: vi.fn(), + mockUpsertBackgroundAutomationSlackThread: vi.fn(), + mockResolveAutomationRuntimeDestination: vi.fn(), + mockListConnectedCommunicationProviders: vi.fn(), + mockHasAnyActiveRepository: vi.fn(), + mockGetCommunicationProviderAdapter: vi.fn(), + mockLoadAutomationThreadFeedbackContext: vi.fn(), + mockGenerateTrackedNonTaskText: vi.fn(), + mockSlackNotifier: vi.fn(), + mockAdapterPostMessage: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + select: vi.fn(() => ({ + from: (table: unknown) => { + if (table === slackInstallationsTable) { + return { where: () => mockSlackInstallationRows() }; + } + + return { + innerJoin: () => ({ + where: () => ({ + orderBy: () => ({ + limit: () => mockMergedPullRequestRows(), + }), + }), + }), + }; + }, + })), + }, + getAutomationRuntime: mockGetAutomationRuntime, + recordAutomationRunOutcome: mockRecordAutomationRunOutcome, + upsertBackgroundAutomationSlackThread: + mockUpsertBackgroundAutomationSlackThread, + slackInstallations: slackInstallationsTable, + taskPullRequests: taskPullRequestsTable, + tasks: { id: 'id' }, + and: vi.fn(), + eq: vi.fn(), + gte: vi.fn(), + isNotNull: vi.fn(), +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + buildManagerAutomationRootSummaryPromptContract: vi.fn(() => 'contract'), +})); + +vi.mock('@roomote/cloud-agents/server/non-task-provider-usage', () => ({ + generateTrackedNonTaskText: mockGenerateTrackedNonTaskText, + NON_TASK_INFERENCE_SURFACES: { backgroundAnnouncer: 'background_announcer' }, +})); + +vi.mock('@roomote/slack', () => ({ + SlackNotifier: mockSlackNotifier, +})); + +vi.mock('../automation-thread-feedback', () => ({ + loadAutomationThreadFeedbackContext: mockLoadAutomationThreadFeedbackContext, +})); + +vi.mock('../destination', () => ({ + resolveAutomationRuntimeDestination: mockResolveAutomationRuntimeDestination, + listConnectedCommunicationProviders: mockListConnectedCommunicationProviders, +})); + +vi.mock('../github-deployment-scope', () => ({ + hasAnyActiveRepository: mockHasAnyActiveRepository, +})); + +vi.mock('../../lib/communication-providers', () => ({ + getCommunicationProviderAdapter: mockGetCommunicationProviderAdapter, +})); + +vi.mock('../../lib/manager-slack', () => ({ + buildAutomationRootSummaryMessage: vi.fn(), + buildManagerSlackSettingsUrl: vi.fn( + (hash: string) => `https://app.example.com/automations#${hash}`, + ), + degradeSlackMrkdwnToMarkdown: vi.fn((text: string) => `md(${text})`), +})); + +vi.mock('../scheduling-utils', () => ({ + isRunDue: vi.fn(() => true), + resolveSlackWorkspaceTimezone: vi.fn(async () => 'UTC'), +})); + +import { SUMMARIZE_MERGED_PRS_SETTINGS_HASH } from '@roomote/types'; + +import { announcerJob } from '../announcer'; + +const SUMMARY = 'Shipped *two fixes* today.'; +const SETTINGS_URL = `https://app.example.com/automations#${SUMMARIZE_MERGED_PRS_SETTINGS_HASH}`; + +const MERGED_PR_ROWS = [ + { + repo: 'acme/app', + prNumber: 1, + prTitle: 'Fix bug', + prUrl: 'https://github.com/acme/app/pull/1', + mergedAt: new Date('2026-07-12T00:00:00Z'), + }, + { + repo: 'acme/app', + prNumber: 2, + prTitle: 'Add thing', + prUrl: 'https://github.com/acme/app/pull/2', + mergedAt: new Date('2026-07-12T01:00:00Z'), + }, +]; + +const EXPECTED_DETAIL_MESSAGE = [ + '*acme/app*', + '- Fix bug ', + '- Add thing ', +].join('\n'); + +describe('announcerJob non-Slack posting', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockHasAnyActiveRepository.mockResolvedValue(true); + mockSlackInstallationRows.mockResolvedValue([]); + mockListConnectedCommunicationProviders.mockResolvedValue(['telegram']); + mockGetAutomationRuntime.mockResolvedValue({ + key: 'announcer', + enabled: true, + scheduleMode: 'daily', + lastRunAt: null, + instructions: null, + destination: null, + }); + mockMergedPullRequestRows.mockResolvedValue(MERGED_PR_ROWS); + mockLoadAutomationThreadFeedbackContext.mockResolvedValue(null); + mockGenerateTrackedNonTaskText.mockResolvedValue(SUMMARY); + + let nextMessageId = 100; + mockAdapterPostMessage.mockImplementation( + async (input: { channelId: string }) => ({ + provider: 'telegram', + channelId: input.channelId, + messageId: `msg-${nextMessageId++}`, + }), + ); + mockGetCommunicationProviderAdapter.mockResolvedValue({ + provider: 'telegram', + postMessage: mockAdapterPostMessage, + }); + }); + + it('posts the report through the telegram adapter with markdown and a settings button', async () => { + mockResolveAutomationRuntimeDestination.mockResolvedValue({ + provider: 'telegram', + channelId: '-100555', + }); + + const result = await announcerJob({ manualTrigger: true }); + + expect(result.completed).toBe(true); + expect(result.errors).toEqual([]); + expect(mockSlackNotifier).not.toHaveBeenCalled(); + expect(mockGetCommunicationProviderAdapter).toHaveBeenCalledWith( + 'telegram', + ); + + // Root message: degraded markdown summary plus the settings link button. + expect(mockAdapterPostMessage).toHaveBeenNthCalledWith(1, { + channelId: '-100555', + text: `md(${SUMMARY})`, + textFormat: 'markdown', + buttons: [[{ text: 'Automation settings', url: SETTINGS_URL }]], + }); + + // Tracked automation thread lands on the destination surface. + expect(mockUpsertBackgroundAutomationSlackThread).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + surface: 'telegram', + automationKey: 'announcer', + slackChannelId: '-100555', + threadTs: 'msg-100', + summaryText: SUMMARY, + }), + ); + + // Detail messages thread under the root message id. + expect(mockAdapterPostMessage).toHaveBeenNthCalledWith(2, { + channelId: '-100555', + threadId: 'msg-100', + text: `md(${EXPECTED_DETAIL_MESSAGE})`, + textFormat: 'markdown', + }); + + expect(mockRecordAutomationRunOutcome).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ key: 'announcer', status: 'succeeded' }), + ); + }); + + it('threads teams detail messages with serviceUrl and replyToMessageId', async () => { + mockListConnectedCommunicationProviders.mockResolvedValue(['teams']); + mockResolveAutomationRuntimeDestination.mockResolvedValue({ + provider: 'teams', + channelId: '19:conv@thread.v2', + serviceUrl: 'https://smba.example/amer/', + }); + mockGetCommunicationProviderAdapter.mockResolvedValue({ + provider: 'teams', + postMessage: mockAdapterPostMessage, + }); + + const result = await announcerJob({ manualTrigger: true }); + + expect(result.completed).toBe(true); + expect(mockAdapterPostMessage).toHaveBeenNthCalledWith(1, { + channelId: '19:conv@thread.v2', + serviceUrl: 'https://smba.example/amer/', + text: `md(${SUMMARY})`, + textFormat: 'markdown', + buttons: [[{ text: 'Automation settings', url: SETTINGS_URL }]], + }); + expect(mockAdapterPostMessage).toHaveBeenNthCalledWith(2, { + channelId: '19:conv@thread.v2', + serviceUrl: 'https://smba.example/amer/', + threadId: 'msg-100', + replyToMessageId: 'msg-100', + text: `md(${EXPECTED_DETAIL_MESSAGE})`, + textFormat: 'markdown', + }); + }); + + it('looks up thread feedback on the destination surface', async () => { + mockResolveAutomationRuntimeDestination.mockResolvedValue({ + provider: 'telegram', + channelId: '-100555', + }); + + await announcerJob({ manualTrigger: true }); + + expect(mockLoadAutomationThreadFeedbackContext).toHaveBeenCalledWith( + expect.objectContaining({ + automationKey: 'announcer', + slackChannelId: '-100555', + surface: 'telegram', + }), + ); + }); + + it('records a failed outcome when the destination provider is not connected', async () => { + mockResolveAutomationRuntimeDestination.mockResolvedValue({ + provider: 'telegram', + channelId: '-100555', + }); + mockGetCommunicationProviderAdapter.mockResolvedValue(null); + + const result = await announcerJob({ manualTrigger: true }); + + expect(result.completed).toBe(false); + expect(result.errors).toEqual([ + 'Failed to post announcer summary: telegram is not connected', + ]); + expect(mockUpsertBackgroundAutomationSlackThread).not.toHaveBeenCalled(); + expect(mockRecordAutomationRunOutcome).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ key: 'announcer', status: 'failed' }), + ); + }); + + it('skips the deployment when no destination resolves', async () => { + mockResolveAutomationRuntimeDestination.mockResolvedValue(null); + + const result = await announcerJob({ manualTrigger: true }); + + expect(result.completed).toBe(false); + expect(result.skippedReason).toBe('Announcer channel is not configured.'); + expect(mockAdapterPostMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/server/automations/announcer.ts b/packages/sdk/src/server/automations/announcer.ts index 387519064..829efe931 100644 --- a/packages/sdk/src/server/automations/announcer.ts +++ b/packages/sdk/src/server/automations/announcer.ts @@ -21,8 +21,18 @@ import { SUMMARIZE_MERGED_PRS_SETTINGS_HASH } from '@roomote/types'; import { SlackNotifier } from '@roomote/slack'; import { loadAutomationThreadFeedbackContext } from './automation-thread-feedback'; +import { + listConnectedCommunicationProviders, + resolveAutomationRuntimeDestination, + type ResolvedAutomationDestination, +} from './destination'; import { hasAnyActiveRepository } from './github-deployment-scope'; -import { buildAutomationRootSummaryMessage } from '../lib/manager-slack'; +import { getCommunicationProviderAdapter } from '../lib/communication-providers'; +import { + buildAutomationRootSummaryMessage, + buildManagerSlackSettingsUrl, + degradeSlackMrkdwnToMarkdown, +} from '../lib/manager-slack'; import { isRunDue, resolveSlackWorkspaceTimezone } from './scheduling-utils'; import { emptyJobResult, @@ -34,8 +44,8 @@ const LOG_PREFIX = '[announcer]'; const SCHEDULE_HOUR_LOCAL = 2; interface DeploymentContext { - slackBotToken: string; - slackTeamId: string; + slackBotToken: string | null; + slackTeamId: string | null; } interface MergedPullRequest { @@ -61,13 +71,25 @@ async function findEligibleDeployments(): Promise { return []; } - return db + const rows = await db .select({ slackBotToken: slackInstallations.botAccessToken, slackTeamId: slackInstallations.teamId, }) .from(slackInstallations) .where(eq(slackInstallations.isActive, true)); + + if (rows.length > 0) { + return rows; + } + + // No Slack: the deployment is still eligible when another comms provider + // can carry the announcer's reports. + const connectedProviders = await listConnectedCommunicationProviders(); + + return connectedProviders.length > 0 + ? [{ slackBotToken: null, slackTeamId: null }] + : []; } async function getMergedPullRequests( @@ -216,6 +238,85 @@ function buildAnnouncerDetailThreadMessages( return messages; } +/** + * Posts the announcer report to a non-Slack destination through its + * communication provider adapter: a markdown root message with an + * automation-settings link button, the tracked automation thread, and the + * per-repo detail messages threaded under the root message. + */ +async function postAnnouncerReportViaCommunicationAdapter(params: { + destination: ResolvedAutomationDestination; + summary: string; + mergedPullRequests: MergedPullRequest[]; + windowDays: number; + now: Date; +}): Promise { + const { destination } = params; + const adapter = await getCommunicationProviderAdapter(destination.provider); + + if (!adapter) { + throw new Error( + `Failed to post announcer summary: ${destination.provider} is not connected`, + ); + } + + const serviceUrlFields = destination.serviceUrl + ? { serviceUrl: destination.serviceUrl } + : {}; + + const root = await adapter.postMessage({ + channelId: destination.channelId, + ...serviceUrlFields, + text: degradeSlackMrkdwnToMarkdown(params.summary), + textFormat: 'markdown', + buttons: [ + [ + { + text: 'Automation settings', + url: buildManagerSlackSettingsUrl(SUMMARIZE_MERGED_PRS_SETTINGS_HASH), + }, + ], + ], + }); + + await upsertBackgroundAutomationSlackThread(db, { + surface: destination.provider, + automationKey: 'announcer', + slackChannelId: destination.channelId, + threadTs: root.messageId, + summaryText: params.summary, + postedAt: params.now, + metadata: { + mergedCount: params.mergedPullRequests.length, + windowDays: params.windowDays, + }, + }); + + for (const detailMessage of buildAnnouncerDetailThreadMessages( + params.mergedPullRequests, + )) { + try { + await adapter.postMessage({ + channelId: destination.channelId, + ...serviceUrlFields, + threadId: root.messageId, + // Teams thread replies address the parent activity directly. + ...(destination.provider === 'teams' + ? { replyToMessageId: root.messageId } + : {}), + text: degradeSlackMrkdwnToMarkdown(detailMessage), + textFormat: 'markdown', + }); + } catch (error) { + console.warn( + `${LOG_PREFIX} Failed to post announcer detail thread chunk: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } +} + export async function announcerJob( opts: AutomationRunOpts = {}, ): Promise { @@ -227,7 +328,7 @@ export async function announcerJob( if (eligibleDeployments.length === 0) { result.skippedReason = - 'An active repository and Slack must both be connected.'; + 'An active repository and a communication provider must both be connected.'; } let processed = 0; @@ -244,9 +345,12 @@ export async function announcerJob( continue; } - const channelId = runtime.slackChannelId; + const destination = await resolveAutomationRuntimeDestination({ + runtime, + slackConnected: deployment.slackBotToken !== null, + }); - if (!channelId) { + if (!destination) { console.log( `${LOG_PREFIX} Skipping deployment: announcer channel not configured`, ); @@ -255,10 +359,16 @@ export async function announcerJob( continue; } - const timezone = await resolveSlackWorkspaceTimezone( - deployment, - LOG_PREFIX, - ); + const channelId = destination.channelId; + const timezone = deployment.slackBotToken + ? await resolveSlackWorkspaceTimezone( + { + slackBotToken: deployment.slackBotToken, + slackTeamId: deployment.slackTeamId ?? '', + }, + LOG_PREFIX, + ) + : 'UTC'; if ( !opts.manualTrigger && @@ -299,6 +409,7 @@ export async function announcerJob( const recentThreadFeedback = await loadAutomationThreadFeedbackContext({ automationKey: 'announcer', slackChannelId: channelId, + surface: destination.provider, now, }); const summary = await runAnnouncerAnalysis( @@ -319,48 +430,64 @@ export async function announcerJob( continue; } - const notifier = new SlackNotifier(deployment.slackBotToken); - const message = buildAutomationRootSummaryMessage({ - summaryText: summary, - automationSettingsHash: SUMMARIZE_MERGED_PRS_SETTINGS_HASH, - }); + if (destination.provider === 'slack') { + if (!deployment.slackBotToken) { + throw new Error( + 'Announcer destination is Slack, but Slack is not connected', + ); + } - const ts = await notifier.postMessage({ - channel: channelId, - ...message, - }); + const notifier = new SlackNotifier(deployment.slackBotToken); + const message = buildAutomationRootSummaryMessage({ + summaryText: summary, + automationSettingsHash: SUMMARIZE_MERGED_PRS_SETTINGS_HASH, + }); - if (!ts) { - throw new Error('Failed to post announcer summary to Slack'); - } + const ts = await notifier.postMessage({ + channel: channelId, + ...message, + }); - await upsertBackgroundAutomationSlackThread(db, { - surface: 'slack', - automationKey: 'announcer', - slackChannelId: channelId, - threadTs: ts, - summaryText: summary, - postedAt: now, - metadata: { - mergedCount: mergedPullRequests.length, - windowDays, - }, - }); + if (!ts) { + throw new Error('Failed to post announcer summary to Slack'); + } - for (const detailMessage of buildAnnouncerDetailThreadMessages( - mergedPullRequests, - )) { - const detailTs = await notifier.postMessage({ - channel: channelId, - thread_ts: ts, - text: detailMessage, + await upsertBackgroundAutomationSlackThread(db, { + surface: 'slack', + automationKey: 'announcer', + slackChannelId: channelId, + threadTs: ts, + summaryText: summary, + postedAt: now, + metadata: { + mergedCount: mergedPullRequests.length, + windowDays, + }, }); - if (!detailTs) { - console.warn( - `${LOG_PREFIX} Failed to post announcer detail thread chunk`, - ); + for (const detailMessage of buildAnnouncerDetailThreadMessages( + mergedPullRequests, + )) { + const detailTs = await notifier.postMessage({ + channel: channelId, + thread_ts: ts, + text: detailMessage, + }); + + if (!detailTs) { + console.warn( + `${LOG_PREFIX} Failed to post announcer detail thread chunk`, + ); + } } + } else { + await postAnnouncerReportViaCommunicationAdapter({ + destination, + summary, + mergedPullRequests, + windowDays, + now, + }); } await recordAutomationRunOutcome(db, { diff --git a/packages/sdk/src/server/automations/manager-stats.ts b/packages/sdk/src/server/automations/manager-stats.ts index aa36f93f7..59e3fc299 100644 --- a/packages/sdk/src/server/automations/manager-stats.ts +++ b/packages/sdk/src/server/automations/manager-stats.ts @@ -2,6 +2,8 @@ import { Env } from '@roomote/env'; import { db, getAutomationRuntime, + githubInstallations, + isNull, recordAutomationRunOutcome, slackInstallations, eq, @@ -10,8 +12,18 @@ import { MANAGER_STATS_SETTINGS_HASH } from '@roomote/types'; import { SlackNotifier } from '@roomote/slack'; import type { SlackMessage } from '@roomote/slack'; -import { buildAutomationSettingsMessage } from '../lib/manager-slack'; +import { getCommunicationProviderAdapter } from '../lib/communication-providers'; +import { + buildAutomationSettingsMessage, + buildManagerSlackSettingsUrl, + degradeSlackMrkdwnToMarkdown, +} from '../lib/manager-slack'; import { buildManagerStatsDigest } from '../lib/manager-stats'; +import { + listConnectedCommunicationProviders, + resolveAutomationRuntimeDestination, + type ResolvedAutomationDestination, +} from './destination'; import { hasActiveGitHubInstallation } from './github-deployment-scope'; import { isWeeklyRunDueOnLocalDay, @@ -29,8 +41,8 @@ const SCHEDULE_HOUR_LOCAL = 16; const WINDOW_DAYS = 7; interface DeploymentContext { - slackBotToken: string; - slackTeamId: string; + slackBotToken: string | null; + slackTeamId: string | null; actorUserId: string; } @@ -86,7 +98,7 @@ async function findEligibleDeployments(): Promise { return []; } - return db + const rows = await db .select({ slackBotToken: slackInstallations.botAccessToken, slackTeamId: slackInstallations.teamId, @@ -94,6 +106,72 @@ async function findEligibleDeployments(): Promise { }) .from(slackInstallations) .where(eq(slackInstallations.isActive, true)); + + if (rows.length > 0) { + return rows; + } + + // No Slack: the deployment is still eligible when another comms provider + // can carry the stats report. GitHub calls then run as the user who + // installed the GitHub App. + const connectedProviders = await listConnectedCommunicationProviders(); + + if (connectedProviders.length === 0) { + return []; + } + + const [installation] = await db + .select({ actorUserId: githubInstallations.installedByUserId }) + .from(githubInstallations) + .where(isNull(githubInstallations.suspendedAt)) + .limit(1); + + return installation + ? [ + { + slackBotToken: null, + slackTeamId: null, + actorUserId: installation.actorUserId, + }, + ] + : []; +} + +/** + * Posts the weekly stats to a non-Slack destination through its + * communication provider adapter: the Slack mrkdwn digest degraded to + * standard markdown, with the automation-settings context degraded to a + * link button. + */ +async function postManagerStatsViaCommunicationAdapter(params: { + destination: ResolvedAutomationDestination; + stats: Awaited>; +}): Promise { + const { destination } = params; + const adapter = await getCommunicationProviderAdapter(destination.provider); + + if (!adapter) { + throw new Error( + `Failed to post weekly manager stats: ${destination.provider} is not connected`, + ); + } + + await adapter.postMessage({ + channelId: destination.channelId, + ...(destination.serviceUrl ? { serviceUrl: destination.serviceUrl } : {}), + text: degradeSlackMrkdwnToMarkdown( + formatManagerStatsText({ stats: params.stats }), + ), + textFormat: 'markdown', + buttons: [ + [ + { + text: 'Automation settings', + url: buildManagerSlackSettingsUrl(MANAGER_STATS_SETTINGS_HASH), + }, + ], + ], + }); } export async function managerStatsJob( @@ -106,7 +184,8 @@ export async function managerStatsJob( const eligibleDeployments = await findEligibleDeployments(); if (eligibleDeployments.length === 0) { - result.skippedReason = 'GitHub and Slack must both be connected.'; + result.skippedReason = + 'GitHub and a communication provider must both be connected.'; } let processed = 0; @@ -116,7 +195,6 @@ export async function managerStatsJob( try { const runtime = await getAutomationRuntime('manager_stats'); const frequency = runtime.enabled ? runtime.scheduleMode : 'off'; - const channelId = runtime.slackChannelId; if (!frequency || frequency === 'off') { result.skippedReason = 'Automation is disabled.'; @@ -124,16 +202,27 @@ export async function managerStatsJob( continue; } - if (!channelId) { + const destination = await resolveAutomationRuntimeDestination({ + runtime, + slackConnected: deployment.slackBotToken !== null, + }); + + if (!destination) { result.skippedReason = 'Manager channel is not configured.'; skipped++; continue; } - const timezone = await resolveSlackWorkspaceTimezone( - deployment, - LOG_PREFIX, - ); + const channelId = destination.channelId; + const timezone = deployment.slackBotToken + ? await resolveSlackWorkspaceTimezone( + { + slackBotToken: deployment.slackBotToken, + slackTeamId: deployment.slackTeamId ?? '', + }, + LOG_PREFIX, + ) + : 'UTC'; if ( !opts.manualTrigger && @@ -167,14 +256,24 @@ export async function managerStatsJob( continue; } - const slack = new SlackNotifier(deployment.slackBotToken); - const messageTs = await slack.postMessage({ - channel: channelId, - ...formatManagerStatsMessage({ stats }), - }); + if (destination.provider === 'slack') { + if (!deployment.slackBotToken) { + throw new Error( + 'Manager stats destination is Slack, but Slack is not connected', + ); + } + + const slack = new SlackNotifier(deployment.slackBotToken); + const messageTs = await slack.postMessage({ + channel: channelId, + ...formatManagerStatsMessage({ stats }), + }); - if (!messageTs) { - throw new Error('Failed to post weekly manager stats'); + if (!messageTs) { + throw new Error('Failed to post weekly manager stats'); + } + } else { + await postManagerStatsViaCommunicationAdapter({ destination, stats }); } await recordAutomationRunOutcome(db, { diff --git a/packages/sdk/src/server/lib/__tests__/manager-slack.test.ts b/packages/sdk/src/server/lib/__tests__/manager-slack.test.ts index 3f9d177e9..a674bc204 100644 --- a/packages/sdk/src/server/lib/__tests__/manager-slack.test.ts +++ b/packages/sdk/src/server/lib/__tests__/manager-slack.test.ts @@ -19,6 +19,7 @@ import { buildAutomationRootSummaryText, buildAutomationSettingsContextText, buildAutomationSettingsMessage, + degradeSlackMrkdwnToMarkdown, SENTRY_TRIAGE_SETTINGS_HASH, SUGGEST_IDEAS_SETTINGS_HASH, shouldPostHistoricalThreadFeedbackDebugSnippet, @@ -206,3 +207,21 @@ describe('manager slack helpers', () => { ); }); }); + +describe('degradeSlackMrkdwnToMarkdown', () => { + it('converts mrkdwn links and bold to standard markdown', () => { + expect( + degradeSlackMrkdwnToMarkdown( + '*Shipped today*\n- Fix bug \nSee .', + ), + ).toBe( + '**Shipped today**\n- Fix bug [#1](https://github.com/acme/app/pull/1)\nSee [settings](https://app.example.com/automations).', + ); + }); + + it('leaves italic, double-asterisk bold, and plain text unchanged', () => { + expect(degradeSlackMrkdwnToMarkdown('_quiet_ **loud** plain')).toBe( + '_quiet_ **loud** plain', + ); + }); +}); diff --git a/packages/sdk/src/server/lib/manager-slack.ts b/packages/sdk/src/server/lib/manager-slack.ts index 0ab190da0..1356998a3 100644 --- a/packages/sdk/src/server/lib/manager-slack.ts +++ b/packages/sdk/src/server/lib/manager-slack.ts @@ -12,6 +12,7 @@ import { } from '@roomote/types'; import { db, eq, users } from '@roomote/db/server'; import { isShowDebugUIEnabledFromMetadata } from '@roomote/feature-flags'; +import { convertSlackLinksToMarkdown } from '@roomote/slack'; const DEFAULT_LOCAL_R_APP_URL = 'http://localhost:13000'; @@ -109,6 +110,19 @@ export function buildAutomationSettingsMessage( }; } +/** + * Degrades Slack mrkdwn automation text to standard markdown for non-Slack + * communication providers: `` links become `[label](url)` and + * single-asterisk bold becomes double-asterisk bold. Other mrkdwn forms + * (italic `_text_`, bullets, plain URLs) already read correctly as markdown. + */ +export function degradeSlackMrkdwnToMarkdown(text: string): string { + return convertSlackLinksToMarkdown(text).replace( + /(? Date: Mon, 13 Jul 2026 19:14:22 -0500 Subject: [PATCH 2/2] Dedupe worker runtime schema import inherited from develop Same one-line fix as #310; keeps this branch typechecking until that lands on develop. Co-Authored-By: Claude Fable 5 --- apps/web/src/trpc/commands/setup-new/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index aca2cfeda..ca40c8756 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -1,5 +1,4 @@ import type { FeatureFlag } from '@roomote/feature-flags'; -import { WORKER_RUNTIME_SCHEMA_VERSION } from '@roomote/types'; import type { UserAuthSuccess } from '@/types';