From 394df5f27787906fa1fec9a2dec2e705645298a0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 10:42:22 +0000 Subject: [PATCH 1/4] feat: generalize automation output routing rules --- AGENTS.md | 1 + apps/docs/automations.mdx | 19 +- .../automations/AutomationsSettings.tsx | 301 ++++++++++++------ ...ilureTriageAdditionalRules.client.test.tsx | 22 +- .../CiFailureTriageAdditionalRules.tsx | 38 ++- .../settings/automations/formState.ts | 22 ++ .../__tests__/settings-update-discord.test.ts | 51 +++ .../automations/ci-failure-triage-routing.ts | 48 ++- .../commands/automations/settings-update.ts | 101 ++++-- .../src/trpc/commands/automations/types.ts | 10 + apps/web/src/trpc/routers/_app.ts | 17 + packages/db/src/lib/automations.ts | 20 ++ packages/db/src/types.ts | 5 + .../automations/__tests__/announcer.test.ts | 34 ++ .../__tests__/merge-announcer.test.ts | 57 ++++ .../merged-pr-audit-runner.pagination.test.ts | 1 + .../__tests__/merged-pr-audit-runner.test.ts | 56 ++++ .../__tests__/suggester-dispatch.test.ts | 50 +-- .../automations/__tests__/suggester.test.ts | 167 ++++++++++ .../sdk/src/server/automations/announcer.ts | 206 +++++++++--- .../automations/ci-failure-triage-routing.ts | 15 +- .../automations/code-quality-auditor.ts | 3 +- .../src/server/automations/merge-announcer.ts | 54 +++- .../automations/merged-pr-audit-runner.ts | 66 +++- .../server/automations/security-auditor.ts | 3 +- .../server/automations/suggester-dispatch.ts | 13 +- .../sdk/src/server/automations/suggester.ts | 160 ++++++++-- packages/sdk/src/server/index.ts | 1 + .../background-automation-registry.test.ts | 16 + .../types/src/automation-additional-rules.ts | 66 ++++ .../src/background-automation-registry.ts | 54 ++++ .../types/src/ci-failure-triage-routing.ts | 68 +--- packages/types/src/index.ts | 1 + 33 files changed, 1375 insertions(+), 371 deletions(-) create mode 100644 packages/sdk/src/server/automations/__tests__/suggester.test.ts create mode 100644 packages/types/src/automation-additional-rules.ts diff --git a/AGENTS.md b/AGENTS.md index b176b6cc83..79574251ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ This repository is open source. Treat GitHub and other public surfaces as fully - Treat workflow prompts and instructions as a first-class control surface. When agent behavior is off, debug prompt clarity before defaulting to code enforcement. - `apps/docs/` is the public product documentation site (published at `https://docs.roomote.dev`) and should be kept in sync with user-facing product changes. - Keep equivalent functionality in sync across supported source-control, communication, and sandbox providers whenever applicable. Do not intentionally make provider-specific exceptions unless the user explicitly requests one. +- When adding or changing a built-in automation, explicitly evaluate whether it needs text-based Additional rules for repository scope, per-repository communication-output routing, and residual workflow/report guidance. Opt in when its output goes to a communication provider and can contain material relevant to different people or teams; exclude inherently global, triage-oriented (except CI Failure Triage), and non-communication-output automations. Keep the eligibility metadata, settings UI, save-time compilation and validation, persisted automation settings, runtime scope/routing enforcement, and prompt propagation aligned. - **Schema N-1 rollback guarantee:** Roomote must always be able to roll application code back one release against the current database. Do not drop tables or columns that the previous release still reads or writes in the same release that removes the feature. Stop using the columns in app code first, keep them in `packages/db` with an explicit N-1 comment, and drop them only after the next release is the supported rollback target. See `packages/db/AGENTS.md` for the package-local rules. ## Slack message formatting diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index c1db593320..bfda383e67 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -491,8 +491,10 @@ For Gitea, repository webhook resync includes the `workflow_run` event so Actions completions reach Roomote; host matching uses the deployment `GITEA_BASE_URL`. -In **Settings > Automations > CI Failure Triage**, choose the standard report -destination and optionally describe **Additional rules** in natural language: +For **Suggest Ideas**, **Summarize Merged PRs**, **Security Auditor**, **Code +Quality Auditor**, **CI Failure Triage**, and **Merge announcer**, choose the +standard report destination and optionally describe **Additional rules** in +natural language: > Only triage backend and platform. Send platform failures to #platform-ci in our Engineering Slack workspace. @@ -502,13 +504,16 @@ workspace, when names could be ambiguous. Unclear, contradictory, unavailable, or unsupported rules cannot be saved. An inference provider must be available to interpret new rules. A failed save leaves the previous configuration intact. -Repository restrictions apply to webhook failures and **Run now**. Explicit +Repository restrictions apply to scheduled runs, **Run now**, and webhook runs +where the automation uses them. Explicit restrictions select the matching repositories at save time; save revised rules to include newly added repositories. Destination-only rules do not limit scope: -other repositories continue using the standard destination. Investigation and -report-writing guidance can also be included. Workflow, job, branch, and time -conditions are not supported in additional rules. Clear the rules to restore -all-repository triage using the standard destination. +other repositories continue using the standard destination. Workflow and +report-writing guidance can also be included. Pre-run workflow, job, branch, +and time conditions are not supported in Additional rules. Clear the rules to +restore the automation's all-repository behavior using the standard +destination. Existing **Suggestion preferences** and merged-PR **Additional +instructions** remain separate and keep their current meaning. Repositories still need an active connection and a configured environment. **Run now** starts at most one eligible investigation per click, using that diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index a87ad3836a..81e77586af 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -68,7 +68,7 @@ import { } from './ScheduleOnlyAutomationContent'; import { CustomAutomationsSection } from './CustomAutomationsSection'; import { AutomationDestinationPicker } from './AutomationDestinationPicker'; -import { CiFailureTriageAdditionalRules } from './CiFailureTriageAdditionalRules'; +import { AutomationAdditionalRules } from './CiFailureTriageAdditionalRules'; import { buildAutomationDiscordDestinationOptions, buildManagerSlackChannelOptions, @@ -133,6 +133,11 @@ type FieldErrors = Partial< Record< | 'general' | 'ciFailureTriageAdditionalRules' + | 'suggesterAdditionalRules' + | 'announcerAdditionalRules' + | 'securityAuditorAdditionalRules' + | 'codeQualityAuditorAdditionalRules' + | 'mergeAnnouncerAdditionalRules' | 'reviewerEnvironmentIds' | 'reviewerCollaborators' | 'reviewerExcludedAuthors' @@ -820,11 +825,13 @@ function mapSettingsToFormState( suggesterTelegramChatId: string | null; suggesterTeamsChannelId: string | null; suggesterInstructions: string | null; + suggesterAdditionalRules?: string; announcerFrequency: AnnouncerFrequency; announcerSlackChannelId: string | null; announcerSlackChannelName?: string | null; announcerDiscordChannelId: string | null; announcerInstructions: string | null; + announcerAdditionalRules?: string; platformIssueAlertsEnabled: boolean; platformIssueSlackChannelId: string | null; platformIssueSlackChannelName?: string | null; @@ -832,9 +839,11 @@ function mapSettingsToFormState( securityAuditorSlackChannelId: string | null; securityAuditorSlackChannelName?: string | null; securityAuditorDiscordChannelId: string | null; + securityAuditorAdditionalRules?: string; codeQualityAuditorSlackChannelId: string | null; codeQualityAuditorSlackChannelName?: string | null; codeQualityAuditorDiscordChannelId: string | null; + codeQualityAuditorAdditionalRules?: string; ciFailureTriageSlackChannelId: string | null; ciFailureTriageSlackChannelName?: string | null; ciFailureTriageDiscordChannelId: string | null; @@ -842,6 +851,7 @@ function mapSettingsToFormState( mergeAnnouncerTargetProvider: CommunicationProvider | null; mergeAnnouncerTargetMode: 'channel' | 'direct_message' | null; mergeAnnouncerTargetChannelId: string | null; + mergeAnnouncerAdditionalRules?: string; } & ScheduleOnlyAutomationFrequencyState & { issueFixerInstructions: string | null; }, @@ -948,6 +958,7 @@ function mapSettingsToFormState( suggesterUseTelegram: Boolean(settings.suggesterTelegramChatId), suggesterUseTeams: Boolean(settings.suggesterTeamsChannelId), suggesterInstructions: settings.suggesterInstructions ?? '', + suggesterAdditionalRules: settings.suggesterAdditionalRules ?? '', announcerFrequency: settings.announcerFrequency, announcerSlackChannel: settings.announcerSlackChannelName ?? @@ -955,6 +966,7 @@ function mapSettingsToFormState( '', announcerDiscordChannel: settings.announcerDiscordChannelId ?? '', announcerInstructions: settings.announcerInstructions ?? '', + announcerAdditionalRules: settings.announcerAdditionalRules ?? '', platformIssueAlertsEnabled: settings.platformIssueAlertsEnabled, platformIssueSlackChannel: settings.platformIssueSlackChannelName ?? @@ -967,12 +979,16 @@ function mapSettingsToFormState( '', securityAuditorDiscordChannel: settings.securityAuditorDiscordChannelId ?? '', + securityAuditorAdditionalRules: + settings.securityAuditorAdditionalRules ?? '', codeQualityAuditorSlackChannel: settings.codeQualityAuditorSlackChannelName ?? settings.codeQualityAuditorSlackChannelId ?? '', codeQualityAuditorDiscordChannel: settings.codeQualityAuditorDiscordChannelId ?? '', + codeQualityAuditorAdditionalRules: + settings.codeQualityAuditorAdditionalRules ?? '', ciFailureTriageSlackChannel: settings.ciFailureTriageSlackChannelName ?? settings.ciFailureTriageSlackChannelId ?? @@ -984,6 +1000,7 @@ function mapSettingsToFormState( settings.mergeAnnouncerTargetProvider ?? 'none', mergeAnnouncerTargetMode: settings.mergeAnnouncerTargetMode ?? 'channel', mergeAnnouncerTargetChannelId: settings.mergeAnnouncerTargetChannelId ?? '', + mergeAnnouncerAdditionalRules: settings.mergeAnnouncerAdditionalRules ?? '', }; } @@ -3353,47 +3370,69 @@ export function AutomationsSettings() { } > {automation.id === 'mergeAnnouncer' ? ( - - settingsQuery.data?.capabilities[ - `${provider}Connected` as keyof typeof settingsQuery.data.capabilities - ] === true, - )} - slackOptions={buildSlackDestinationOptions( - formState.mergeAnnouncerTargetProvider === 'slack' - ? formState.mergeAnnouncerTargetChannelId - : null, - )} - discordOptions={mergeAnnouncerDiscordOptions} - defaultSlackChannelId={managerSlackChannelId ?? ''} - defaultDiscordChannelId={managerDiscordChannelId ?? ''} - noneLabel="Default" - noneDescription="Uses the Manager Channel or primary conversation fallback." - onChange={(destination) => + setFormState((previous) => previous ? { ...previous, - mergeAnnouncerTargetProvider: - destination.provider, - mergeAnnouncerTargetMode: destination.mode, - mergeAnnouncerTargetChannelId: - destination.channelId, + mergeAnnouncerAdditionalRules: value, } : previous, ) } + error={fieldErrors.mergeAnnouncerAdditionalRules} + globalDestination={ + + settingsQuery.data?.capabilities[ + `${provider}Connected` as keyof typeof settingsQuery.data.capabilities + ] === true, + )} + slackOptions={buildSlackDestinationOptions( + formState.mergeAnnouncerTargetProvider === 'slack' + ? formState.mergeAnnouncerTargetChannelId + : null, + )} + discordOptions={mergeAnnouncerDiscordOptions} + defaultSlackChannelId={managerSlackChannelId ?? ''} + defaultDiscordChannelId={ + managerDiscordChannelId ?? '' + } + noneLabel="Default" + noneDescription="Uses the Manager Channel or primary conversation fallback." + onChange={(destination) => + setFormState((previous) => + previous + ? { + ...previous, + mergeAnnouncerTargetProvider: + destination.provider, + mergeAnnouncerTargetMode: + destination.mode, + mergeAnnouncerTargetChannelId: + destination.channelId, + } + : previous, + ) + } + /> + } /> ) : ( - setFormState((previous) => @@ -3669,44 +3708,72 @@ export function AutomationsSettings() { ) } > - {renderSlackDestinationField({ - field: + + setFormState((previous) => + previous + ? automation.id === 'securityAuditor' + ? { + ...previous, + securityAuditorAdditionalRules: value, + } + : { + ...previous, + codeQualityAuditorAdditionalRules: value, + } + : previous, + ) + } + error={ automation.id === 'securityAuditor' - ? (settingsQuery.data?.settings - .securityAuditorSlackChannelId ?? null) - : automation.id === 'codeQualityAuditor' + ? fieldErrors.securityAuditorAdditionalRules + : fieldErrors.codeQualityAuditorAdditionalRules + } + globalDestination={renderSlackDestinationField({ + field: + automation.id === 'securityAuditor' + ? 'securityAuditorSlackChannel' + : automation.id === 'codeQualityAuditor' + ? 'codeQualityAuditorSlackChannel' + : 'ciFailureTriageSlackChannel', + inputId: `${automation.id}-slack-channel`, + label: 'Post follow-up work to this Slack channel', + helperText: + automation.id === 'ciFailureTriage' + ? 'Choose where Roomote should post CI failure triage work.' + : 'Choose where Roomote should post actionable follow-up work.', + savedChannelId: + automation.id === 'securityAuditor' ? (settingsQuery.data?.settings - .codeQualityAuditorSlackChannelId ?? null) - : (settingsQuery.data?.settings - .ciFailureTriageSlackChannelId ?? null), - savedDiscordChannelId: - automation.id === 'securityAuditor' - ? (settingsQuery.data?.settings - .securityAuditorDiscordChannelId ?? null) - : automation.id === 'codeQualityAuditor' + .securityAuditorSlackChannelId ?? null) + : automation.id === 'codeQualityAuditor' + ? (settingsQuery.data?.settings + .codeQualityAuditorSlackChannelId ?? null) + : (settingsQuery.data?.settings + .ciFailureTriageSlackChannelId ?? null), + savedDiscordChannelId: + automation.id === 'securityAuditor' ? (settingsQuery.data?.settings - .codeQualityAuditorDiscordChannelId ?? null) - : (settingsQuery.data?.settings - .ciFailureTriageDiscordChannelId ?? null), - warningChannelId: - automation.id === 'securityAuditor' - ? slackChannelAccessWarnings.securityAuditorSlackChannel - : automation.id === 'codeQualityAuditor' - ? slackChannelAccessWarnings.codeQualityAuditorSlackChannel - : slackChannelAccessWarnings.ciFailureTriageSlackChannel, - })} + .securityAuditorDiscordChannelId ?? null) + : automation.id === 'codeQualityAuditor' + ? (settingsQuery.data?.settings + .codeQualityAuditorDiscordChannelId ?? null) + : (settingsQuery.data?.settings + .ciFailureTriageDiscordChannelId ?? null), + warningChannelId: + automation.id === 'securityAuditor' + ? slackChannelAccessWarnings.securityAuditorSlackChannel + : automation.id === 'codeQualityAuditor' + ? slackChannelAccessWarnings.codeQualityAuditorSlackChannel + : slackChannelAccessWarnings.ciFailureTriageSlackChannel, + })} + /> ); @@ -4301,29 +4368,43 @@ export function AutomationsSettings() { {suggesterIsEnabled ? (
<> - {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, - allowTelegram: true, - savedTelegramSelected: Boolean( - settingsQuery.data?.settings.suggesterTelegramChatId, - ), - allowTeams: true, - savedTeamsSelected: Boolean( - settingsQuery.data?.settings.suggesterTeamsChannelId, - ), - })} + + setFormState((previous) => + previous + ? { ...previous, suggesterAdditionalRules: value } + : previous, + ) + } + error={fieldErrors.suggesterAdditionalRules} + globalDestination={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, + allowTelegram: true, + savedTelegramSelected: Boolean( + settingsQuery.data?.settings + .suggesterTelegramChatId, + ), + allowTeams: true, + savedTeamsSelected: Boolean( + settingsQuery.data?.settings + .suggesterTeamsChannelId, + ), + })} + />