From 5f2858301af20ef390cbb87a5e5d8262dc695682 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 15 Jul 2026 23:26:35 -0500 Subject: [PATCH 1/8] Add Discord destinations for suggester, announcer, and platform issue alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the PR #268 Slack-or-Discord one-of destination picker to the three remaining automation destination fields: - suggester: registry now declares 'discord' in supportedCommunicationProviders (scheduled-suggestion delivery already falls through Slack -> Discord); settings write path and picker added. - announcer: settings write path and picker added (Discord delivery already worked via resolveAutomationRuntimeDestination + the comms adapter). - platform_issue_alerts: settings write path, picker, and a new Discord delivery branch in record-task-message-envelope — when the automation's own destination target is a Discord channel, the alert posts there via the Discord communication provider instead of Slack (best-effort, and the existing slackPostedAt marker doubles as the posted-anywhere gate). Details: - normalizeBackgroundAgentSettings now exposes suggesterDiscordChannelId, announcerDiscordChannelId, and platformIssueDiscordChannelId from the automations' discord_channel targets. - settings-update upserts the three automations with buildDestinationChannelTargets + managedTargetKinds ['slack_channel', 'discord_channel'], with the same server-side one-of backstop (Discord preferred) as the six existing pickers. Their keep-persisted Slack values stay un-suppressed because these three resolve via getAutomationSlackChannelTarget, which never merges the manager-channel fallback. - The shared renderSlackDestinationField now serves suggester, announcer, and a newly rendered Alert on Config Errors card; platform_issue_alerts joins MANAGER_REPORTING_AUTOMATION_KEYS so its "Reports to" line resolves. - Suggester's capability badge changes from "Slack only" to "Slack · Discord only". Tests: settings-update-discord real-DB coverage for the three fields (discord save clears slack, slack save clears discord, unrelated saves preserve), a new platform-issue alert delivery test (Discord target, Slack fallback, missing Discord credentials), registry provider-list assertion, and render coverage for the new card. Co-Authored-By: Claude Opus 4.8 --- .changeset/complete-discord-destinations.md | 8 + .../AutomationsSettings.client.test.tsx | 17 +- ...AutomationsSettings.render.client.test.tsx | 45 +++- .../automations/AutomationsSettings.tsx | 146 ++++++++-- .../settings/automations/formState.ts | 10 + .../__tests__/settings-update-discord.test.ts | 186 +++++++++++++ .../commands/automations/settings-update.ts | 109 +++++--- .../src/trpc/commands/automations/types.ts | 13 + apps/web/src/trpc/routers/_app.ts | 21 ++ packages/db/src/lib/automations.ts | 4 + packages/db/src/types.ts | 3 + .../platform-issue-alert-delivery.test.ts | 255 ++++++++++++++++++ .../task-runs/record-task-message-envelope.ts | 95 +++++-- .../background-automation-registry.test.ts | 10 + .../src/background-automation-registry.ts | 5 +- 15 files changed, 840 insertions(+), 87 deletions(-) create mode 100644 .changeset/complete-discord-destinations.md create mode 100644 packages/sdk/src/server/lib/task-runs/__tests__/platform-issue-alert-delivery.test.ts diff --git a/.changeset/complete-discord-destinations.md b/.changeset/complete-discord-destinations.md new file mode 100644 index 000000000..f70038de4 --- /dev/null +++ b/.changeset/complete-discord-destinations.md @@ -0,0 +1,8 @@ +--- +'@roomote/web': minor +--- + +Suggested-task summaries, announcer reports, and platform-issue alerts can now +report to a Discord channel: their destination pickers offer Slack or Discord +channels the same way the triage and auditor automations do, and platform-issue +alerts deliver to the selected Discord channel. diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx index a1e30be70..fbd6dc610 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx @@ -71,13 +71,16 @@ const baseFormState: FormState = { ciFailureTriageDiscordChannel: '', suggesterFrequency: 'off', suggesterSlackChannel: '', + suggesterDiscordChannel: '', suggesterInstructions: '', suggesterRoutingMode: DEFAULT_SUGGESTER_ROUTING_MODE, suggesterRoutingInstructions: '', announcerFrequency: 'off' as const, announcerSlackChannel: '', + announcerDiscordChannel: '', announcerInstructions: '', platformIssueSlackChannel: '', + platformIssueDiscordChannel: '', }; describe('Automations selection helpers', () => { @@ -754,15 +757,17 @@ describe('Automations selection helpers', () => { ).toBe(true); }); - it('treats platform issue alerts as disabled without a selected Slack channel', () => { + it('treats platform issue alerts as disabled without a selected channel', () => { expect( isPlatformIssueAlertsEnabled({ platformIssueSlackChannel: '', + platformIssueDiscordChannel: '', }), ).toBe(false); expect( isPlatformIssueAlertsEnabled({ platformIssueSlackChannel: ' ', + platformIssueDiscordChannel: ' ', }), ).toBe(false); }); @@ -771,6 +776,16 @@ describe('Automations selection helpers', () => { expect( isPlatformIssueAlertsEnabled({ platformIssueSlackChannel: 'C123', + platformIssueDiscordChannel: '', + }), + ).toBe(true); + }); + + it('treats platform issue alerts as enabled when a Discord channel is selected', () => { + expect( + isPlatformIssueAlertsEnabled({ + platformIssueSlackChannel: '', + platformIssueDiscordChannel: '111222333444555666', }), ).toBe(true); }); diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 706077b5c..0706419d5 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -82,13 +82,16 @@ const state = vi.hoisted(() => ({ ciFailureTriageDiscordChannelId: null, suggesterFrequency: 'off' as const, suggesterSlackChannelId: null, + suggesterDiscordChannelId: null, suggesterInstructions: null, suggesterRoutingMode: 'manager_channel' as const, suggesterRoutingInstructions: null, announcerFrequency: 'off' as const, announcerSlackChannelId: null, + announcerDiscordChannelId: null, announcerInstructions: null, platformIssueSlackChannelId: null, + platformIssueDiscordChannelId: null, }, slackChannelDisplayNames: { channelAutoStartSlackChannels: { @@ -138,6 +141,7 @@ const state = vi.hoisted(() => ({ 'ci_failure_triage', 'suggester', 'announcer', + 'platform_issue_alerts', ].map((key) => [ key, { @@ -373,6 +377,9 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.capabilities.discordConnected = false; state.discordChannelsQuery.data.channels = []; state.settingsQuery.data.settings.managerStatsDiscordChannelId = null; + state.settingsQuery.data.settings.suggesterDiscordChannelId = null; + state.settingsQuery.data.settings.announcerDiscordChannelId = null; + state.settingsQuery.data.settings.platformIssueDiscordChannelId = null; state.settingsQuery.data.settings.managerSlackChannelId = 'C123MANAGER'; state.settingsQuery.data.settings.managerStatsFrequency = 'off' as never; state.settingsQuery.data.settings.sentryTriageFrequency = 'off' as never; @@ -486,6 +493,37 @@ describe('AutomationsSettings', () => { ).not.toBeInTheDocument(); }); + it('offers the platform issue alerts destination picker with a saved Discord channel selected', async () => { + state.settingsQuery.data.capabilities.discordConnected = true; + state.discordChannelsQuery.data.channels = [ + { + id: '111222333444555666', + name: 'automation-reports', + label: '#automation-reports', + guildId: 'guild-1', + guildName: 'Acme', + }, + ]; + ( + state.settingsQuery.data.settings as Record + ).platformIssueDiscordChannelId = '111222333444555666'; + + render(); + + fireEvent.click( + await screen.findByRole('button', { + name: 'Expand Alert on Config Errors', + }), + ); + + expect( + screen.getByLabelText('Post alerts to this Slack channel'), + ).toBeInTheDocument(); + expect( + screen.getByText('#automation-reports (Discord)'), + ).toBeInTheDocument(); + }); + it('hides the launch mode picker when decision mode is disabled', async () => { render(); @@ -533,9 +571,10 @@ describe('AutomationsSettings', () => { // Dependabot and CI failure triage stay GitHub-only; manager stats is // provider-neutral now and shows no source-control badge. expect((await screen.findAllByText('GitHub only')).length).toBe(2); - // Only the suggester is limited to Slack; CI failure triage posts to all - // configured communication providers after multi-comms support. - expect(screen.getAllByText('Slack only').length).toBe(1); + // The suggester supports Slack and Discord destinations; the other + // manager automations post to all configured communication providers. + expect(screen.queryByText('Slack only')).toBeNull(); + expect(screen.getAllByText('Slack · Discord only').length).toBe(1); // conflict_resolver supports GitHub, GitLab, and Azure DevOps (no // Gitea/Bitbucket conflict signal). expect( diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index b610eb347..a5c1d00c1 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -142,6 +142,9 @@ type FieldErrors = Partial< | 'securityAuditorDiscordChannel' | 'codeQualityAuditorDiscordChannel' | 'ciFailureTriageDiscordChannel' + | 'suggesterDiscordChannel' + | 'announcerDiscordChannel' + | 'platformIssueDiscordChannel' | 'sentryTriageProjectSlugs' | 'suggesterInstructions' | 'suggesterRoutingInstructions' @@ -176,7 +179,10 @@ type AutomationSlackDestinationField = | 'dependabotTriageSlackChannel' | 'securityAuditorSlackChannel' | 'codeQualityAuditorSlackChannel' - | 'ciFailureTriageSlackChannel'; + | 'ciFailureTriageSlackChannel' + | 'suggesterSlackChannel' + | 'announcerSlackChannel' + | 'platformIssueSlackChannel'; const SLACK_DESTINATION_FIELD_AUTOMATION_KEYS = { managerStatsSlackChannel: 'manager_stats', @@ -185,18 +191,36 @@ const SLACK_DESTINATION_FIELD_AUTOMATION_KEYS = { securityAuditorSlackChannel: 'security_auditor', codeQualityAuditorSlackChannel: 'code_quality_auditor', ciFailureTriageSlackChannel: 'ci_failure_triage', + suggesterSlackChannel: 'suggester', + announcerSlackChannel: 'announcer', + platformIssueSlackChannel: 'platform_issue_alerts', } as const satisfies Record< AutomationSlackDestinationField, BackgroundAutomationKey >; +const SLACK_DESTINATION_FIELD_AUTOMATION_IDS = { + managerStatsSlackChannel: 'managerStats', + sentryTriageSlackChannel: 'sentryTriage', + dependabotTriageSlackChannel: 'dependabotTriage', + securityAuditorSlackChannel: 'securityAuditor', + codeQualityAuditorSlackChannel: 'codeQualityAuditor', + ciFailureTriageSlackChannel: 'ciFailureTriage', + suggesterSlackChannel: 'suggester', + announcerSlackChannel: 'announcer', + platformIssueSlackChannel: 'platformIssueAlerts', +} as const satisfies Record; + type AutomationDiscordDestinationField = | 'managerStatsDiscordChannel' | 'sentryTriageDiscordChannel' | 'dependabotTriageDiscordChannel' | 'securityAuditorDiscordChannel' | 'codeQualityAuditorDiscordChannel' - | 'ciFailureTriageDiscordChannel'; + | 'ciFailureTriageDiscordChannel' + | 'suggesterDiscordChannel' + | 'announcerDiscordChannel' + | 'platformIssueDiscordChannel'; // The form field holding the same automation's Discord destination; the // destination picker is one-of, so selecting one provider clears the other. @@ -207,6 +231,9 @@ const SLACK_TO_DISCORD_DESTINATION_FIELDS = { securityAuditorSlackChannel: 'securityAuditorDiscordChannel', codeQualityAuditorSlackChannel: 'codeQualityAuditorDiscordChannel', ciFailureTriageSlackChannel: 'ciFailureTriageDiscordChannel', + suggesterSlackChannel: 'suggesterDiscordChannel', + announcerSlackChannel: 'announcerDiscordChannel', + platformIssueSlackChannel: 'platformIssueDiscordChannel', } as const satisfies Record< AutomationSlackDestinationField, AutomationDiscordDestinationField @@ -522,7 +549,8 @@ const AUTOMATION_DEFINITIONS: Record = { platformIssueAlerts: { id: 'platformIssueAlerts', label: 'Alert on Config Errors', - description: 'Alert on Slack when a task runs into admin-fixable issues.', + description: + 'Alert on Slack or Discord when a task runs into admin-fixable issues.', icon: BellElectric, }, }; @@ -689,15 +717,18 @@ function mapSettingsToFormState( suggesterFrequency: SuggesterFrequency; suggesterSlackChannelId: string | null; suggesterSlackChannelName?: string | null; + suggesterDiscordChannelId: string | null; suggesterInstructions: string | null; suggesterRoutingMode: SuggesterRoutingMode; suggesterRoutingInstructions: string | null; announcerFrequency: AnnouncerFrequency; announcerSlackChannelId: string | null; announcerSlackChannelName?: string | null; + announcerDiscordChannelId: string | null; announcerInstructions: string | null; platformIssueSlackChannelId: string | null; platformIssueSlackChannelName?: string | null; + platformIssueDiscordChannelId: string | null; securityAuditorSlackChannelId: string | null; securityAuditorSlackChannelName?: string | null; securityAuditorDiscordChannelId: string | null; @@ -771,6 +802,7 @@ function mapSettingsToFormState( settings.suggesterSlackChannelName ?? settings.suggesterSlackChannelId ?? '', + suggesterDiscordChannel: settings.suggesterDiscordChannelId ?? '', suggesterInstructions: settings.suggesterInstructions ?? '', suggesterRoutingMode: settings.suggesterRoutingMode ?? DEFAULT_SUGGESTER_ROUTING_MODE, @@ -780,11 +812,13 @@ function mapSettingsToFormState( settings.announcerSlackChannelName ?? settings.announcerSlackChannelId ?? '', + announcerDiscordChannel: settings.announcerDiscordChannelId ?? '', announcerInstructions: settings.announcerInstructions ?? '', platformIssueSlackChannel: settings.platformIssueSlackChannelName ?? settings.platformIssueSlackChannelId ?? '', + platformIssueDiscordChannel: settings.platformIssueDiscordChannelId ?? '', securityAuditorSlackChannel: settings.securityAuditorSlackChannelName ?? settings.securityAuditorSlackChannelId ?? @@ -831,9 +865,18 @@ export function buildSlackWorkflowLaunchUrl( } export function isPlatformIssueAlertsEnabled( - formState: Pick | null | undefined, + formState: + | Pick< + FormState, + 'platformIssueSlackChannel' | 'platformIssueDiscordChannel' + > + | null + | undefined, ): boolean { - return Boolean(formState?.platformIssueSlackChannel.trim()); + return Boolean( + formState?.platformIssueSlackChannel.trim() || + formState?.platformIssueDiscordChannel.trim(), + ); } export function canSelectSentryTriageFrequency({ @@ -2312,20 +2355,7 @@ export function AutomationsSettings() { formValue: value, savedChannelId, warningChannelId, - isDirty: - isDirty[ - field === 'managerStatsSlackChannel' - ? 'managerStats' - : field === 'sentryTriageSlackChannel' - ? 'sentryTriage' - : field === 'dependabotTriageSlackChannel' - ? 'dependabotTriage' - : field === 'securityAuditorSlackChannel' - ? 'securityAuditor' - : field === 'codeQualityAuditorSlackChannel' - ? 'codeQualityAuditor' - : 'ciFailureTriage' - ], + isDirty: isDirty[SLACK_DESTINATION_FIELD_AUTOMATION_IDS[field]], }) } error={fieldErrors[field] ?? fieldErrors[discordField]} @@ -3322,11 +3352,21 @@ export function AutomationsSettings() {
{!suggestionRoutingEnabled ? ( <> - + {renderSlackDestinationField({ + field: 'suggesterSlackChannel', + inputId: 'suggester-slack-channel', + label: 'Post suggestions to this Slack channel', + helperText: + 'Choose where Roomote should post its suggestion digests.', + savedChannelId: + settingsQuery.data?.settings + .suggesterSlackChannelId ?? null, + savedDiscordChannelId: + settingsQuery.data?.settings + .suggesterDiscordChannelId ?? null, + warningChannelId: + slackChannelAccessWarnings.suggesterSlackChannel, + })}