From 46b6f3d293f9d39db1470927d25d1c0e766386e0 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sat, 21 Feb 2026 02:47:39 -0300 Subject: [PATCH 1/4] feat: add incident detection automation --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 1 + .../resolvers/automations.schema.ts | 1 + .../automations/services/automation.types.ts | 30 +++- .../card-automation/card-automation.tsx | 46 +++--- .../header-automation/header-automation.tsx | 4 +- .../form-incident-detection-settings.tsx | 128 ++++++++++++++++ .../form-incident-detection-settings/index.ts | 1 + .../settings/incident-detection/page.tsx | 141 ++++++++++++++++++ .../settings/incident-detection/types.ts | 21 +++ .../app/automations/use-automation-cards.tsx | 41 +++-- apps/web/src/routes.tsx | 5 + packages/graphql-types/api.ts | 1 + packages/graphql-types/frontend/graphql.ts | 1 + 14 files changed, 388 insertions(+), 35 deletions(-) create mode 100644 apps/api/prisma/migrations/20260221000000_add_incident_detection_automation_type/migration.sql create mode 100644 apps/web/src/app/automations/settings/incident-detection/components/form-incident-detection-settings/form-incident-detection-settings.tsx create mode 100644 apps/web/src/app/automations/settings/incident-detection/components/form-incident-detection-settings/index.ts create mode 100644 apps/web/src/app/automations/settings/incident-detection/page.tsx create mode 100644 apps/web/src/app/automations/settings/incident-detection/types.ts diff --git a/apps/api/prisma/migrations/20260221000000_add_incident_detection_automation_type/migration.sql b/apps/api/prisma/migrations/20260221000000_add_incident_detection_automation_type/migration.sql new file mode 100644 index 00000000..2b885a53 --- /dev/null +++ b/apps/api/prisma/migrations/20260221000000_add_incident_detection_automation_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "AutomationType" ADD VALUE 'INCIDENT_DETECTION'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4af2250a..63febf04 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -58,6 +58,7 @@ enum IntegrationApp { enum AutomationType { PR_TITLE_CHECK PR_SIZE_LABELER + INCIDENT_DETECTION } enum DigestType { diff --git a/apps/api/src/app/automations/resolvers/automations.schema.ts b/apps/api/src/app/automations/resolvers/automations.schema.ts index 875f7b4b..1a5c3aba 100644 --- a/apps/api/src/app/automations/resolvers/automations.schema.ts +++ b/apps/api/src/app/automations/resolvers/automations.schema.ts @@ -2,6 +2,7 @@ export default /* GraphQL */ ` enum AutomationType { PR_TITLE_CHECK PR_SIZE_LABELER + INCIDENT_DETECTION } type Automation { diff --git a/apps/api/src/app/automations/services/automation.types.ts b/apps/api/src/app/automations/services/automation.types.ts index af3db34b..b7abac26 100644 --- a/apps/api/src/app/automations/services/automation.types.ts +++ b/apps/api/src/app/automations/services/automation.types.ts @@ -40,6 +40,7 @@ export interface CanRunAutomationArgs { export type AutomationTypeMap = { [AutomationType.PR_TITLE_CHECK]: AutomationPrTitleCheck; [AutomationType.PR_SIZE_LABELER]: AutomationPrSizeLabeler; + [AutomationType.INCIDENT_DETECTION]: AutomationIncidentDetection; }; export interface AutomationPrTitleCheck extends Omit { @@ -54,13 +55,40 @@ export interface AutomationPrSizeLabeler extends Omit { export type AutomationSettings = | AutomationPrTitleCheck - | AutomationPrSizeLabeler; + | AutomationPrSizeLabeler + | AutomationIncidentDetection; export interface PrTitleCheckSettings extends Prisma.JsonObject { regex?: string; regexExample?: string; } +export interface AutomationIncidentDetection + extends Omit { + type: typeof AutomationType.INCIDENT_DETECTION; + settings: IncidentDetectionSettings; +} + +export interface IncidentDetectionSettings { + revert?: { + enabled?: boolean; + }; + hotfix?: { + detectByTitle?: { + enabled?: boolean; + regex?: string; + }; + detectByBranch?: { + enabled?: boolean; + regex?: string; + }; + detectByLabel?: { + enabled?: boolean; + label?: string; + }; + }; +} + export interface PrSizeLabelerSettings { repositories?: string[]; labels?: { diff --git a/apps/web/src/app/automations/components/card-automation/card-automation.tsx b/apps/web/src/app/automations/components/card-automation/card-automation.tsx index 6cc606ca..bb1c0d8b 100644 --- a/apps/web/src/app/automations/components/card-automation/card-automation.tsx +++ b/apps/web/src/app/automations/components/card-automation/card-automation.tsx @@ -45,28 +45,32 @@ export const CardAutomation = ({ p="md" style={{ borderBottom: "1px solid #303030", flexGrow: 1 }} > - - - {benefits && - Object.entries(benefits).map( - ([key, value]) => - value && ( - - ), - )} - - - - {title} - - {description} - + + + + {benefits && + Object.entries(benefits).map( + ([key, value]) => + value && ( + + ), + )} + + + + {title} + + {description} + + - + + + diff --git a/apps/web/src/app/automations/settings/components/header-automation/header-automation.tsx b/apps/web/src/app/automations/settings/components/header-automation/header-automation.tsx index 2bf06bca..a24f0f72 100644 --- a/apps/web/src/app/automations/settings/components/header-automation/header-automation.tsx +++ b/apps/web/src/app/automations/settings/components/header-automation/header-automation.tsx @@ -11,7 +11,9 @@ export const HeaderAutomation = ({ automation }: HeaderAutomationProps) => { return ( <> - + {automation.demoUrl && ( + + )} {automation.description} diff --git a/apps/web/src/app/automations/settings/incident-detection/components/form-incident-detection-settings/form-incident-detection-settings.tsx b/apps/web/src/app/automations/settings/incident-detection/components/form-incident-detection-settings/form-incident-detection-settings.tsx new file mode 100644 index 00000000..84226478 --- /dev/null +++ b/apps/web/src/app/automations/settings/incident-detection/components/form-incident-detection-settings/form-incident-detection-settings.tsx @@ -0,0 +1,128 @@ +import { + Divider, + SimpleGrid, + Stack, + Switch, + TextInput, + Title, +} from "@mantine/core"; +import { BoxSetting } from "../../../../../../components/box-setting"; +import { UseFormReturnType } from "@mantine/form"; +import { FormIncidentDetection } from "../../types"; + +interface FormIncidentDetectionSettingsProps { + form: UseFormReturnType; +} + +export const FormIncidentDetectionSettings = ({ + form, +}: FormIncidentDetectionSettingsProps) => { + return ( + <> + + Settings + + + + + + + {form.values.enabled && ( + <> + + + + Reverts + + + + + + + + + + Rollback + + + + + + + + + + Hotfixes + + + + {form.values.settings.hotfix?.enabled && ( + + + + + + )} + + + )} + + ); +}; diff --git a/apps/web/src/app/automations/settings/incident-detection/components/form-incident-detection-settings/index.ts b/apps/web/src/app/automations/settings/incident-detection/components/form-incident-detection-settings/index.ts new file mode 100644 index 00000000..ef629789 --- /dev/null +++ b/apps/web/src/app/automations/settings/incident-detection/components/form-incident-detection-settings/index.ts @@ -0,0 +1 @@ +export { FormIncidentDetectionSettings } from "./form-incident-detection-settings"; diff --git a/apps/web/src/app/automations/settings/incident-detection/page.tsx b/apps/web/src/app/automations/settings/incident-detection/page.tsx new file mode 100644 index 00000000..69dcbbc9 --- /dev/null +++ b/apps/web/src/app/automations/settings/incident-detection/page.tsx @@ -0,0 +1,141 @@ +import { Stack, Title, Skeleton, Group, Text, Button } from "@mantine/core"; +import { LoadableContent } from "../../../../components/loadable-content"; +import { AutomationType } from "@sweetr/graphql-types/frontend/graphql"; +import { useAutomationSettings } from "../use-automation"; +import { FormIncidentDetectionSettings } from "./components/form-incident-detection-settings"; +import { useDrawerPage } from "../../../../providers/drawer-page.provider"; +import { DrawerScrollable } from "../../../../components/drawer-scrollable"; +import { ButtonDocs } from "../../../../components/button-docs"; +import { useForm, zodResolver } from "@mantine/form"; +import { FormEventHandler, useEffect, useMemo } from "react"; +import { FormIncidentDetection } from "./types"; +import { HeaderAutomation } from "../components/header-automation"; + +const defaultValues: FormIncidentDetection = { + enabled: false, + settings: { + revert: { + enabled: true, + }, + hotfix: { + enabled: true, + prTitleRegEx: "hotfix", + branchRegEx: "^hotfix", + prLabelRegEx: "hotfix", + }, + rollback: { + enabled: true, + }, + }, +}; + +export const AutomationIncidentDetectionPage = () => { + const { automation, automationSettings, query, mutation, mutate } = + useAutomationSettings(AutomationType.INCIDENT_DETECTION); + + const drawerProps = useDrawerPage({ + closeUrl: `/automations`, + }); + + const form = useForm({ + validate: zodResolver(FormIncidentDetection), + }); + + useEffect(() => { + const settings = automationSettings?.settings as + | FormIncidentDetection["settings"] + | undefined + | null; + + form.setValues({ + enabled: automationSettings?.enabled || false, + settings: { + revert: { + enabled: + settings?.revert?.enabled ?? defaultValues.settings.revert.enabled, + }, + rollback: { + enabled: + settings?.rollback?.enabled ?? + defaultValues.settings.rollback.enabled, + }, + hotfix: { + enabled: + settings?.hotfix?.enabled ?? defaultValues.settings.hotfix.enabled, + prTitleRegEx: + settings?.hotfix?.prTitleRegEx ?? + defaultValues.settings.hotfix.prTitleRegEx, + branchRegEx: + settings?.hotfix?.branchRegEx ?? + defaultValues.settings.hotfix.branchRegEx, + prLabelRegEx: + settings?.hotfix?.prLabelRegEx ?? + defaultValues.settings.hotfix.prLabelRegEx, + }, + }, + }); + form.resetDirty(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [automationSettings]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const isFormValid = useMemo(() => !form.validate().hasErrors, [form.values]); + + const handleSave: FormEventHandler = async (event) => { + event.preventDefault(); + + if (form.validate().hasErrors) return; + + await mutate({ + settings: form.values.settings, + enabled: form.values.enabled, + }); + }; + + return ( + + + {automation?.icon} + + + {automation?.title} + + + } + toolbar={automation.docsUrl && } + actions={ + + } + onSubmit={handleSave} + > + + + + + + + } + isLoading={query.isLoading} + content={ + <> + + + + } + /> + + ); +}; diff --git a/apps/web/src/app/automations/settings/incident-detection/types.ts b/apps/web/src/app/automations/settings/incident-detection/types.ts new file mode 100644 index 00000000..9d52813c --- /dev/null +++ b/apps/web/src/app/automations/settings/incident-detection/types.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const FormIncidentDetection = z.object({ + enabled: z.boolean(), + settings: z.object({ + revert: z.object({ + enabled: z.boolean(), + }), + hotfix: z.object({ + enabled: z.boolean(), + prTitleRegEx: z.string().optional(), + branchRegEx: z.string().optional(), + prLabelRegEx: z.string().optional(), + }), + rollback: z.object({ + enabled: z.boolean(), + }), + }), +}); + +export type FormIncidentDetection = z.infer; diff --git a/apps/web/src/app/automations/use-automation-cards.tsx b/apps/web/src/app/automations/use-automation-cards.tsx index 724c5157..c41e415f 100644 --- a/apps/web/src/app/automations/use-automation-cards.tsx +++ b/apps/web/src/app/automations/use-automation-cards.tsx @@ -1,21 +1,21 @@ import { fork } from "radash"; const automationCards = { - PR_TITLE_CHECK: { - type: "PR_TITLE_CHECK", + INCIDENT_DETECTION: { + type: "INCIDENT_DETECTION", enabled: false, available: true, - title: "PR Title Requirements", + title: "Incident Detection", description: - "Enforce standards on Pull Request titles. Ticket code, specific prefix, or something else? You pick it.", + "Automatically detect incidents from rollbacks, hotfixes and reverts.", shortDescription: - "Enforce standards on Pull Request titles. Ticket code, specific prefix, or something else? You pick it.", - demoUrl: "/images/automations/pr-title-check-demo.webp", - docsUrl: "https://docs.sweetr.dev/features/automations/pr-title-check", - color: "red.1", - icon: "✍️", + "Automatically detect incidents from rollbacks, hotfixes and reverts.", + demoUrl: null, + docsUrl: "https://docs.sweetr.dev/features/automations/incident-detection", + color: "orange.1", + icon: "🚨", benefits: { - compliance: "Standardize Pull Request titles across the organization.", + failureRate: "Track production incidents automatically.", }, }, PR_SIZE_LABELER: { @@ -29,13 +29,30 @@ const automationCards = { "Automatically label a Pull Request with its size. Increase awareness on creating small PRs.", demoUrl: "/images/automations/pr-size-labeler.webp", docsUrl: "https://docs.sweetr.dev/features/automations/pr-size-labeler", - color: "green.1", + color: "indigo.1", icon: "📏", benefits: { cycleTime: "Encourage faster reviews on smaller PRs.", failureRate: "Mitigate reviewer fatigue with smaller PRs.", }, }, + PR_TITLE_CHECK: { + type: "PR_TITLE_CHECK", + enabled: false, + available: true, + title: "PR Title Requirements", + description: + "Enforce standards on Pull Request titles. Ticket code, specific prefix, or something else? You pick it.", + shortDescription: + "Enforce standards on Pull Request titles. Ticket code, specific prefix, or something else? You pick it.", + demoUrl: "/images/automations/pr-title-check-demo.webp", + docsUrl: "https://docs.sweetr.dev/features/automations/pr-title-check", + color: "lime.1", + icon: "✍️", + benefits: { + compliance: "Standardize Pull Request titles across the organization.", + }, + }, // TO-DO: Build UI to control these automations (they are team settings today) // PULL_REQUEST_ALERTS: { // type: "PULL_REQUEST_ALERTS", @@ -97,7 +114,7 @@ const automationCards = { "Auto-generated release notes for every deployment posted in your Slack.", demoUrl: "/images/automations/pr-title-check-demo.webp", docsUrl: "https://docs.sweetr.dev/", - color: "green.1", + color: "teal.1", icon: "📝", benefits: { compliance: diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index b9650e22..93f0d365 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -4,6 +4,7 @@ import { ErrorPage } from "./app/500"; import { LoginPage } from "./app/auth/login/page"; import { AutomationsPage } from "./app/automations/page"; import { AutomationPrSizeLabelerPage } from "./app/automations/settings/pr-size-labeler/page"; +import { AutomationIncidentDetectionPage } from "./app/automations/settings/incident-detection/page"; import { AutomationPrTitleCheckPage } from "./app/automations/settings/pr-title-check/page"; import { GithubInstallPage } from "./app/github/install/page"; import { OAuthGithubPage } from "./app/github/oauth/page"; @@ -394,6 +395,10 @@ export const router = createBrowserRouter([ path: "/automations/pr-size-labeler", element: , }, + { + path: "/automations/incident-detection", + element: , + }, ], }, ], diff --git a/packages/graphql-types/api.ts b/packages/graphql-types/api.ts index 1eade9c2..4b4293a3 100644 --- a/packages/graphql-types/api.ts +++ b/packages/graphql-types/api.ts @@ -162,6 +162,7 @@ export type AutomationQueryInput = { }; export enum AutomationType { + INCIDENT_DETECTION = 'INCIDENT_DETECTION', PR_SIZE_LABELER = 'PR_SIZE_LABELER', PR_TITLE_CHECK = 'PR_TITLE_CHECK' } diff --git a/packages/graphql-types/frontend/graphql.ts b/packages/graphql-types/frontend/graphql.ts index 74582afd..1267cfe1 100644 --- a/packages/graphql-types/frontend/graphql.ts +++ b/packages/graphql-types/frontend/graphql.ts @@ -159,6 +159,7 @@ export type AutomationQueryInput = { }; export enum AutomationType { + INCIDENT_DETECTION = 'INCIDENT_DETECTION', PR_SIZE_LABELER = 'PR_SIZE_LABELER', PR_TITLE_CHECK = 'PR_TITLE_CHECK' } From 824730e5bf927ea69986036c06bb85cc7b9c8dc3 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sat, 21 Feb 2026 03:15:26 -0300 Subject: [PATCH 2/4] fix: backend types --- .../automations/services/automation.types.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/api/src/app/automations/services/automation.types.ts b/apps/api/src/app/automations/services/automation.types.ts index b7abac26..b8eda2d5 100644 --- a/apps/api/src/app/automations/services/automation.types.ts +++ b/apps/api/src/app/automations/services/automation.types.ts @@ -74,18 +74,13 @@ export interface IncidentDetectionSettings { enabled?: boolean; }; hotfix?: { - detectByTitle?: { - enabled?: boolean; - regex?: string; - }; - detectByBranch?: { - enabled?: boolean; - regex?: string; - }; - detectByLabel?: { - enabled?: boolean; - label?: string; - }; + enabled?: boolean; + prTitleRegEx?: string; + branchRegEx?: string; + prLabelRegEx?: string; + }; + rollback?: { + enabled?: boolean; }; } From df5a45ebd72cf3479c1cfc489f9e9ec2277f3dcf Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sat, 21 Feb 2026 03:27:17 -0300 Subject: [PATCH 3/4] feat: validate regex --- .../settings/incident-detection/types.ts | 16 +++++++++++++--- .../automations/settings/pr-title-check/types.ts | 2 +- .../validation.provider.ts} | 0 3 files changed, 14 insertions(+), 4 deletions(-) rename apps/web/src/{app/automations/settings/pr-title-check/components/form-pr-title-check-settings/regex.provider.ts => providers/validation.provider.ts} (100%) diff --git a/apps/web/src/app/automations/settings/incident-detection/types.ts b/apps/web/src/app/automations/settings/incident-detection/types.ts index 9d52813c..b8736c03 100644 --- a/apps/web/src/app/automations/settings/incident-detection/types.ts +++ b/apps/web/src/app/automations/settings/incident-detection/types.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { validateRegEx } from "../../../../providers/validation.provider"; export const FormIncidentDetection = z.object({ enabled: z.boolean(), @@ -8,9 +9,18 @@ export const FormIncidentDetection = z.object({ }), hotfix: z.object({ enabled: z.boolean(), - prTitleRegEx: z.string().optional(), - branchRegEx: z.string().optional(), - prLabelRegEx: z.string().optional(), + prTitleRegEx: z + .string() + .refine(validateRegEx, "Invalid regular expression") + .optional(), + branchRegEx: z + .string() + .refine(validateRegEx, "Invalid regular expression") + .optional(), + prLabelRegEx: z + .string() + .refine(validateRegEx, "Invalid regular expression") + .optional(), }), rollback: z.object({ enabled: z.boolean(), diff --git a/apps/web/src/app/automations/settings/pr-title-check/types.ts b/apps/web/src/app/automations/settings/pr-title-check/types.ts index 3ce4759b..9693bfa7 100644 --- a/apps/web/src/app/automations/settings/pr-title-check/types.ts +++ b/apps/web/src/app/automations/settings/pr-title-check/types.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { validateRegEx } from "./components/form-pr-title-check-settings/regex.provider"; +import { validateRegEx } from "../../../../providers/validation.provider"; import { stringCantBeEmpty } from "../../../../providers/zod-rules.provider"; export const FormPrTitleCheck = z.object({ diff --git a/apps/web/src/app/automations/settings/pr-title-check/components/form-pr-title-check-settings/regex.provider.ts b/apps/web/src/providers/validation.provider.ts similarity index 100% rename from apps/web/src/app/automations/settings/pr-title-check/components/form-pr-title-check-settings/regex.provider.ts rename to apps/web/src/providers/validation.provider.ts From 75d84a6f2d87d4832590a648b6fda589e914ef42 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Sat, 21 Feb 2026 03:32:25 -0300 Subject: [PATCH 4/4] fix: undefined guards --- .../src/app/automations/settings/incident-detection/page.tsx | 2 +- apps/web/src/app/automations/settings/pr-size-labeler/page.tsx | 2 +- apps/web/src/app/automations/settings/pr-title-check/page.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/automations/settings/incident-detection/page.tsx b/apps/web/src/app/automations/settings/incident-detection/page.tsx index 69dcbbc9..c594145c 100644 --- a/apps/web/src/app/automations/settings/incident-detection/page.tsx +++ b/apps/web/src/app/automations/settings/incident-detection/page.tsx @@ -107,7 +107,7 @@ export const AutomationIncidentDetectionPage = () => { } - toolbar={automation.docsUrl && } + toolbar={automation?.docsUrl && } actions={