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..310b99eac 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', () => { @@ -646,6 +649,7 @@ describe('Automations selection helpers', () => { buildAutomationDiscordDestinationOptions({ channels: [{ id: '111', name: 'general', label: '#general' }], selectedChannelId: null, + includeProviderSuffix: true, }), ).toEqual([ { @@ -661,6 +665,7 @@ describe('Automations selection helpers', () => { buildAutomationDiscordDestinationOptions({ channels: [{ id: '111', name: 'general', label: '#general' }], selectedChannelId: '222', + includeProviderSuffix: true, }), ).toEqual([ { @@ -676,11 +681,33 @@ describe('Automations selection helpers', () => { ]); }); + it('drops the provider suffix when Discord is the only connected provider', () => { + expect( + buildAutomationDiscordDestinationOptions({ + channels: [{ id: '111', name: 'general', label: '#general' }], + selectedChannelId: '222', + includeProviderSuffix: false, + }), + ).toEqual([ + { + id: `${DISCORD_DESTINATION_OPTION_PREFIX}222`, + name: '222', + label: '#222', + }, + { + id: `${DISCORD_DESTINATION_OPTION_PREFIX}111`, + name: 'general', + label: '#general', + }, + ]); + }); + it('does not duplicate a Discord option for a selection the catalog already lists', () => { expect( buildAutomationDiscordDestinationOptions({ channels: [{ id: '111', name: 'general', label: '#general' }], selectedChannelId: '111', + includeProviderSuffix: true, }), ).toEqual([ { @@ -754,15 +781,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 +800,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..dd5781f47 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 channel'), + ).toBeInTheDocument(); + expect( + screen.getByText('#automation-reports (Discord)'), + ).toBeInTheDocument(); + }); + it('hides the launch mode picker when decision mode is disabled', async () => { render(); @@ -518,9 +556,7 @@ describe('AutomationsSettings', () => { expect(await screen.findByText('Summarize Merged PRs')).toBeInTheDocument(); expect( - screen.getByText( - 'Post a recurring digest of recently merged PRs to Slack.', - ), + screen.getByText('Post a recurring digest of recently merged PRs.'), ).toBeInTheDocument(); expect( screen.getByText("Summary of Roomote's activity during the week"), @@ -533,9 +569,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..f9683dad4 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 @@ -309,7 +336,7 @@ const CHANNEL_AUTO_START_LAUNCH_MODE_OPTIONS: ChannelAutoStartLaunchModeOption[] const TRIGGERABLE_AUTOMATION_DESCRIPTIONS = { conflict_resolver: 'Fix merge conflicts in open PRs.', suggester: 'Suggest valuable coding work to do.', - announcer: 'Post a recurring digest of recently merged PRs to Slack.', + announcer: 'Post a recurring digest of recently merged PRs.', manager_stats: "Summary of Roomote's activity during the week", sentry_triage: 'Scan Sentry issues and post a prioritized triage report.', dependabot_triage: @@ -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({ @@ -992,11 +1035,17 @@ export function buildManagerSlackChannelOptions(params: { export function buildAutomationDiscordDestinationOptions(params: { channels: Array<{ id: string; name: string; label: string }>; selectedChannelId: string | null | undefined; + /** + * The "(Discord)" suffix only disambiguates when Slack channels can appear + * in the same picker; on a Discord-only deployment it is noise. + */ + includeProviderSuffix: boolean; }): SlackChannelOption[] { + const suffix = params.includeProviderSuffix ? ' (Discord)' : ''; const options = params.channels.map((channel) => ({ id: `${DISCORD_DESTINATION_OPTION_PREFIX}${channel.id}`, name: channel.name, - label: `${channel.label} (Discord)`, + label: `${channel.label}${suffix}`, })); const selectedChannelId = params.selectedChannelId?.trim(); @@ -1017,7 +1066,7 @@ export function buildAutomationDiscordDestinationOptions(params: { { id: selectedOptionId, name: selectedChannelId!, - label: `#${selectedChannelId} (Discord)`, + label: `#${selectedChannelId}${suffix}`, }, ...options, ]; @@ -2246,6 +2295,7 @@ export function AutomationsSettings() { [slackChannelChoices], ); const discordConnected = capabilities?.discordConnected === true; + const slackConnected = capabilities?.slackConnected === true; const renderSlackDestinationField = useCallback( ({ field, @@ -2268,12 +2318,18 @@ export function AutomationsSettings() { const value = formState?.[field] ?? ''; const discordValue = formState?.[discordField] ?? ''; const showDiscordOptions = discordConnected || Boolean(discordValue); + // The historical labels say "Slack channel"; once Discord channels are + // offered in the same picker that wording reads as a bug. + const effectiveLabel = showDiscordOptions + ? label.replace(/ Slack channel$/u, ' channel') + : label; const options = [ ...buildSlackDestinationOptions(value), ...(showDiscordOptions ? buildAutomationDiscordDestinationOptions({ channels: discordChannelsQuery.data?.channels ?? [], selectedChannelId: discordValue || null, + includeProviderSuffix: slackConnected, }) : []), ]; @@ -2286,13 +2342,12 @@ export function AutomationsSettings() { return ( {!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, + })}