diff --git a/apps/web/src/components/settings/automations/AutomationList.tsx b/apps/web/src/components/settings/automations/AutomationList.tsx new file mode 100644 index 000000000..3fd32cb47 --- /dev/null +++ b/apps/web/src/components/settings/automations/AutomationList.tsx @@ -0,0 +1,150 @@ +'use client'; + +import type { ComponentType, ReactNode } from 'react'; + +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Search, +} from '@/components/system'; + +export type AutomationListFilter = 'all' | 'custom' | 'built-in'; + +export function AutomationListToolbar({ + filter, + search, + leading, + action, + showBuiltInFilter = true, + onFilterChange, + onSearchChange, +}: { + filter: AutomationListFilter; + search: string; + leading?: ReactNode; + action?: ReactNode; + showBuiltInFilter?: boolean; + onFilterChange: (filter: AutomationListFilter) => void; + onSearchChange: (search: string) => void; +}) { + return ( +
+ {leading} + onFilterChange(value as AutomationListFilter)} + aria-label="Automation type" + className="flex items-center gap-4 md:ml-auto" + > + {( + [ + ['all', 'All'], + ['custom', 'Custom'], + ['built-in', 'Built-in'], + ] as const + ) + .filter(([value]) => showBuiltInFilter || value !== 'built-in') + .map(([value, label]) => ( +
+ + +
+ ))} +
+
+ + onSearchChange(event.currentTarget.value)} + placeholder="Search automations" + aria-label="Search automations" + className="h-8 w-full pl-8 text-sm sm:w-56" + /> +
+ {action} +
+ ); +} + +export function AutomationListHeader() { + return ( +
+ + Enabled + + + Name + + + Description + + + Actions + +
+ ); +} + +export function AutomationListRow({ + icon: Icon, + name, + summary, + description, + enabledControl, + actions, +}: { + icon: ComponentType<{ className?: string }>; + name: string; + summary: ReactNode; + description: ReactNode; + enabledControl: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+ {enabledControl} +
+
+ +
+

{name}

+
+ {summary} +
+
+
+
+ {description} +
+
+ {actions} +
+
+ ); +} 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 4047e6fe9..ca7be4618 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 @@ -564,10 +564,7 @@ vi.mock('@/trpc/client', () => ({ }), })); -import { - AutomationsSettings, - getAutomationHistoryHref, -} from './AutomationsSettings'; +import { AutomationsSettings } from './AutomationsSettings'; import { CustomAutomationsSection } from './CustomAutomationsSection'; it.each([false, true])( @@ -593,6 +590,12 @@ it('opens the standalone custom editor without querying admin settings', () => { state.queriedKeys = []; render(); + expect( + screen.queryByRole('radio', { name: 'Built-in' }), + ).not.toBeInTheDocument(); + expect( + screen.getByText('No custom automations created yet.'), + ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'New' })); expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument(); expect(state.queriedKeys).toContainEqual([ @@ -606,16 +609,16 @@ it('opens the standalone custom editor without querying admin settings', () => { async function openSuggesterCard() { fireEvent.click( - await screen.findByRole('button', { - name: /(?:Set up|Configure) Suggest Ideas/, + await screen.findByRole('switch', { + name: /(?:Set up|Configure) Suggest Ideas enabled state/, }), ); } async function openReviewerCard() { fireEvent.click( - await screen.findByRole('button', { - name: /(?:Set up|Configure) Review Code/, + await screen.findByRole('switch', { + name: /(?:Set up|Configure) Review Code enabled state/, }), ); } @@ -689,8 +692,8 @@ describe('AutomationsSettings', () => { render(); expect( - await screen.findByRole('button', { - name: /(?:Set up|Configure) Weekly Manager Stats/, + await screen.findByRole('switch', { + name: /(?:Set up|Configure) Weekly Manager Stats enabled state/, }), ).toBeInTheDocument(); expect(screen.queryByText('Beta')).not.toBeInTheDocument(); @@ -700,8 +703,8 @@ describe('AutomationsSettings', () => { render(); fireEvent.click( - await screen.findByRole('button', { - name: 'Configure Inference Provider Usage Alerts', + await screen.findByRole('switch', { + name: 'Configure Inference Provider Usage Alerts enabled state', }), ); @@ -721,8 +724,8 @@ describe('AutomationsSettings', () => { render(); fireEvent.click( - await screen.findByRole('button', { - name: 'Set up Call Roomote via emoji', + await screen.findByRole('switch', { + name: 'Set up Call Roomote via emoji enabled state', }), ); fireEvent.click( @@ -786,8 +789,8 @@ describe('AutomationsSettings', () => { render(); fireEvent.click( - await screen.findByRole('button', { - name: /(?:Set up|Configure) Weekly Manager Stats/, + await screen.findByRole('switch', { + name: /(?:Set up|Configure) Weekly Manager Stats enabled state/, }), ); expect( @@ -798,8 +801,8 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { - name: /(?:Set up|Configure) Triage Sentry Issues/, + screen.getByRole('switch', { + name: /(?:Set up|Configure) Triage Sentry Issues enabled state/, }), ); expect( @@ -807,8 +810,8 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { - name: /(?:Set up|Configure) Triage Dependabot Alerts/, + screen.getByRole('switch', { + name: /(?:Set up|Configure) Triage Dependabot Alerts enabled state/, }), ); @@ -841,8 +844,8 @@ describe('AutomationsSettings', () => { render(); fireEvent.click( - await screen.findByRole('button', { - name: /(?:Set up|Configure) Weekly Manager Stats/, + await screen.findByRole('switch', { + name: /(?:Set up|Configure) Weekly Manager Stats enabled state/, }), ); expect( @@ -850,8 +853,8 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { - name: /(?:Set up|Configure) Triage Sentry Issues/, + screen.getByRole('switch', { + name: /(?:Set up|Configure) Triage Sentry Issues enabled state/, }), ); @@ -881,8 +884,8 @@ describe('AutomationsSettings', () => { render(); fireEvent.click( - await screen.findByRole('button', { - name: /(?:Set up|Configure) Automation output/, + await screen.findByRole('switch', { + name: /(?:Set up|Configure) Automation output enabled state/, }), ); const destination = await screen.findByRole('button', { @@ -915,8 +918,8 @@ describe('AutomationsSettings', () => { render(); fireEvent.click( - await screen.findByRole('button', { - name: /(?:Set up|Configure) Alert on Config Errors/, + await screen.findByRole('switch', { + name: /(?:Set up|Configure) Alert on Config Errors enabled state/, }), ); @@ -936,8 +939,8 @@ describe('AutomationsSettings', () => { render(); fireEvent.click( - await screen.findByRole('button', { - name: /(?:Set up|Configure) Alert on Config Errors/, + await screen.findByRole('switch', { + name: /(?:Set up|Configure) Alert on Config Errors enabled state/, }), ); @@ -951,8 +954,8 @@ describe('AutomationsSettings', () => { it('hides the launch mode picker when decision mode is disabled', async () => { render(); - const expandButton = await screen.findByRole('button', { - name: /(?:Set up|Configure) Auto-respond to channels/, + const expandButton = await screen.findByRole('switch', { + name: /(?:Set up|Configure) Auto-respond to channels enabled state/, }); fireEvent.click(expandButton); @@ -991,69 +994,51 @@ describe('AutomationsSettings', () => { render(); await screen.findByText('Triage Dependabot Alerts'); - const providerSupport = screen.getAllByText('GitHub only')[0]!; - expect(providerSupport.tagName).toBe('P'); - expect(providerSupport).toHaveClass('text-sm', 'text-foreground'); + const providerSupport = screen.getAllByText(/GitHub only/)[0]!; + expect(providerSupport.tagName).toBe('SPAN'); + expect(providerSupport.closest('[role="row"]')).toHaveTextContent( + 'Triage Dependabot Alerts', + ); }); - it('groups built-in automations into Enabled and Available sections', async () => { + it('renders custom and built-in automations in one list by default', async () => { render(); - expect(await screen.findByText('Enabled')).toBeInTheDocument(); - expect(screen.getByText('Available')).toBeInTheDocument(); - expect(screen.queryByText('Source Code automations')).toBeNull(); - expect(screen.queryByText('Meta automations')).toBeNull(); + expect( + await screen.findByRole('table', { name: 'Automations' }), + ).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'All' })).toBeChecked(); + expect(screen.getByText('Auto-respond to channels')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Enabled' })).toHaveClass( + 'sr-only', + ); + expect(screen.getByRole('columnheader', { name: 'Actions' })).toHaveClass( + 'sr-only', + ); + expect(screen.queryByText('Available')).not.toBeInTheDocument(); }); - it('links enabled built-in automations to their filtered task history', async () => { + it('uses the left switch as the built-in configuration entry point', async () => { render(); + const enabledSwitch = await screen.findByRole('switch', { + name: 'Configure Auto-respond to channels enabled state', + }); + expect(enabledSwitch).toBeChecked(); expect( - await screen.findByRole('link', { + screen.queryByRole('link', { name: 'View previous runs for Auto-respond to channels', }), - ).toHaveAttribute( - 'href', - '/tasks?userId=automation%3Aslack_channel_auto_start', - ); + ).not.toBeInTheDocument(); expect( - screen.queryByRole('link', { - name: 'View previous runs for Review Code', + screen.getByRole('button', { + name: 'Configure Auto-respond to channels', }), - ).not.toBeInTheDocument(); - }); - - it.each([ - ['callRoomoteViaEmoji', 'call_roomote_via_emoji'], - ['channelAutoStart', 'slack_channel_auto_start'], - ['managerStats', 'manager_stats'], - ['sentryTriage', 'sentry_triage'], - ['dependabotTriage', 'dependabot_triage'], - ['codeqlTriage', 'codeql_triage'], - ['issueFixer', 'issue_fixer'], - ['securityAuditor', 'security_auditor'], - ['codeQualityAuditor', 'code_quality_auditor'], - ['ciFailureTriage', 'ci_failure_triage'], - ['reviewer', 'review_code'], - ['conflictResolver', 'conflict_resolver'], - ['suggester', 'suggester'], - ['announcer', 'announcer'], - ['platformIssueAlerts', 'platform_issue_alerts'], - ] as const)( - 'builds the filtered task history link for %s', - (automationId, automationKey) => { - expect(getAutomationHistoryHref(automationId)).toBe( - `/tasks?userId=${encodeURIComponent(`automation:${automationKey}`)}`, - ); - }, - ); - - it('does not add task history to non-running built-in configuration', () => { - expect(getAutomationHistoryHref('managerChannel')).toBeNull(); - }); - - it('does not add task history to provider usage alerts', () => { - expect(getAutomationHistoryHref('providerUsageLimit')).toBeNull(); + ).toBeInTheDocument(); + fireEvent.click(enabledSwitch); + expect( + await screen.findByRole('dialog', { name: 'Auto-respond to channels' }), + ).toBeInTheDocument(); }); it('shows Merge announcer as a webhook-driven automation without task history', async () => { @@ -1071,10 +1056,10 @@ describe('AutomationsSettings', () => { 'Summarize commits pushed to each active repository’s default branch and announce who pushed them.', ), ).toBeInTheDocument(); - expect(getAutomationHistoryHref('mergeAnnouncer')).toBeNull(); - fireEvent.click( - screen.getByRole('button', { name: 'Configure Merge announcer' }), + screen.getByRole('switch', { + name: 'Configure Merge announcer enabled state', + }), ); expect( screen.getByRole('combobox', { name: 'Destination provider' }), @@ -1084,33 +1069,22 @@ describe('AutomationsSettings', () => { ).toHaveTextContent('DM me'); }); - it('filters available automations by category and provider-aware search', async () => { + it('filters the unified list by type and searches built-in summaries', async () => { render(); - const categoryFilter = await screen.findByRole('combobox', { - name: 'Filter available automations by category', - }); - expect(categoryFilter).toHaveTextContent('All'); - fireEvent.change( - screen.getByRole('textbox', { name: 'Search available automations' }), - { target: { value: 'Discord' } }, - ); - - expect(screen.getByText('Auto-respond to channels')).toBeInTheDocument(); - expect(screen.queryByText('Review Code')).not.toBeInTheDocument(); - fireEvent.click( - screen.getByRole('button', { name: 'Clear automation filters' }), + await screen.findByRole('textbox', { name: 'Search automations' }), + { target: { value: 'Pull request events' } }, ); expect(screen.getByText('Review Code')).toBeInTheDocument(); - - fireEvent.click(categoryFilter); - fireEvent.click(await screen.findByRole('option', { name: 'Operations' })); - expect(screen.getByText('Triage Sentry Issues')).toBeInTheDocument(); - expect(screen.queryByText('Review Code')).not.toBeInTheDocument(); expect( - screen.queryByText('Call Roomote via emoji'), + screen.queryByText('Auto-respond to channels'), ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })); + expect(screen.queryByText('Review Code')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('radio', { name: 'Built-in' })); + expect(screen.getByText('Review Code')).toBeInTheDocument(); }); it('shows independent structural skeletons for custom and built-in automations', () => { @@ -1143,11 +1117,12 @@ describe('AutomationsSettings', () => { screen.queryByText('No built-in automations enabled yet.'), ).not.toBeInTheDocument(); expect( - await screen.findByRole('button', { - name: 'Configure Alert on Config Errors', + await screen.findByRole('switch', { + name: 'Configure Alert on Config Errors enabled state', }), ).toBeInTheDocument(); - const customEmptyState = screen.getByText( + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })); + const customEmptyState = await screen.findByText( 'No custom automations created yet.', ); expect(customEmptyState.tagName).toBe('P'); @@ -1218,14 +1193,6 @@ describe('AutomationsSettings', () => { expect( screen.getByRole('button', { name: 'Run Weekly flaky-test scan now' }), ).toBeEnabled(); - expect( - screen.getByRole('link', { - name: 'View previous runs for Weekly flaky-test scan', - }), - ).toHaveAttribute( - 'href', - '/tasks?userId=automation%3Acustom_automation%3Aautomation-1', - ); fireEvent.click( screen.getByRole('button', { name: 'Run Weekly flaky-test scan now' }), ); @@ -1271,11 +1238,6 @@ describe('AutomationsSettings', () => { name: 'Configure Weekly flaky-test scan', }), ).toBeInTheDocument(); - expect( - screen.getByRole('link', { - name: 'View previous runs for Weekly flaky-test scan', - }), - ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Delete Weekly flaky-test scan' }), ).toBeInTheDocument(); @@ -1369,14 +1331,14 @@ describe('AutomationsSettings', () => { render(); expect(await screen.findByText('Daily →')).toBeInTheDocument(); - expect( - screen.getByText('No actionable regressions found.'), - ).toBeInTheDocument(); - expect( - screen.getByRole('link', { - name: 'View previous runs for Fast daily digest', - }), - ).toBeInTheDocument(); + const search = screen.getByRole('textbox', { name: 'Search automations' }); + fireEvent.change(search, { + target: { value: 'Summarize priorities' }, + }); + expect(screen.getByText('Fast daily digest')).toBeInTheDocument(); + fireEvent.change(search, { target: { value: 'No matching automation' } }); + expect(screen.queryByText('Fast daily digest')).not.toBeInTheDocument(); + fireEvent.change(search, { target: { value: '' } }); fireEvent.click( screen.getByRole('button', { name: 'Configure Fast daily digest' }), ); diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index 81e77586a..7fbed6c73 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -2,7 +2,15 @@ import Link from 'next/link'; import type { ComponentType } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { @@ -67,6 +75,7 @@ import { SCHEDULE_ONLY_AUTOMATION_UI_DEFINITIONS, } from './ScheduleOnlyAutomationContent'; import { CustomAutomationsSection } from './CustomAutomationsSection'; +import { AutomationListRow, type AutomationListFilter } from './AutomationList'; import { AutomationDestinationPicker } from './AutomationDestinationPicker'; import { AutomationAdditionalRules } from './CiFailureTriageAdditionalRules'; import { @@ -92,7 +101,6 @@ import { Button, Card, CardHeader, - CardTitle, ChartColumnIncreasing, Check, Dialog, @@ -107,9 +115,6 @@ import { Label, Lightbulb, Play, - Plus, - RotateCcwClock, - Search, Select, SelectContent, SelectItem, @@ -126,7 +131,6 @@ import { Textarea, TriangleAlert, Users, - X, } from '@/components/system'; type FieldErrors = Partial< @@ -252,16 +256,6 @@ type AutomationDefinition = { type AutomationCategory = 'source-code' | 'communication' | 'operations'; -const AUTOMATION_CATEGORY_OPTIONS: Array<{ - value: AutomationCategory | 'all'; - label: string; -}> = [ - { value: 'all', label: 'All' }, - { value: 'source-code', label: 'Source code' }, - { value: 'communication', label: 'Communication' }, - { value: 'operations', label: 'Operations' }, -]; - /** * Where an automation's next run will report, as resolved server-side through * the destination waterfall (own target -> Manager Channel -> primary @@ -595,6 +589,10 @@ const AUTOMATION_DEFINITIONS: Record = { }, }; +const AutomationSummaryContext = createContext< + Partial> +>({}); + const HASH_ALIAS_TO_AUTOMATION_ID: Record = { ...Object.fromEntries( Object.keys(AUTOMATION_DEFINITIONS).map((automationId) => [ @@ -662,34 +660,6 @@ const AUTOMATION_RUN_KEYS_BY_ID: Partial< ), }; -const AUTOMATION_HISTORY_KEYS_BY_ID: Partial< - Record -> = { - callRoomoteViaEmoji: 'call_roomote_via_emoji', - channelAutoStart: 'slack_channel_auto_start', - reviewer: 'review_code', - platformIssueAlerts: 'platform_issue_alerts', - ...AUTOMATION_RUN_KEYS_BY_ID, -}; - -export function getAutomationHistoryHref( - automationId: AutomationId, -): string | null { - // Provider usage alerts are delivered directly to a communication channel; - // their runner does not create Roomote tasks to inspect. - if ( - automationId === 'providerUsageLimit' || - automationId === 'mergeAnnouncer' - ) { - return null; - } - - const automationKey = AUTOMATION_HISTORY_KEYS_BY_ID[automationId]; - return automationKey - ? `/tasks?userId=${encodeURIComponent(`automation:${automationKey}`)}` - : null; -} - type ScheduleOnlyAutomationFrequencyState = Pick< FormState, ScheduleOnlyBackgroundAutomationFrequencyField @@ -1441,76 +1411,66 @@ function AutomationCard({ const actionLabel = iconEnabled ? `Configure ${automation.label}` : `Set up ${automation.label}`; - const historyHref = getAutomationHistoryHref(automation.id); + const summaries = useContext(AutomationSummaryContext); - if (!iconEnabled && !isAvailableMatch) { + if (!isAvailableMatch) { return null; } return (
- - -
-
-
-
- -
-
-
- {automation.label} - {automation.commsBadge || automation.scmBadge ? ( -

- {[automation.commsBadge, automation.scmBadge] - .filter(Boolean) - .join(' · ')} -

- ) : null} -

- {automation.description} -

-
-
-
- {historyHref && iconEnabled && !disabled ? ( - - - - ) : null} - {runAction && iconEnabled && !disabled ? runAction : null} - - - -
-
-
-
+ + + {summaries[automation.id] ?? (iconEnabled ? 'Enabled' : 'Off')} + + {automation.commsBadge || automation.scmBadge ? ( + + ·{' '} + {[automation.commsBadge, automation.scmBadge] + .filter(Boolean) + .join(' · ')} + + ) : null} + + } + enabledControl={ + + onOpenChange(true)} + /> + + } + actions={ + <> + {runAction && iconEnabled && !disabled ? runAction : null} + + + + + } + /> ({ ); } -export function AutomationsSettings() { +export function AutomationsSettings({ + toolbarLeading, +}: { + toolbarLeading?: React.ReactNode; +} = {}) { const showAutomationDebugRuns = false; const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -1661,10 +1625,9 @@ export function AutomationsSettings() { const [openAutomationIds, setOpenAutomationIds] = useState>( () => new Set(), ); - const [availableCategory, setAvailableCategory] = useState< - AutomationCategory | 'all' - >('all'); - const [availableSearch, setAvailableSearch] = useState(''); + const [automationFilter, setAutomationFilter] = + useState('all'); + const [automationSearch, setAutomationSearch] = useState(''); const formStateRef = useRef(null); const savedStateRef = useRef(null); const didApplyInitialHashRef = useRef(false); @@ -2540,28 +2503,108 @@ export function AutomationsSettings() { announcer: announcerIsEnabled, platformIssueAlerts: isPlatformIssueAlertsEnabled(formState), } satisfies Record; - const normalizedAvailableSearch = availableSearch.trim().toLowerCase(); - const availableAutomationMatches = new Set( + + const resolvedDestinationLabel = ( + automationKey: keyof NonNullable< + typeof settingsQuery.data + >['resolvedDestinations'], + ): string => { + const destination = settingsQuery.data?.resolvedDestinations[automationKey]; + if (!destination) return 'No report destination'; + + return `${getCommunicationProviderDisplayName(destination.provider)} ${destination.displayName ?? destination.channelId}`; + }; + const scheduledSummary = ( + frequency: keyof typeof TRIGGERABLE_AUTOMATION_SCHEDULE_LABELS | undefined, + destination?: string, + ): string => { + const schedule = frequency + ? TRIGGERABLE_AUTOMATION_SCHEDULE_LABELS[frequency] + : 'Off'; + return destination ? `${schedule} → ${destination}` : schedule; + }; + const scheduleOnlySummaries = Object.fromEntries( + SCHEDULE_ONLY_BACKGROUND_AUTOMATION_LIST.map((automation) => { + const definition = SCHEDULE_ONLY_AUTOMATION_UI_DEFINITIONS[automation.id]; + const frequency = formState?.[automation.frequencyField]; + const trigger = + definition.control.kind === 'toggle' + ? automation.id === 'issueFixer' + ? 'Issue opened or reopened' + : automation.id === 'mergeAnnouncer' + ? 'Default-branch push' + : 'CI failure event' + : frequency && frequency !== 'off' + ? TRIGGERABLE_AUTOMATION_SCHEDULE_LABELS[frequency] + : 'Off'; + return [ + automation.id, + `${definition.control.kind === 'toggle' && frequency === 'off' ? 'Off · ' : ''}${trigger} → ${ + automation.id === 'issueFixer' + ? 'source control issue' + : resolvedDestinationLabel(automation.automationKey) + }`, + ]; + }), + ) as Record; + const automationSummaries = { + callRoomoteViaEmoji: 'Emoji reaction → source thread', + channelAutoStart: `${formState?.channelAutoStartChannels.length ?? 0} configured channel${formState?.channelAutoStartChannels.length === 1 ? '' : 's'} → Sessions`, + managerChannel: managerChannelConfigured + ? `Shared output → ${managerSlackChannelId ? `Slack ${formState?.managerSlackChannel || managerSlackChannelId}` : `Discord ${formState?.managerDiscordChannel || managerDiscordChannelId}`}` + : 'No shared output destination', + managerStats: scheduledSummary( + formState?.managerStatsFrequency, + resolvedDestinationLabel('manager_stats'), + ), + providerUsageLimit: scheduledSummary( + formState?.providerUsageLimitFrequency, + resolvedDestinationLabel('provider_usage_limit'), + ), + sentryTriage: scheduledSummary( + formState?.sentryTriageFrequency, + resolvedDestinationLabel('sentry_triage'), + ), + dependabotTriage: scheduledSummary( + formState?.dependabotTriageFrequency, + resolvedDestinationLabel('dependabot_triage'), + ), + codeqlTriage: scheduledSummary( + formState?.codeqlTriageFrequency, + resolvedDestinationLabel('codeql_triage'), + ), + ...scheduleOnlySummaries, + reviewer: 'Pull request events and manual runs → source control', + conflictResolver: scheduledSummary(formState?.conflictResolverFrequency), + suggester: scheduledSummary( + formState?.suggesterFrequency, + resolvedDestinationLabel('suggester'), + ), + announcer: scheduledSummary( + formState?.announcerFrequency, + resolvedDestinationLabel('announcer'), + ), + platformIssueAlerts: `Configuration errors → ${resolvedDestinationLabel('platform_issue_alerts')}`, + } satisfies Record; + + const normalizedAutomationSearch = automationSearch.trim().toLowerCase(); + const visibleBuiltInAutomations = new Set( Object.values(AUTOMATION_DEFINITIONS) .filter( (automation) => - !iconEnabled[automation.id] && - (availableCategory === 'all' || - automation.category === availableCategory) && - (!normalizedAvailableSearch || - [ - automation.label, - automation.description, - ...(automation.searchTerms ?? []), - ] - .join(' ') - .toLowerCase() - .includes(normalizedAvailableSearch)), + !normalizedAutomationSearch || + [ + automation.label, + automation.description, + automationSummaries[automation.id], + ...(automation.searchTerms ?? []), + ] + .join(' ') + .toLowerCase() + .includes(normalizedAutomationSearch), ) .map((automation) => automation.id), ); - const hasAvailableFilters = - availableCategory !== 'all' || Boolean(normalizedAvailableSearch); const isAutomationSaving = (automationId: AutomationId) => updateMutation.isPending && savingAutomation === automationId; @@ -2647,86 +2690,24 @@ export function AutomationsSettings() { ) : null} - - - {settingsQuery.isPending || !formState ? ( - - ) : ( -
-
-

- Enabled -

- {Object.values(iconEnabled).some(Boolean) ? null : ( -

- No built-in automations enabled yet. -

- )} -
-

- Available -

-
- -
- - - setAvailableSearch(event.currentTarget.value) - } - placeholder="Search" - aria-label="Search available automations" - className="h-8 w-36 pl-8 text-sm" - /> -
- {hasAvailableFilters ? ( - - - - ) : null} -
-
- {availableAutomationMatches.size === 0 ? ( -

- No available automations match these filters. -

- ) : null} + + {settingsQuery.isPending || !formState ? ( + + ) : visibleBuiltInAutomations.size === 0 ? ( +

+ No built-in automations match your search. +

+ ) : ( + setAutomationOpen('reviewer', open)} iconEnabled={iconEnabled.reviewer} @@ -3008,7 +2989,7 @@ export function AutomationsSettings() { setAutomationOpen('codeqlTriage', open)} iconEnabled={iconEnabled.codeqlTriage} @@ -3638,7 +3619,7 @@ export function AutomationsSettings() { setAutomationOpen('managerChannel', open)} iconEnabled={iconEnabled.managerChannel} @@ -3906,7 +3885,7 @@ export function AutomationsSettings() { setAutomationOpen('managerStats', open)} iconEnabled={iconEnabled.managerStats} @@ -3993,7 +3972,7 @@ export function AutomationsSettings() { setAutomationOpen('sentryTriage', open)} iconEnabled={iconEnabled.sentryTriage} @@ -4302,7 +4281,7 @@ export function AutomationsSettings() { setAutomationOpen('suggester', open)} iconEnabled={iconEnabled.suggester} @@ -4440,7 +4419,7 @@ export function AutomationsSettings() { setAutomationOpen('announcer', open)} iconEnabled={iconEnabled.announcer} @@ -4566,7 +4545,7 @@ export function AutomationsSettings() { -
-
- )} + + )} +
); } diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index deecc9e4e..04e6978db 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import type { ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -16,7 +16,6 @@ import { import { tryParseCronSchedule } from '@/lib/cron-schedule'; import { formatDistanceToNowCompact, formatTimeZone } from '@/lib/formatters'; -import { buildCreatorFilterValue } from '@/lib/task-creator-filter'; import { useTRPC } from '@/trpc/client'; import type { CustomAutomationListItem } from '@/trpc/commands/automations'; @@ -34,7 +33,6 @@ import { Label, Play, Plus, - RotateCcwClock, Select, SelectContent, SelectItem, @@ -45,6 +43,7 @@ import { Switch, Textarea, Trash2, + Zap, } from '@/components/system'; import { ModelSelect } from '@/components/tasks/ModelSelect'; @@ -56,6 +55,12 @@ import { AutomationDestinationPicker, type AutomationDestinationProvider, } from './AutomationDestinationPicker'; +import { + AutomationListHeader, + AutomationListRow, + AutomationListToolbar, + type AutomationListFilter, +} from './AutomationList'; type ConnectedDestinationProvider = Exclude< AutomationDestinationProvider, @@ -281,7 +286,21 @@ function scheduleSummaryLine(summary: string, timeZone: string): string { : `${summary} (${timeZoneLabel})`; } -export function CustomAutomationsSection() { +export function CustomAutomationsSection({ + filter: controlledFilter, + search: controlledSearch, + onFilterChange, + onSearchChange, + toolbarLeading, + children, +}: { + filter?: AutomationListFilter; + search?: string; + onFilterChange?: (filter: AutomationListFilter) => void; + onSearchChange?: (search: string) => void; + toolbarLeading?: ReactNode; + children?: ReactNode; +} = {}) { const { isAdmin } = useAuthorizedUser(); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -309,6 +328,12 @@ export function CustomAutomationsSection() { const [form, setForm] = useState(EMPTY_FORM); const [resolvedCron, setResolvedCron] = useState(null); const [scheduleSummary, setScheduleSummary] = useState(null); + const [localFilter, setLocalFilter] = useState('all'); + const [localSearch, setLocalSearch] = useState(''); + const filter = controlledFilter ?? localFilter; + const search = controlledSearch ?? localSearch; + const setFilter = onFilterChange ?? setLocalFilter; + const setSearch = onSearchChange ?? setLocalSearch; // New destinations default to the shared manager channel, matching where // the other automations report by default. @@ -483,6 +508,51 @@ export function CustomAutomationsSection() { : scheduleSummary; const rows = useMemo(() => listQuery.data ?? [], [listQuery.data]); + const normalizedSearch = search.trim().toLowerCase(); + const visibleRows = + filter === 'built-in' + ? [] + : rows.filter((row) => { + const target = targetFromRow(row); + const environmentName = + row.executionMode === 'fast' + ? '' + : (environmentOptions.find( + (environment) => environment.id === row.environmentId, + )?.name ?? 'Environment missing'); + const destinationName = + DESTINATION_OPTIONS.find( + (option) => option.value === target.provider, + )?.label ?? 'No report channel'; + const destinationLabel = + target.provider === 'slack' + ? (slackOptions.find( + (option) => + option.id === target.channelId || + option.name === target.channelId, + )?.label ?? target.channelId) + : target.provider === 'discord' + ? (discordOptions.find( + (option) => option.id === target.channelId, + )?.label ?? target.channelId) + : target.channelId; + + return ( + !normalizedSearch || + [ + row.name, + row.prompt, + cadenceLabel(row), + environmentName, + destinationName, + destinationLabel, + row.createdByName ?? '', + ] + .join(' ') + .toLowerCase() + .includes(normalizedSearch) + ); + }); const atCap = rows.length >= MAX_CUSTOM_AUTOMATIONS; const busy = createMutation.isPending || @@ -885,54 +955,53 @@ export function CustomAutomationsSection() { ); + const newButton = + !isCreating && !editingId ? ( + + ) : null; + return ( -
-
-
-

- Custom -

-
- {!isCreating && !editingId ? ( - - ) : null} -
+
+ - {listQuery.isPending ? ( - - -
- {Array.from({ length: 2 }).map((_, index) => ( -
- -
- - -
+ + +
+ +
+ {listQuery.isPending && filter !== 'built-in' ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ +
+ + +
+
+ ))}
- ))} -
- - - ) : rows.length === 0 && !isCreating ? ( -

- No custom automations created yet. -

- ) : ( - - -
- {rows.map((row) => { + ) : null} + {!listQuery.isPending && + visibleRows.length === 0 && + (filter === 'custom' || (!children && filter === 'all')) ? ( +

+ {normalizedSearch + ? 'No custom automations match your search.' + : 'No custom automations created yet.'} +

+ ) : null} + {visibleRows.map((row) => { const environmentName = row.executionMode === 'fast' ? null @@ -998,34 +1070,29 @@ export function CustomAutomationsSection() { (option) => option.id === target.channelId, )?.label ?? target.channelId) : target.channelId; - const historyFilter = buildCreatorFilterValue({ - initiatorKind: 'automation', - initiatorUserId: null, - initiatorAutomation: 'custom_automation', - actorExternalId: row.id, - }); - return ( -
- - toggleMutation.mutate({ - id: row.id, - ...writeInputFromRow(row), - enabled, - }) - } - /> -
-

{row.name}

-

+ icon={Zap} + name={row.name} + description={

{row.prompt}

} + enabledControl={ + + toggleMutation.mutate({ + id: row.id, + ...writeInputFromRow(row), + enabled, + }) + } + /> + } + summary={ + <> {cadenceLabel(row)} {environmentName @@ -1043,86 +1110,81 @@ export function CustomAutomationsSection() { {destinationName} {destinationLabel ? ` ${destinationLabel}` : ''} -

-

- Created by {row.createdByName ?? 'Unknown'} - {row.lastRunAt ? ( - <> - {' · Last run '} - - {formatDistanceToNowCompact( - new Date(row.lastRunAt), - { addSuffix: true }, - )} - - - ) : null} -

- {row.latestFastResult ? ( -

- {row.latestFastResult} -

- ) : null} -
-
- {historyFilter ? ( - - - ) : null} - - - - - - - -
-
+ + + + + } + /> ); })} + {filter !== 'custom' ? children : null} + {!listQuery.isPending && + visibleRows.length === 0 && + !children && + filter !== 'custom' && + filter !== 'all' ? ( +

+ No automations match your filters. +

+ ) : null}
-
-
- )} +
+
+
); } diff --git a/apps/web/src/components/settings/pages/AutomationsSettingsPage.client.test.tsx b/apps/web/src/components/settings/pages/AutomationsSettingsPage.client.test.tsx index 70fe6a3e2..843a06583 100644 --- a/apps/web/src/components/settings/pages/AutomationsSettingsPage.client.test.tsx +++ b/apps/web/src/components/settings/pages/AutomationsSettingsPage.client.test.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { render, screen } from '@testing-library/react'; const state = vi.hoisted(() => ({ isAdmin: false })); @@ -7,7 +8,12 @@ vi.mock('@/hooks/useUser', () => ({ })); vi.mock('@/components/settings/automations', () => ({ - AutomationsSettings: () =>
Full automation settings
, + AutomationsSettings: ({ toolbarLeading }: { toolbarLeading?: ReactNode }) => ( + <> + {toolbarLeading} +
Full automation settings
+ + ), })); vi.mock('@/components/settings/automations/CustomAutomationsSection', () => ({ diff --git a/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx b/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx index 9fa2c02e5..9eded11f7 100644 --- a/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx +++ b/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx @@ -33,10 +33,13 @@ export function AutomationsSettingsPage() { {isAdmin ? ( - <> - - - + + + + } + /> ) : ( )} diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts index 960e7513e..f6a0c656b 100644 --- a/apps/web/src/components/system/primitives/icons.ts +++ b/apps/web/src/components/system/primitives/icons.ts @@ -159,7 +159,6 @@ export { RefreshCwIcon, RectangleHorizontal, RotateCcw, - History as RotateCcwClock, RotateCcwKey, ScrollText, Rows4, diff --git a/apps/web/src/testing/exclusive-automation-settings-database-lock.ts b/apps/web/src/testing/exclusive-automation-settings-database-lock.ts new file mode 100644 index 000000000..1174a7442 --- /dev/null +++ b/apps/web/src/testing/exclusive-automation-settings-database-lock.ts @@ -0,0 +1,36 @@ +import { db, sql } from '@roomote/db/server'; + +const AUTOMATION_SETTINGS_TEST_LOCK = [20260910, 2451] as const; + +/** Serializes suites that replace deployment-wide automation settings rows. */ +export function registerExclusiveAutomationSettingsDatabaseLock() { + let releaseLock: (() => void) | undefined; + let lockTransaction: Promise | undefined; + + beforeAll(async () => { + let markAcquired: (() => void) | undefined; + let rejectAcquired: ((error: unknown) => void) | undefined; + const acquired = new Promise((resolve, reject) => { + markAcquired = resolve; + rejectAcquired = reject; + }); + const released = new Promise((resolve) => { + releaseLock = resolve; + }); + + lockTransaction = db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(${AUTOMATION_SETTINGS_TEST_LOCK[0]}, ${AUTOMATION_SETTINGS_TEST_LOCK[1]})`, + ); + markAcquired?.(); + await released; + }); + void lockTransaction.catch((error) => rejectAcquired?.(error)); + await acquired; + }); + + afterAll(async () => { + releaseLock?.(); + await lockTransaction; + }); +} diff --git a/apps/web/src/trpc/commands/automations/__tests__/settings-read.test.ts b/apps/web/src/trpc/commands/automations/__tests__/settings-read.test.ts index 90cf8764b..f76634f92 100644 --- a/apps/web/src/trpc/commands/automations/__tests__/settings-read.test.ts +++ b/apps/web/src/trpc/commands/automations/__tests__/settings-read.test.ts @@ -10,9 +10,12 @@ import { import { USER_FACING_AUTOMATION_KEYS } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; +import { registerExclusiveAutomationSettingsDatabaseLock } from '@/testing/exclusive-automation-settings-database-lock'; import { getBackgroundAgentSettingsCommand } from '../settings-read'; +registerExclusiveAutomationSettingsDatabaseLock(); + const SETTINGS_READ_USER_ID = 'user-settings-read-admin'; const MANAGER_CHANNEL_ID = 'CMANAGER1'; const MANAGER_DISCORD_CHANNEL_ID = 'DMANAGER1'; diff --git a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts index b6a7bc9c1..6e5c3f399 100644 --- a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts +++ b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts @@ -20,6 +20,7 @@ import { } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; +import { registerExclusiveAutomationSettingsDatabaseLock } from '@/testing/exclusive-automation-settings-database-lock'; import { updateBackgroundAgentSettingsCommand } from '../settings-update'; import { mergeAnnouncerDestinationInputSchema } from '../settings-schema'; @@ -60,6 +61,8 @@ vi.mock('@roomote/telemetry/server', () => ({ captureActivationAutomationChanged: mockCaptureActivationAutomationChanged, })); +registerExclusiveAutomationSettingsDatabaseLock(); + // Keep the test hermetic: the command constructs a SlackNotifier whenever a // Slack installation exists and probes channel membership/names after saving. vi.mock('@roomote/slack', async (importOriginal) => { @@ -273,6 +276,11 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => { }); it('tracks a built-in automation when its enabled state changes', async () => { + await upsertAutomation(db, { + key: 'manager_stats', + enabled: false, + schedule: { mode: 'weekly' }, + }); await insertAvailableDiscordChannel({ guildId: 'guild-1', channelId: 'channel-1', @@ -611,6 +619,11 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => { }); it('does not track a built-in automation when its enabled state is unchanged', async () => { + await upsertAutomation(db, { + key: 'manager_stats', + enabled: false, + schedule: { mode: 'weekly' }, + }); const result = await updateBackgroundAgentSettingsCommand( adminAuth, buildInput({ savingAutomation: 'managerStats' }), @@ -800,6 +813,11 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => { }, 15_000); it('switches a Discord manager channel to Slack and clears Discord', async () => { + await upsertAutomation(db, { + key: 'manager_stats', + enabled: false, + schedule: { mode: 'weekly' }, + }); await insertSlackInstallation(); await db.insert(deploymentSettings).values({ id: 'default', diff --git a/apps/web/src/trpc/commands/slack/auth-provisioning.test.ts b/apps/web/src/trpc/commands/slack/auth-provisioning.test.ts index 04c751944..4a3bff473 100644 --- a/apps/web/src/trpc/commands/slack/auth-provisioning.test.ts +++ b/apps/web/src/trpc/commands/slack/auth-provisioning.test.ts @@ -13,6 +13,9 @@ import { } from '@roomote/db/server'; import { USER_FACING_AUTOMATION_KEYS } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; +import { registerExclusiveAutomationSettingsDatabaseLock } from '@/testing/exclusive-automation-settings-database-lock'; + +registerExclusiveAutomationSettingsDatabaseLock(); const { ensureChannel, education, decodeState, fetchMock } = vi.hoisted(() => ({ ensureChannel: vi.fn(),