From 58759ef27d29b2ea0d790045ad43021c2d8daae1 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 5 Aug 2026 19:19:19 +0000 Subject: [PATCH 1/2] feat: track automation activation events --- .../custom-automations-routes.test.ts | 53 ++++++++ .../src/handlers/custom-automations/index.ts | 10 ++ .../custom-automations-telemetry.test.ts | 117 ++++++++++++++++++ .../__tests__/settings-update-discord.test.ts | 59 +++++++++ .../automations/custom-automations.ts | 11 ++ .../commands/automations/settings-update.ts | 111 +++++++++++++++++ .../telemetry/src/__tests__/telemetry.test.ts | 22 ++++ packages/telemetry/src/index.ts | 49 ++++++++ packages/telemetry/src/server/index.ts | 26 ++++ 9 files changed, 458 insertions(+) create mode 100644 apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts diff --git a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts index abd02df75..91600ac52 100644 --- a/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts +++ b/apps/api/src/handlers/custom-automations/__tests__/custom-automations-routes.test.ts @@ -20,6 +20,7 @@ const { mockListConnectedCommunicationProviders, mockResolveCustomAutomationSchedule, mockRunCustomAutomationNow, + mockCaptureActivationCustomAutomationChanged, } = vi.hoisted(() => ({ mockUsersFindFirst: vi.fn(), mockResolveActingUserIdOrNull: vi.fn(), @@ -31,6 +32,7 @@ const { mockListConnectedCommunicationProviders: vi.fn(), mockResolveCustomAutomationSchedule: vi.fn(), mockRunCustomAutomationNow: vi.fn(), + mockCaptureActivationCustomAutomationChanged: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -52,6 +54,11 @@ vi.mock('@roomote/sdk/server', () => ({ runCustomAutomationNow: mockRunCustomAutomationNow, })); +vi.mock('@roomote/telemetry/server', () => ({ + captureActivationCustomAutomationChanged: + mockCaptureActivationCustomAutomationChanged, +})); + vi.mock('../../mcp/proxy-utils', () => ({ resolveActingUserIdOrNull: mockResolveActingUserIdOrNull, })); @@ -113,6 +120,31 @@ describe('custom-automations MCP routes', () => { }); describe('POST / (create)', () => { + it('tracks creation with only the destination provider', async () => { + const { app } = createApp(); + mockResolveCustomAutomationSchedule.mockResolvedValue({ + status: 'resolved', + scheduleMode: 'daily', + cronExpression: null, + resolution: null, + }); + mockCreateCustomAutomation.mockResolvedValue({ id: 'automation-1' }); + + const res = await postCreate( + app, + createBody({ + targetProvider: 'slack', + targetChannelId: 'private-channel-id', + }), + ); + + expect(res.status).toBe(201); + expect(mockCaptureActivationCustomAutomationChanged).toHaveBeenCalledWith( + 'created', + 'slack', + ); + }); + it('returns 400 with the message when the environment does not exist', async () => { const { app } = createApp(); mockCreateCustomAutomation.mockRejectedValue( @@ -209,6 +241,27 @@ describe('custom-automations MCP routes', () => { }); }); + describe('DELETE /:id', () => { + it('tracks deletion with only the persisted destination provider', async () => { + const { app } = createApp(); + mockGetCustomAutomationById.mockResolvedValue({ + id: 'automation-1', + name: 'Private automation name', + target: { provider: 'discord' }, + }); + + const res = await app.request('/custom-automations/automation-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(200); + expect(mockCaptureActivationCustomAutomationChanged).toHaveBeenCalledWith( + 'deleted', + 'discord', + ); + }); + }); + describe('PATCH /:id (update)', () => { const existing = { id: 'automation-1', diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index b1eea1085..214f322d5 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -25,6 +25,8 @@ import type { CustomAutomationScheduleMode, OptionalAutomationTarget, } from '@roomote/types'; +import { toActivationAutomationDestinationProvider } from '@roomote/telemetry'; +import { captureActivationCustomAutomationChanged } from '@roomote/telemetry/server'; import type { Variables } from '../../types'; import type { McpAuth } from '../mcp/middleware'; @@ -329,6 +331,10 @@ customAutomationsRouter.post('/', async (c) => { target: buildTarget(parsed.data), createdByUserId: adminId(c), }); + void captureActivationCustomAutomationChanged( + 'created', + parsed.data.targetProvider ?? null, + ); return c.json({ automation, resolution: schedule.resolution }, 201); } catch (error) { const known = knownErrorResponse(c, error); @@ -415,6 +421,10 @@ customAutomationsRouter.delete('/:id', async (c) => { if (!existing) return c.json({ error: 'Custom automation was not found.' }, 404); await deleteCustomAutomation(existing.id); + void captureActivationCustomAutomationChanged( + 'deleted', + toActivationAutomationDestinationProvider(existing.target.provider), + ); return c.json({ deleted: { id: existing.id, name: existing.name } }); }); diff --git a/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts b/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts new file mode 100644 index 000000000..9094ee8dc --- /dev/null +++ b/apps/web/src/trpc/commands/automations/__tests__/custom-automations-telemetry.test.ts @@ -0,0 +1,117 @@ +import type { UserAuthSuccess } from '@/types'; + +import { + createCustomAutomationCommand, + deleteCustomAutomationCommand, +} from '../custom-automations'; + +const mocks = vi.hoisted(() => ({ + createCustomAutomation: vi.fn(), + deleteCustomAutomation: vi.fn(), + getCustomAutomationById: vi.fn(), + listConnectedCommunicationProviders: vi.fn(), + captureActivationCustomAutomationChanged: vi.fn(), +})); + +vi.mock('@roomote/db/server', async (importOriginal) => ({ + ...(await importOriginal()), + createCustomAutomation: mocks.createCustomAutomation, + deleteCustomAutomation: mocks.deleteCustomAutomation, + getCustomAutomationById: mocks.getCustomAutomationById, +})); + +vi.mock('@roomote/sdk/server', async (importOriginal) => ({ + ...(await importOriginal()), + listConnectedCommunicationProviders: + mocks.listConnectedCommunicationProviders, +})); + +vi.mock('@roomote/telemetry/server', () => ({ + captureActivationCustomAutomationChanged: + mocks.captureActivationCustomAutomationChanged, +})); + +const adminAuth = { + success: true, + userType: 'user', + userId: 'user-admin', + name: 'Admin', + primaryEmail: 'admin@example.com', + isAdmin: true, + featureFlags: {}, + anonymousAnalyticsEnabled: false, + cloudEnabled: false, + cookieConsentedAt: null, + resource: { + username: null, + fullName: null, + firstName: null, + lastName: null, + primaryEmailAddress: null, + emailAddresses: [], + imageUrl: '', + createdAt: null, + }, +} satisfies UserAuthSuccess; + +function customAutomation(target: { provider?: string } = {}) { + return { + id: 'automation-id', + name: 'Private automation name', + prompt: 'Private prompt', + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + model: null, + environmentId: 'environment-id', + target, + lastRunAt: null, + lastSucceededAt: null, + lastFailedAt: null, + lastError: null, + lastLaunchedTaskId: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }; +} + +describe('custom automation activation telemetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listConnectedCommunicationProviders.mockResolvedValue(['slack']); + }); + + it('tracks creation with only the destination provider classification', async () => { + mocks.createCustomAutomation.mockResolvedValue( + customAutomation({ provider: 'slack' }), + ); + + await createCustomAutomationCommand(adminAuth, { + name: 'Private automation name', + prompt: 'Private prompt', + enabled: true, + scheduleMode: 'daily', + environmentId: 'environment-id', + targetProvider: 'slack', + targetChannelId: 'private-channel-id', + }); + + expect(mocks.captureActivationCustomAutomationChanged).toHaveBeenCalledWith( + 'created', + 'slack', + ); + }); + + it('tracks deletion with only the persisted destination provider classification', async () => { + mocks.getCustomAutomationById.mockResolvedValue( + customAutomation({ provider: 'discord' }), + ); + + await deleteCustomAutomationCommand(adminAuth, { id: 'automation-id' }); + + expect(mocks.captureActivationCustomAutomationChanged).toHaveBeenCalledWith( + 'deleted', + 'discord', + ); + }); +}); 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 6f4430c23..7e222b62f 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 @@ -16,6 +16,12 @@ import type { UserAuthSuccess } from '@/types'; import { updateBackgroundAgentSettingsCommand } from '../settings-update'; import type { UpdateBackgroundAgentSettingsInput } from '../types'; +const mockCaptureActivationAutomationChanged = vi.hoisted(() => vi.fn()); + +vi.mock('@roomote/telemetry/server', () => ({ + captureActivationAutomationChanged: mockCaptureActivationAutomationChanged, +})); + // 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) => { @@ -195,6 +201,7 @@ async function getAutomationTargets(key: BackgroundAutomationKey) { describe('updateBackgroundAgentSettingsCommand Discord destinations', () => { beforeEach(async () => { + mockCaptureActivationAutomationChanged.mockClear(); await db.delete(automations); await db.delete(deploymentSettings); await db.delete(discordInstallations); @@ -202,6 +209,58 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => { await db.delete(users).where(eq(users.id, adminAuth.userId)); }); + it('tracks a built-in automation when its enabled state changes', async () => { + await insertAvailableDiscordChannel({ + guildId: 'guild-1', + channelId: 'channel-1', + channelName: 'reports', + }); + + const result = await updateBackgroundAgentSettingsCommand( + adminAuth, + buildInput({ + savingAutomation: 'managerStats', + managerStatsFrequency: 'weekly', + managerStatsDiscordChannel: 'channel-1', + }), + ); + + expect(result.success).toBe(true); + expect(mockCaptureActivationAutomationChanged).toHaveBeenCalledWith( + 'enabled', + 'manager_stats', + ); + }); + + it('does not track a built-in automation when its enabled state is unchanged', async () => { + const result = await updateBackgroundAgentSettingsCommand( + adminAuth, + buildInput({ savingAutomation: 'managerStats' }), + ); + + expect(result.success).toBe(true); + expect(mockCaptureActivationAutomationChanged).not.toHaveBeenCalled(); + }); + + it('tracks a built-in automation when it is disabled', async () => { + await upsertAutomation(db, { + key: 'manager_stats', + enabled: true, + schedule: { mode: 'weekly' }, + }); + + const result = await updateBackgroundAgentSettingsCommand( + adminAuth, + buildInput({ savingAutomation: 'managerStats' }), + ); + + expect(result.success).toBe(true); + expect(mockCaptureActivationAutomationChanged).toHaveBeenCalledWith( + 'disabled', + 'manager_stats', + ); + }); + it('preserves a disabled emoji trigger during an unrelated save', async () => { await upsertAutomation(db, { key: 'call_roomote_via_emoji', diff --git a/apps/web/src/trpc/commands/automations/custom-automations.ts b/apps/web/src/trpc/commands/automations/custom-automations.ts index 64e19da8e..19469d304 100644 --- a/apps/web/src/trpc/commands/automations/custom-automations.ts +++ b/apps/web/src/trpc/commands/automations/custom-automations.ts @@ -22,6 +22,8 @@ import { type CustomAutomationScheduleMode, type OptionalAutomationTarget, } from '@roomote/types'; +import { captureActivationCustomAutomationChanged } from '@roomote/telemetry/server'; +import { toActivationAutomationDestinationProvider } from '@roomote/telemetry'; import type { UserAuthSuccess } from '@/types'; @@ -191,6 +193,11 @@ export async function createCustomAutomationCommand( createdByUserId: auth.userId, }); + void captureActivationCustomAutomationChanged( + 'created', + input.targetProvider ?? null, + ); + return toListItem(created); } @@ -237,6 +244,10 @@ export async function deleteCustomAutomationCommand( } await deleteCustomAutomation(input.id); + void captureActivationCustomAutomationChanged( + 'deleted', + toActivationAutomationDestinationProvider(existing.target.provider), + ); return { success: true }; } diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts index 53f2ff558..0ec6df3bb 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -25,6 +25,8 @@ import { } from '@roomote/sdk/server'; import { resolveConfiguredGitHubAppSlug } from '@roomote/github'; import { SlackNotifier } from '@roomote/slack'; +import { captureActivationAutomationChanged } from '@roomote/telemetry/server'; +import type { ActivationAutomation } from '@roomote/telemetry'; import type { UserAuthSuccess } from '@/types'; @@ -77,6 +79,97 @@ import type { UpdateBackgroundAgentSettingsInput, } from './types'; +type BackgroundAgentSettings = Awaited< + ReturnType +>; + +function getSavedAutomationActivation( + savingAutomation: UpdateBackgroundAgentSettingsInput['savingAutomation'], + settings: BackgroundAgentSettings, +): { automation: ActivationAutomation; enabled: boolean } | null { + switch (savingAutomation) { + case 'callRoomoteViaEmoji': + return { + automation: 'call_roomote_via_emoji', + enabled: settings.callRoomoteViaEmojiEnabled, + }; + case 'channelAutoStart': + return { + automation: 'slack_channel_auto_start', + enabled: settings.channelAutoStartEnabled, + }; + case 'reviewer': + return { + automation: 'review_code', + enabled: settings.reviewCodeSettings.enabled === true, + }; + case 'conflictResolver': + return { + automation: 'conflict_resolver', + enabled: settings.conflictResolverFrequency !== 'off', + }; + case 'managerStats': + return { + automation: 'manager_stats', + enabled: settings.managerStatsFrequency !== 'off', + }; + case 'sentryTriage': + return { + automation: 'sentry_triage', + enabled: settings.sentryTriageFrequency !== 'off', + }; + case 'dependabotTriage': + return { + automation: 'dependabot_triage', + enabled: settings.dependabotTriageFrequency !== 'off', + }; + case 'codeqlTriage': + return { + automation: 'codeql_triage', + enabled: settings.codeqlTriageFrequency !== 'off', + }; + case 'issueFixer': + return { + automation: 'issue_fixer', + enabled: settings.issueFixerFrequency !== 'off', + }; + case 'securityAuditor': + return { + automation: 'security_auditor', + enabled: settings.securityAuditorFrequency !== 'off', + }; + case 'codeQualityAuditor': + return { + automation: 'code_quality_auditor', + enabled: settings.codeQualityAuditorFrequency !== 'off', + }; + case 'ciFailureTriage': + return { + automation: 'ci_failure_triage', + enabled: settings.ciFailureTriageFrequency !== 'off', + }; + case 'suggester': + return { + automation: 'suggester', + enabled: settings.suggesterFrequency !== 'off', + }; + case 'announcer': + return { + automation: 'announcer', + enabled: settings.announcerFrequency !== 'off', + }; + case 'platformIssueAlerts': + return { + automation: 'platform_issue_alerts', + enabled: + settings.platformIssueSlackChannelId !== null || + settings.platformIssueDiscordChannelId !== null, + }; + case 'managerChannel': + return null; + } +} + function parseSentryProjectSlugs(value: string | null | undefined): string[] { return [ ...new Set( @@ -1290,6 +1383,24 @@ export async function updateBackgroundAgentSettingsCommand( ]); const updatedSettings = await getBackgroundAgentSettingsForDeployment(); + const previousActivation = getSavedAutomationActivation( + input.savingAutomation, + existingSettings, + ); + const updatedActivation = getSavedAutomationActivation( + input.savingAutomation, + updatedSettings, + ); + if ( + previousActivation && + updatedActivation && + previousActivation.enabled !== updatedActivation.enabled + ) { + void captureActivationAutomationChanged( + updatedActivation.enabled ? 'enabled' : 'disabled', + updatedActivation.automation, + ); + } const updatedChannelAutoStartSlackChannelIds = updatedSettings.channelAutoStartSlackChannels.map( ({ channelId }) => channelId, diff --git a/packages/telemetry/src/__tests__/telemetry.test.ts b/packages/telemetry/src/__tests__/telemetry.test.ts index c858c29dc..6c4937fcc 100644 --- a/packages/telemetry/src/__tests__/telemetry.test.ts +++ b/packages/telemetry/src/__tests__/telemetry.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it, vi } from 'vitest'; import { buildActivationPrMergedProperties, + buildActivationAutomationProperties, + buildActivationCustomAutomationProperties, buildActivationSetupMilestoneProperties, buildActivationTaskProperties, PAGEVIEW_EVENT, TELEMETRY_EVENT_NAME_PATTERN, + toActivationAutomationDestinationProvider, } from '../index'; import { getTelemetryConfigurationNotice, @@ -226,4 +229,23 @@ describe('activation event properties', () => { }), ).toEqual({ provider: 'github', workflow: 'standard', surface: 'web' }); }); + + it('allows only the built-in automation identifier', () => { + expect(buildActivationAutomationProperties('manager_stats')).toEqual({ + automation: 'manager_stats', + }); + }); + + it('allows only the custom automation destination provider', () => { + expect(buildActivationCustomAutomationProperties('slack')).toEqual({ + destinationProvider: 'slack', + }); + expect(buildActivationCustomAutomationProperties(null)).toEqual({ + destinationProvider: null, + }); + expect(toActivationAutomationDestinationProvider('discord')).toBe( + 'discord', + ); + expect(toActivationAutomationDestinationProvider('sentry')).toBeNull(); + }); }); diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index 5afea2d51..a9afddfe2 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -118,6 +118,55 @@ export function buildActivationPrMergedProperties(properties: { }; } +export type ActivationAutomationAction = 'enabled' | 'disabled'; + +export type ActivationAutomation = + | 'call_roomote_via_emoji' + | 'slack_channel_auto_start' + | 'review_code' + | 'conflict_resolver' + | 'manager_stats' + | 'sentry_triage' + | 'dependabot_triage' + | 'codeql_triage' + | 'issue_fixer' + | 'security_auditor' + | 'code_quality_auditor' + | 'ci_failure_triage' + | 'suggester' + | 'announcer' + | 'platform_issue_alerts'; + +export function buildActivationAutomationProperties( + automation: ActivationAutomation, +): TelemetryEventProperties { + return { automation }; +} + +export type ActivationCustomAutomationAction = 'created' | 'deleted'; +export type ActivationAutomationDestinationProvider = + | 'slack' + | 'discord' + | 'teams' + | 'telegram'; + +export function toActivationAutomationDestinationProvider( + provider: string | null | undefined, +): ActivationAutomationDestinationProvider | null { + return provider === 'slack' || + provider === 'discord' || + provider === 'teams' || + provider === 'telegram' + ? provider + : null; +} + +export function buildActivationCustomAutomationProperties( + destinationProvider: ActivationAutomationDestinationProvider | null, +): TelemetryEventProperties { + return { destinationProvider }; +} + /** * Wire types for the hosted Ping service (`/v1/*`). Versioned: breaking * changes require a new API version, additive fields do not. diff --git a/packages/telemetry/src/server/index.ts b/packages/telemetry/src/server/index.ts index b33cfeeac..d8ddd58d7 100644 --- a/packages/telemetry/src/server/index.ts +++ b/packages/telemetry/src/server/index.ts @@ -30,10 +30,16 @@ import { type PingVersionCheckResponse, type TelemetryEventProperties, type ActivationEnvironmentSource, + type ActivationAutomationAction, + type ActivationAutomation, + type ActivationAutomationDestinationProvider, + type ActivationCustomAutomationAction, type ActivationSetupMilestone, type ActivationSetupMilestoneProperties, type ActivationTaskProperties, buildActivationPrMergedProperties, + buildActivationAutomationProperties, + buildActivationCustomAutomationProperties, buildActivationSetupMilestoneProperties, buildActivationTaskProperties, } from '../index'; @@ -322,6 +328,26 @@ export async function captureActivationEnvironmentSaved( return captureInstanceEvent('activation_environment_saved', { source }); } +export async function captureActivationAutomationChanged( + action: ActivationAutomationAction, + automation: ActivationAutomation, +): Promise { + return captureInstanceEvent( + `activation_automation_${action}`, + buildActivationAutomationProperties(automation), + ); +} + +export async function captureActivationCustomAutomationChanged( + action: ActivationCustomAutomationAction, + destinationProvider: ActivationAutomationDestinationProvider | null, +): Promise { + return captureInstanceEvent( + `activation_custom_automation_${action}`, + buildActivationCustomAutomationProperties(destinationProvider), + ); +} + export async function captureActivationSetupMilestone( milestone: ActivationSetupMilestone, properties: ActivationSetupMilestoneProperties = {}, From 444dcb8ba2c5c2ff346b3b95aee7dbf2fca1090e Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 5 Aug 2026 19:37:12 +0000 Subject: [PATCH 2/2] fix: capture cascaded automation activations --- .../handlers/slack/events/member-joined.ts | 13 ++ .../__tests__/settings-update-discord.test.ts | 10 + .../commands/automations/settings-update.ts | 178 ++++++++---------- 3 files changed, 101 insertions(+), 100 deletions(-) diff --git a/apps/api/src/handlers/slack/events/member-joined.ts b/apps/api/src/handlers/slack/events/member-joined.ts index 5257fe302..c86ae8437 100644 --- a/apps/api/src/handlers/slack/events/member-joined.ts +++ b/apps/api/src/handlers/slack/events/member-joined.ts @@ -15,6 +15,7 @@ import type { SlackMemberJoinedChannelEvent, SlackNotifier, } from '@roomote/slack'; +import { captureActivationAutomationChanged } from '@roomote/telemetry/server'; import { SLACK_WELCOME_MESSAGE_CHANNEL_LIMIT } from '../constants.js'; @@ -234,4 +235,16 @@ export async function maybePostSlackChannelWelcome(params: { throw error; } + + if (shouldEnableStarterAutomations) { + for (const [automation, wasEnabled] of [ + ['suggester', settings.suggesterFrequency !== 'off'], + ['announcer', settings.announcerFrequency !== 'off'], + ['manager_stats', settings.managerStatsFrequency !== 'off'], + ] as const) { + if (!wasEnabled) { + void captureActivationAutomationChanged('enabled', automation); + } + } + } } 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 7e222b62f..8ad045f17 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 @@ -335,7 +335,17 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => { if (result.success) { expect(result.settings.managerSlackChannelId).toBe('C123456NEW'); expect(result.settings.managerDiscordChannelId).toBeNull(); + expect(result.settings.suggesterFrequency).toBe('daily'); + expect(result.settings.announcerFrequency).toBe('weekly'); + expect(result.settings.managerStatsFrequency).toBe('weekly'); } + expect(mockCaptureActivationAutomationChanged.mock.calls).toEqual( + expect.arrayContaining([ + ['enabled', 'suggester'], + ['enabled', 'announcer'], + ['enabled', 'manager_stats'], + ]), + ); }, 15_000); it('preserves a Discord manager channel when a legacy manager save omits the Discord field', async () => { diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts index 0ec6df3bb..2ef39fdee 100644 --- a/apps/web/src/trpc/commands/automations/settings-update.ts +++ b/apps/web/src/trpc/commands/automations/settings-update.ts @@ -83,91 +83,73 @@ type BackgroundAgentSettings = Awaited< ReturnType >; -function getSavedAutomationActivation( - savingAutomation: UpdateBackgroundAgentSettingsInput['savingAutomation'], +function getAutomationActivations( settings: BackgroundAgentSettings, -): { automation: ActivationAutomation; enabled: boolean } | null { - switch (savingAutomation) { - case 'callRoomoteViaEmoji': - return { - automation: 'call_roomote_via_emoji', - enabled: settings.callRoomoteViaEmojiEnabled, - }; - case 'channelAutoStart': - return { - automation: 'slack_channel_auto_start', - enabled: settings.channelAutoStartEnabled, - }; - case 'reviewer': - return { - automation: 'review_code', - enabled: settings.reviewCodeSettings.enabled === true, - }; - case 'conflictResolver': - return { - automation: 'conflict_resolver', - enabled: settings.conflictResolverFrequency !== 'off', - }; - case 'managerStats': - return { - automation: 'manager_stats', - enabled: settings.managerStatsFrequency !== 'off', - }; - case 'sentryTriage': - return { - automation: 'sentry_triage', - enabled: settings.sentryTriageFrequency !== 'off', - }; - case 'dependabotTriage': - return { - automation: 'dependabot_triage', - enabled: settings.dependabotTriageFrequency !== 'off', - }; - case 'codeqlTriage': - return { - automation: 'codeql_triage', - enabled: settings.codeqlTriageFrequency !== 'off', - }; - case 'issueFixer': - return { - automation: 'issue_fixer', - enabled: settings.issueFixerFrequency !== 'off', - }; - case 'securityAuditor': - return { - automation: 'security_auditor', - enabled: settings.securityAuditorFrequency !== 'off', - }; - case 'codeQualityAuditor': - return { - automation: 'code_quality_auditor', - enabled: settings.codeQualityAuditorFrequency !== 'off', - }; - case 'ciFailureTriage': - return { - automation: 'ci_failure_triage', - enabled: settings.ciFailureTriageFrequency !== 'off', - }; - case 'suggester': - return { - automation: 'suggester', - enabled: settings.suggesterFrequency !== 'off', - }; - case 'announcer': - return { - automation: 'announcer', - enabled: settings.announcerFrequency !== 'off', - }; - case 'platformIssueAlerts': - return { - automation: 'platform_issue_alerts', - enabled: - settings.platformIssueSlackChannelId !== null || - settings.platformIssueDiscordChannelId !== null, - }; - case 'managerChannel': - return null; - } +): Array<{ automation: ActivationAutomation; enabled: boolean }> { + return [ + { + automation: 'call_roomote_via_emoji', + enabled: settings.callRoomoteViaEmojiEnabled, + }, + { + automation: 'slack_channel_auto_start', + enabled: settings.channelAutoStartEnabled, + }, + { + automation: 'review_code', + enabled: settings.reviewCodeSettings.enabled === true, + }, + { + automation: 'conflict_resolver', + enabled: settings.conflictResolverFrequency !== 'off', + }, + { + automation: 'manager_stats', + enabled: settings.managerStatsFrequency !== 'off', + }, + { + automation: 'sentry_triage', + enabled: settings.sentryTriageFrequency !== 'off', + }, + { + automation: 'dependabot_triage', + enabled: settings.dependabotTriageFrequency !== 'off', + }, + { + automation: 'codeql_triage', + enabled: settings.codeqlTriageFrequency !== 'off', + }, + { + automation: 'issue_fixer', + enabled: settings.issueFixerFrequency !== 'off', + }, + { + automation: 'security_auditor', + enabled: settings.securityAuditorFrequency !== 'off', + }, + { + automation: 'code_quality_auditor', + enabled: settings.codeQualityAuditorFrequency !== 'off', + }, + { + automation: 'ci_failure_triage', + enabled: settings.ciFailureTriageFrequency !== 'off', + }, + { + automation: 'suggester', + enabled: settings.suggesterFrequency !== 'off', + }, + { + automation: 'announcer', + enabled: settings.announcerFrequency !== 'off', + }, + { + automation: 'platform_issue_alerts', + enabled: + settings.platformIssueSlackChannelId !== null || + settings.platformIssueDiscordChannelId !== null, + }, + ]; } function parseSentryProjectSlugs(value: string | null | undefined): string[] { @@ -1383,23 +1365,19 @@ export async function updateBackgroundAgentSettingsCommand( ]); const updatedSettings = await getBackgroundAgentSettingsForDeployment(); - const previousActivation = getSavedAutomationActivation( - input.savingAutomation, - existingSettings, - ); - const updatedActivation = getSavedAutomationActivation( - input.savingAutomation, - updatedSettings, + const previousActivations = new Map( + getAutomationActivations(existingSettings).map((activation) => [ + activation.automation, + activation.enabled, + ]), ); - if ( - previousActivation && - updatedActivation && - previousActivation.enabled !== updatedActivation.enabled - ) { - void captureActivationAutomationChanged( - updatedActivation.enabled ? 'enabled' : 'disabled', - updatedActivation.automation, - ); + for (const activation of getAutomationActivations(updatedSettings)) { + if (previousActivations.get(activation.automation) !== activation.enabled) { + void captureActivationAutomationChanged( + activation.enabled ? 'enabled' : 'disabled', + activation.automation, + ); + } } const updatedChannelAutoStartSlackChannelIds = updatedSettings.channelAutoStartSlackChannels.map(