From fc4664c56d4e0a2c4e4b55b8c6d33519dadf1485 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Mon, 23 Feb 2026 01:39:18 -0300 Subject: [PATCH 1/8] feat: incident detection automation --- apps/api/prisma/schema.prisma | 3 + .../services/work-log.unit.test.ts | 3 + .../deployment-create-from-merge.service.ts | 6 + .../services/deployment-pr-linking.service.ts | 6 + .../services/github-pull-request.service.ts | 11 + .../services/incident-detection.service.ts | 256 ++++++++++++++++++ .../services/incident-detection.types.ts | 4 + .../automation-incident-detection.worker.ts | 22 ++ apps/api/src/bull-mq/queues.ts | 1 + 9 files changed, 312 insertions(+) create mode 100644 apps/api/src/app/incidents/services/incident-detection.service.ts create mode 100644 apps/api/src/app/incidents/services/incident-detection.types.ts create mode 100644 apps/api/src/app/incidents/workers/automation-incident-detection.worker.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 63febf04..e8502539 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -369,8 +369,11 @@ model PullRequest { title String number String + sourceBranch String @default("") targetBranch String @default("main") + body String @default("") + labels Json @default("[]") files Json @default("[]") commentCount Int changedFilesCount Int diff --git a/apps/api/src/app/activity-events/services/work-log.unit.test.ts b/apps/api/src/app/activity-events/services/work-log.unit.test.ts index fa8b92e9..1468bc38 100644 --- a/apps/api/src/app/activity-events/services/work-log.unit.test.ts +++ b/apps/api/src/app/activity-events/services/work-log.unit.test.ts @@ -58,12 +58,14 @@ describe("groupSerialReviews", () => { type, eventAt, pullRequest: { + sourceBranch: "feat/test", targetBranch: "main", mergeCommitSha: null, gitProvider: "GITHUB", gitPullRequestId: "1", gitUrl: "https://github.com/test/test/pull/1", title: "Test PR", + body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", number: "1", files: [], commentCount: 0, @@ -78,6 +80,7 @@ describe("groupSerialReviews", () => { authorId: John, repositoryId: 1, workspaceId: 1, + labels: [], ...pullRequest, }, }); diff --git a/apps/api/src/app/deployment/services/deployment-create-from-merge.service.ts b/apps/api/src/app/deployment/services/deployment-create-from-merge.service.ts index acd972bb..3fb8e996 100644 --- a/apps/api/src/app/deployment/services/deployment-create-from-merge.service.ts +++ b/apps/api/src/app/deployment/services/deployment-create-from-merge.service.ts @@ -9,6 +9,7 @@ import { } from "./deployment-pr-linking.service"; import { CreateDeploymentFromPullRequestMergeArgs } from "./deployment-create-from-merge.types"; import { BusinessRuleException } from "../../errors/exceptions/business-rule.exception"; +import { addJob, SweetQueue } from "../../../bull-mq/queues"; export const createDeploymentFromPullRequestMerge = async ({ application, @@ -79,6 +80,11 @@ export const createDeploymentFromPullRequestMerge = async ({ workspaceId, }); + await addJob(SweetQueue.AUTOMATION_INCIDENT_DETECTION, { + deploymentId: deployment.id, + workspaceId, + }); + logger.info("deploymentCreateFromMergeWorker: Deployment created", { deployment, }); diff --git a/apps/api/src/app/deployment/services/deployment-pr-linking.service.ts b/apps/api/src/app/deployment/services/deployment-pr-linking.service.ts index 3a1bd891..27ef2eb5 100644 --- a/apps/api/src/app/deployment/services/deployment-pr-linking.service.ts +++ b/apps/api/src/app/deployment/services/deployment-pr-linking.service.ts @@ -25,6 +25,7 @@ import { getTimeToDeploy } from "../../github/services/github-pull-request-track import { DataIntegrityException } from "../../errors/exceptions/data-integrity.exception"; import { isBefore } from "date-fns"; import { captureException } from "../../../lib/sentry"; +import { addJob, SweetQueue } from "../../../bull-mq/queues"; export const handleDeploymentPullRequestAutoLinking = async ({ workspaceId, @@ -158,6 +159,11 @@ export const handleDeploymentPullRequestAutoLinking = async ({ pullRequestIds: filteredPullRequests.map((pr) => pr.id), }); + await addJob(SweetQueue.AUTOMATION_INCIDENT_DETECTION, { + deploymentId, + workspaceId, + }); + await Promise.all( filteredPullRequests.map(async (pr) => { if (!pr.tracking) { diff --git a/apps/api/src/app/github/services/github-pull-request.service.ts b/apps/api/src/app/github/services/github-pull-request.service.ts index 88b62b56..e6731d51 100644 --- a/apps/api/src/app/github/services/github-pull-request.service.ts +++ b/apps/api/src/app/github/services/github-pull-request.service.ts @@ -124,6 +124,14 @@ const fetchPullRequest = async ( closedAt mergedAt baseRefName + headRefName + body + + labels(first: 100) { + nodes { + name + } + } createdAt updatedAt @@ -287,8 +295,11 @@ const upsertPullRequest = async ( gitPullRequestId: gitPrData.id, gitUrl: gitPrData.url, title: gitPrData.title, + sourceBranch: gitPrData.headRefName ?? "", targetBranch: gitPrData.baseRefName, + body: gitPrData.body ?? "", number: gitPrData.number.toString(), + labels: gitPrData.labels?.nodes?.map((l: { name: string }) => l.name) ?? [], commentCount: gitPrData.totalCommentsCount, changedFilesCount: gitPrData.changedFiles, linesAddedCount: gitPrData.additions, diff --git a/apps/api/src/app/incidents/services/incident-detection.service.ts b/apps/api/src/app/incidents/services/incident-detection.service.ts new file mode 100644 index 00000000..cee88798 --- /dev/null +++ b/apps/api/src/app/incidents/services/incident-detection.service.ts @@ -0,0 +1,256 @@ +import { AutomationType, PullRequest } from "@prisma/client"; +import { getPrisma } from "../../../prisma"; +import { logger } from "../../../lib/logger"; +import { ResourceNotFoundException } from "../../errors/exceptions/resource-not-found.exception"; +import { findDeploymentByIdOrThrow } from "../../deployment/services/deployment.service"; +import { findWorkspaceById } from "../../workspaces/services/workspace.service"; +import { findAutomationByType } from "../../automations/services/automation.service"; +import { isActiveCustomer } from "../../authorization.service"; +import { IncidentDetectionSettings } from "../../automations/services/automation.types"; +import { HandleIncidentDetectionAutomationArgs } from "./incident-detection.types"; + +interface DetectionResult { + causeDeploymentId: number; + fixDeploymentId: number; +} + +export const handleIncidentDetectionAutomation = async ({ + workspaceId, + deploymentId, +}: HandleIncidentDetectionAutomationArgs) => { + const workspace = await findWorkspaceById(workspaceId); + + if (!workspace) { + throw new ResourceNotFoundException( + "handleDeploymentIncidentDetection: Workspace not found", + { extra: { workspaceId } } + ); + } + + const automation = await findAutomationByType({ + workspaceId, + type: AutomationType.INCIDENT_DETECTION, + }); + + if (!automation?.enabled) return; + + if (!isActiveCustomer(workspace)) return; + + const settings = automation.settings as IncidentDetectionSettings; + + const deployment = await findDeploymentByIdOrThrow( + { workspaceId, deploymentId }, + { + include: { + pullRequests: { + include: { + pullRequest: true, + }, + }, + }, + } + ); + + const pullRequests = deployment.pullRequests.map((dp) => dp.pullRequest); + + const result = + (await detectRollback(settings, deployment, workspaceId)) ?? + (await detectRevert(settings, pullRequests, deployment, workspaceId)) ?? + (await detectHotfix(settings, pullRequests, deployment, workspaceId)); + + if (!result) return; + + const existingIncident = await getPrisma(workspaceId).incident.findFirst({ + where: { causeDeploymentId: result.causeDeploymentId, workspaceId }, + }); + + if (existingIncident) { + logger.info( + "handleDeploymentIncidentDetection: Incident already exists for cause deployment", + { causeDeploymentId: result.causeDeploymentId, existingIncident } + ); + return; + } + + await getPrisma(workspaceId).incident.create({ + data: { + causeDeploymentId: result.causeDeploymentId, + fixDeploymentId: result.fixDeploymentId, + detectedAt: deployment.deployedAt, + workspaceId, + }, + }); + + logger.info("handleDeploymentIncidentDetection: Incident created", { + deploymentId, + causeDeploymentId: result.causeDeploymentId, + fixDeploymentId: result.fixDeploymentId, + }); +}; + +const detectRollback = async ( + settings: IncidentDetectionSettings, + deployment: { + id: number; + version: string; + applicationId: number; + environmentId: number; + deployedAt: Date; + }, + workspaceId: number +): Promise => { + if (!settings.rollback?.enabled) return null; + + const rolledBackTo = await getPrisma(workspaceId).deployment.findFirst({ + where: { + workspaceId, + applicationId: deployment.applicationId, + environmentId: deployment.environmentId, + version: deployment.version, + deployedAt: { lt: deployment.deployedAt }, + id: { not: deployment.id }, + archivedAt: null, + }, + orderBy: { deployedAt: "desc" }, + }); + + if (!rolledBackTo) return null; + + const causeDeployment = await getPrisma(workspaceId).deployment.findFirst({ + where: { + workspaceId, + applicationId: deployment.applicationId, + environmentId: deployment.environmentId, + deployedAt: { gt: rolledBackTo.deployedAt }, + id: { not: deployment.id }, + archivedAt: null, + }, + orderBy: { deployedAt: "asc" }, + }); + + if (!causeDeployment) return null; + + logger.info("detectRollback: Rollback detected", { + deploymentId: deployment.id, + rolledBackToId: rolledBackTo.id, + causeDeploymentId: causeDeployment.id, + }); + + return { + causeDeploymentId: causeDeployment.id, + fixDeploymentId: deployment.id, + }; +}; + +const detectRevert = async ( + settings: IncidentDetectionSettings, + pullRequests: PullRequest[], + deployment: { id: number; applicationId: number; environmentId: number }, + workspaceId: number +): Promise => { + if (!settings.revert?.enabled) return null; + + const revertPattern = /^Revert "(.+)"$/; + + for (const pr of pullRequests) { + const match = pr.title.match(revertPattern); + if (!match) continue; + + const originalTitle = match[1]; + + const originalPr = await getPrisma(workspaceId).pullRequest.findFirst({ + where: { + title: originalTitle, + repositoryId: pr.repositoryId, + mergedAt: { not: null }, + id: { not: pr.id }, + }, + orderBy: { mergedAt: "desc" }, + include: { + deploymentEvents: true, + }, + }); + + if (!originalPr) continue; + + const deploymentLink = originalPr.deploymentEvents.find( + (de) => de.deploymentId !== deployment.id + ); + + if (!deploymentLink) continue; + + logger.info("detectRevert: Revert detected", { + revertPrId: pr.id, + originalPrId: originalPr.id, + causeDeploymentId: deploymentLink.deploymentId, + }); + + return { + causeDeploymentId: deploymentLink.deploymentId, + fixDeploymentId: deployment.id, + }; + } + + return null; +}; + +const detectHotfix = async ( + settings: IncidentDetectionSettings, + pullRequests: PullRequest[], + deployment: { + id: number; + applicationId: number; + environmentId: number; + deployedAt: Date; + }, + workspaceId: number +): Promise => { + if (!settings.hotfix?.enabled) return null; + + const { prTitleRegEx, branchRegEx, prLabelRegEx } = settings.hotfix; + + const isHotfix = pullRequests.some((pr) => { + if (prTitleRegEx && new RegExp(prTitleRegEx, "i").test(pr.title)) { + return true; + } + + if (branchRegEx && new RegExp(branchRegEx, "i").test(pr.sourceBranch)) { + return true; + } + + if (prLabelRegEx) { + const labels = (pr.labels as string[]) ?? []; + if (labels.some((label) => new RegExp(prLabelRegEx, "i").test(label))) { + return true; + } + } + + return false; + }); + + if (!isHotfix) return null; + + const previousDeployment = await getPrisma(workspaceId).deployment.findFirst({ + where: { + workspaceId, + applicationId: deployment.applicationId, + environmentId: deployment.environmentId, + deployedAt: { lt: deployment.deployedAt }, + id: { not: deployment.id }, + archivedAt: null, + }, + orderBy: { deployedAt: "desc" }, + }); + + if (!previousDeployment) return null; + + logger.info("detectHotfix: Hotfix detected", { + deploymentId: deployment.id, + causeDeploymentId: previousDeployment.id, + }); + + return { + causeDeploymentId: previousDeployment.id, + fixDeploymentId: deployment.id, + }; +}; diff --git a/apps/api/src/app/incidents/services/incident-detection.types.ts b/apps/api/src/app/incidents/services/incident-detection.types.ts new file mode 100644 index 00000000..017443f0 --- /dev/null +++ b/apps/api/src/app/incidents/services/incident-detection.types.ts @@ -0,0 +1,4 @@ +export interface HandleIncidentDetectionAutomationArgs { + workspaceId: number; + deploymentId: number; +} diff --git a/apps/api/src/app/incidents/workers/automation-incident-detection.worker.ts b/apps/api/src/app/incidents/workers/automation-incident-detection.worker.ts new file mode 100644 index 00000000..4d707804 --- /dev/null +++ b/apps/api/src/app/incidents/workers/automation-incident-detection.worker.ts @@ -0,0 +1,22 @@ +import { Job } from "bullmq"; +import { SweetQueue } from "../../../bull-mq/queues"; +import { createWorker } from "../../../bull-mq/workers"; +import { logger } from "../../../lib/logger"; +import { handleIncidentDetectionAutomation } from "../services/incident-detection.service"; + +interface AutomationIncidentDetectionJobData { + deploymentId: number; + workspaceId: number; +} + +export const automationIncidentDetectionWorker = createWorker( + SweetQueue.AUTOMATION_INCIDENT_DETECTION, + async (job: Job) => { + logger.info("[AUTOMATION_INCIDENT_DETECTION]", { data: job.data }); + + await handleIncidentDetectionAutomation({ + workspaceId: job.data.workspaceId, + deploymentId: job.data.deploymentId, + }); + } +); diff --git a/apps/api/src/bull-mq/queues.ts b/apps/api/src/bull-mq/queues.ts index 4a6053f9..9a3a3fb8 100644 --- a/apps/api/src/bull-mq/queues.ts +++ b/apps/api/src/bull-mq/queues.ts @@ -37,6 +37,7 @@ export enum SweetQueue { // Automations AUTOMATION_PR_TITLE_CHECK = "{automation.pr_title_check}", AUTOMATION_PR_SIZE_LABELER = "{automation.pr_size_labeler}", + AUTOMATION_INCIDENT_DETECTION = "{automation.incident_detection}", // Alerts ALERT_MERGED_WITHOUT_APPROVAL = "{alert.merged_without_approval}", From 17460ad400d2e294af1680d4aa2e04535d205c56 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Mon, 23 Feb 2026 01:44:37 -0300 Subject: [PATCH 2/8] feat: add db migration --- .../migration.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 apps/api/prisma/migrations/20260223044415_alter_table_pull_requests_add_columns/migration.sql diff --git a/apps/api/prisma/migrations/20260223044415_alter_table_pull_requests_add_columns/migration.sql b/apps/api/prisma/migrations/20260223044415_alter_table_pull_requests_add_columns/migration.sql new file mode 100644 index 00000000..f7d8e6bf --- /dev/null +++ b/apps/api/prisma/migrations/20260223044415_alter_table_pull_requests_add_columns/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "PullRequest" ADD COLUMN "body" TEXT NOT NULL DEFAULT '', +ADD COLUMN "labels" JSONB NOT NULL DEFAULT '[]', +ADD COLUMN "sourceBranch" TEXT NOT NULL DEFAULT ''; From ab36ae3fcde878447efc44244041a5fbabb3589b Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Thu, 26 Feb 2026 02:11:53 -0300 Subject: [PATCH 3/8] fix: pr feedback + add integration tests --- apps/api/package.json | 2 +- .../services/deployment-pr-linking.service.ts | 5 + .../services/github-pull-request.service.ts | 2 +- .../incident-detection.integration.test.ts | 1285 +++++++++++++++++ .../services/incident-detection.service.ts | 53 +- apps/api/src/lib/string.ts | 10 + apps/api/test/seed/index.ts | 37 +- 7 files changed, 1371 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/app/incidents/services/incident-detection.integration.test.ts create mode 100644 apps/api/src/lib/string.ts diff --git a/apps/api/package.json b/apps/api/package.json index 4141532c..c5360b17 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,7 +20,7 @@ "prisma:migrate:new": "prisma migrate dev --create-only --name", "prisma:migrate:production": "prisma migrate deploy", "test:unit": "vitest run --project unit", - "test:integration": "cross-env NODE_ENV=test DATABASE_URL=postgresql://app_user:app_user@localhost:5433/sweetr_test SUPERUSER_DATABASE_URL=postgresql://postgres:postgres@localhost:5433/sweetr_test vitest run --project integration" + "test:integration": "cross-env NODE_ENV=production DATABASE_URL=postgresql://app_user:app_user@localhost:5433/sweetr_test SUPERUSER_DATABASE_URL=postgresql://postgres:postgres@localhost:5433/sweetr_test vitest run --project integration" }, "prisma": { "seed": "tsx prisma/seed/run-seeder.ts" diff --git a/apps/api/src/app/deployment/services/deployment-pr-linking.service.ts b/apps/api/src/app/deployment/services/deployment-pr-linking.service.ts index 27ef2eb5..af4c7514 100644 --- a/apps/api/src/app/deployment/services/deployment-pr-linking.service.ts +++ b/apps/api/src/app/deployment/services/deployment-pr-linking.service.ts @@ -141,6 +141,11 @@ export const handleDeploymentPullRequestAutoLinking = async ({ } ); + await addJob(SweetQueue.AUTOMATION_INCIDENT_DETECTION, { + deploymentId, + workspaceId, + }); + return; } diff --git a/apps/api/src/app/github/services/github-pull-request.service.ts b/apps/api/src/app/github/services/github-pull-request.service.ts index e6731d51..7520edca 100644 --- a/apps/api/src/app/github/services/github-pull-request.service.ts +++ b/apps/api/src/app/github/services/github-pull-request.service.ts @@ -127,7 +127,7 @@ const fetchPullRequest = async ( headRefName body - labels(first: 100) { + labels(first: ${GITHUB_MAX_PAGE_LIMIT}) { nodes { name } diff --git a/apps/api/src/app/incidents/services/incident-detection.integration.test.ts b/apps/api/src/app/incidents/services/incident-detection.integration.test.ts new file mode 100644 index 00000000..e8232e2d --- /dev/null +++ b/apps/api/src/app/incidents/services/incident-detection.integration.test.ts @@ -0,0 +1,1285 @@ +import { AutomationType, PullRequestState } from "@prisma/client"; +import { describe, expect, it } from "vitest"; +import { createTestContextWithGitProfile } from "../../../../test/integration-setup/context"; +import { + seedApplication, + seedAutomation, + seedDeployment, + seedDeploymentPullRequest, + seedEnvironment, + seedGitProfile, + seedPullRequest, + seedRepository, +} from "../../../../test/seed"; +import { getPrisma } from "../../../prisma"; +import { handleIncidentDetectionAutomation } from "./incident-detection.service"; + +async function setupBaseContext() { + const ctx = await createTestContextWithGitProfile(); + const gitProfile = await seedGitProfile(ctx); + const repository = await seedRepository(ctx); + const application = await seedApplication(ctx, repository.repositoryId); + const environment = await seedEnvironment(ctx); + + return { ctx, gitProfile, repository, application, environment }; +} + +describe("Incident Detection", () => { + describe("Hotfix", () => { + it("detects hotfix when PR title matches regex", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + hotfix: { enabled: true, prTitleRegEx: "hotfix" }, + }, + }); + + const previousDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T10:00:00Z"), version: "1.0.0" } + ); + + const hotfixPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "hotfix: fix critical bug", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T12:00:00Z"), + } + ); + + const hotfixDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + hotfixDeployment.deploymentId, + hotfixPr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: hotfixDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + expect(incidents[0].causeDeploymentId).toBe( + previousDeployment.deploymentId + ); + expect(incidents[0].fixDeploymentId).toBe( + hotfixDeployment.deploymentId + ); + }); + + it("detects hotfix when branch name matches regex", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + hotfix: { enabled: true, branchRegEx: "^hotfix/" }, + }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Fix login crash", + sourceBranch: "hotfix/login-crash", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T12:00:00Z"), + } + ); + + const hotfixDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + hotfixDeployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: hotfixDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + }); + + it("detects hotfix when PR label matches regex", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + hotfix: { enabled: true, prLabelRegEx: "^hotfix$" }, + }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Fix payment processing", + labels: ["hotfix", "urgent"], + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T12:00:00Z"), + } + ); + + const hotfixDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + hotfixDeployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: hotfixDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + }); + + it("does not detect hotfix when no PR matches any pattern", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + hotfix: { + enabled: true, + prTitleRegEx: "hotfix", + branchRegEx: "^hotfix/", + }, + }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Add new feature", + sourceBranch: "feature/new-feature", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T12:00:00Z"), + } + ); + + const deployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.1.0" } + ); + + await seedDeploymentPullRequest( + ctx, + deployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: deployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not detect hotfix when there is no previous deployment", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + hotfix: { enabled: true, prTitleRegEx: "hotfix" }, + }, + }); + + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "hotfix: initial deploy fix", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T12:00:00Z"), + } + ); + + const deployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.0.0" } + ); + + await seedDeploymentPullRequest( + ctx, + deployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: deployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not detect hotfix when hotfix detection is disabled", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + hotfix: { enabled: false, prTitleRegEx: "hotfix" }, + }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "hotfix: fix critical bug", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T12:00:00Z"), + } + ); + + const deployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + deployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: deployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("uses case-insensitive matching for hotfix patterns", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + hotfix: { enabled: true, prTitleRegEx: "hotfix" }, + }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "HOTFIX: Fix critical bug", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T12:00:00Z"), + } + ); + + const deployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + deployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: deployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + }); + + it("identifies the correct previous deployment as cause across multiple deployments", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + hotfix: { enabled: true, prTitleRegEx: "hotfix" }, + }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-08T10:00:00Z"), + version: "0.9.0", + }); + + const immediatelyPreviousDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T10:00:00Z"), version: "1.0.0" } + ); + + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "hotfix: urgent fix", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T12:00:00Z"), + } + ); + + const hotfixDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + hotfixDeployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: hotfixDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + expect(incidents[0].causeDeploymentId).toBe( + immediatelyPreviousDeployment.deploymentId + ); + }); + }); + + describe("Rollback", () => { + it("detects rollback when the same version was deployed before", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: true } }, + }); + + // v1.0.0 deployed first + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + // v1.1.0 deployed (this is the bad deploy) + const badDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T12:00:00Z"), version: "1.1.0" } + ); + + // v1.0.0 deployed again (rollback) + const rollbackDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollbackDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + expect(incidents[0].causeDeploymentId).toBe(badDeployment.deploymentId); + expect(incidents[0].fixDeploymentId).toBe( + rollbackDeployment.deploymentId + ); + }); + + it("identifies the first deployment after the rolled-back-to version as the cause", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: true } }, + }); + + // v1.0.0 + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + // v1.1.0 — the actual cause (first after rolled-back-to) + const causeDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T12:00:00Z"), version: "1.1.0" } + ); + + // v1.2.0 — also bad, but not the cause + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T13:00:00Z"), + version: "1.2.0", + }); + + // v1.0.0 again — rollback + const rollbackDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollbackDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + expect(incidents[0].causeDeploymentId).toBe( + causeDeployment.deploymentId + ); + }); + + it("does not detect rollback when the version was never deployed before", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: true } }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + const newDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T12:00:00Z"), version: "1.1.0" } + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: newDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not detect rollback when rollback detection is disabled", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: false } }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T12:00:00Z"), + version: "1.1.0", + }); + + const rollbackDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollbackDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("ignores archived deployments when detecting rollback", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: true } }, + }); + + // v1.0.0 deployed but then archived + const archivedDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T10:00:00Z"), version: "1.0.0" } + ); + + await getPrisma(ctx.workspaceId).deployment.update({ + where: { id: archivedDeployment.deploymentId }, + data: { archivedAt: new Date() }, + }); + + // v1.0.0 deployed again — not a rollback because original is archived + const deployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: deployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not detect rollback across different environments", async () => { + const { ctx, application } = await setupBaseContext(); + const staging = await seedEnvironment(ctx, { name: "staging" }); + const production = await seedEnvironment(ctx, { name: "production" }); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: true } }, + }); + + // v1.0.0 in staging + await seedDeployment(ctx, application.applicationId, staging.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + // v1.0.0 in production — not a rollback, different environment + const prodDeployment = await seedDeployment( + ctx, + application.applicationId, + production.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: prodDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + }); + + describe("Revert", () => { + it("detects revert when PR title matches revert pattern", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { revert: { enabled: true } }, + }); + + // Original PR deployed + const originalPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Add new payment flow", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T10:00:00Z"), + } + ); + + const causeDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T11:00:00Z"), version: "1.0.0" } + ); + + await seedDeploymentPullRequest( + ctx, + causeDeployment.deploymentId, + originalPr.pullRequestId + ); + + // Revert PR deployed + const revertPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: 'Revert "Add new payment flow"', + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T14:00:00Z"), + } + ); + + const revertDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T15:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + revertDeployment.deploymentId, + revertPr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: revertDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + expect(incidents[0].causeDeploymentId).toBe( + causeDeployment.deploymentId + ); + expect(incidents[0].fixDeploymentId).toBe( + revertDeployment.deploymentId + ); + }); + + it("does not detect revert when original PR was never deployed to the same app/env", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + const otherEnvironment = await seedEnvironment(ctx, { + name: "staging", + }); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { revert: { enabled: true } }, + }); + + // Original PR deployed to a different environment + const originalPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Add new payment flow", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T10:00:00Z"), + } + ); + + const otherEnvDeployment = await seedDeployment( + ctx, + application.applicationId, + otherEnvironment.environmentId, + { deployedAt: new Date("2024-01-10T11:00:00Z"), version: "1.0.0" } + ); + + await seedDeploymentPullRequest( + ctx, + otherEnvDeployment.deploymentId, + originalPr.pullRequestId + ); + + // Revert PR deployed to production + const revertPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: 'Revert "Add new payment flow"', + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T14:00:00Z"), + } + ); + + const revertDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T15:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + revertDeployment.deploymentId, + revertPr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: revertDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not detect revert when original PR is not found", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { revert: { enabled: true } }, + }); + + const revertPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: 'Revert "Non-existent PR"', + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T14:00:00Z"), + } + ); + + const deployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T15:00:00Z"), version: "1.0.0" } + ); + + await seedDeploymentPullRequest( + ctx, + deployment.deploymentId, + revertPr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: deployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not detect revert when revert detection is disabled", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { revert: { enabled: false } }, + }); + + const originalPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Add new payment flow", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T10:00:00Z"), + } + ); + + const causeDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T11:00:00Z"), version: "1.0.0" } + ); + + await seedDeploymentPullRequest( + ctx, + causeDeployment.deploymentId, + originalPr.pullRequestId + ); + + const revertPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: 'Revert "Add new payment flow"', + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T14:00:00Z"), + } + ); + + const revertDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T15:00:00Z"), version: "1.0.1" } + ); + + await seedDeploymentPullRequest( + ctx, + revertDeployment.deploymentId, + revertPr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: revertDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not detect revert when PR title does not match revert pattern", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { revert: { enabled: true } }, + }); + + // PR with "revert" in title but not matching the exact pattern + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Revert changes from last sprint", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T14:00:00Z"), + } + ); + + const deployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T15:00:00Z"), version: "1.0.0" } + ); + + await seedDeploymentPullRequest( + ctx, + deployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: deployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("links revert to the most recent deployment of the original PR in the same app/env", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { revert: { enabled: true } }, + }); + + const originalPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Add new payment flow", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T10:00:00Z"), + } + ); + + // First deployment with the original PR + const firstDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T11:00:00Z"), version: "1.0.0" } + ); + + await seedDeploymentPullRequest( + ctx, + firstDeployment.deploymentId, + originalPr.pullRequestId + ); + + // Second deployment also including the original PR (e.g. cherry-pick) + const secondDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.1.0" } + ); + + await seedDeploymentPullRequest( + ctx, + secondDeployment.deploymentId, + originalPr.pullRequestId + ); + + // Revert PR + const revertPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: 'Revert "Add new payment flow"', + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T16:00:00Z"), + } + ); + + const revertDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T17:00:00Z"), version: "1.2.0" } + ); + + await seedDeploymentPullRequest( + ctx, + revertDeployment.deploymentId, + revertPr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: revertDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + expect(incidents[0].causeDeploymentId).toBe( + secondDeployment.deploymentId + ); + }); + }); + + describe("General", () => { + it("does not create an incident when automation is disabled", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: false, + settings: { rollback: { enabled: true } }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T12:00:00Z"), + version: "1.1.0", + }); + + const rollback = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollback.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not create a duplicate incident for the same cause and fix", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: true } }, + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T12:00:00Z"), + version: "1.1.0", + }); + + const rollback = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + // Run twice + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollback.deploymentId, + }); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollback.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(1); + }); + + it("rollback takes priority over revert and hotfix", async () => { + const { ctx, gitProfile, repository, application, environment } = + await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { + rollback: { enabled: true }, + revert: { enabled: true }, + hotfix: { enabled: true, prTitleRegEx: "hotfix" }, + }, + }); + + // v1.0.0 deployed + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + // v1.1.0 deployed (bad) + const badDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T12:00:00Z"), version: "1.1.0" } + ); + + // v1.0.0 deployed again (rollback) with a hotfix-titled PR + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "hotfix: rollback to stable", + state: PullRequestState.MERGED, + mergedAt: new Date("2024-01-10T13:30:00Z"), + } + ); + + const rollbackDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + await seedDeploymentPullRequest( + ctx, + rollbackDeployment.deploymentId, + pr.pullRequestId + ); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollbackDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + // Rollback is detected (cause is badDeployment, not previous from hotfix logic) + expect(incidents).toHaveLength(1); + expect(incidents[0].causeDeploymentId).toBe(badDeployment.deploymentId); + expect(incidents[0].fixDeploymentId).toBe( + rollbackDeployment.deploymentId + ); + }); + }); +}); diff --git a/apps/api/src/app/incidents/services/incident-detection.service.ts b/apps/api/src/app/incidents/services/incident-detection.service.ts index cee88798..08b3937a 100644 --- a/apps/api/src/app/incidents/services/incident-detection.service.ts +++ b/apps/api/src/app/incidents/services/incident-detection.service.ts @@ -8,6 +8,7 @@ import { findAutomationByType } from "../../automations/services/automation.serv import { isActiveCustomer } from "../../authorization.service"; import { IncidentDetectionSettings } from "../../automations/services/automation.types"; import { HandleIncidentDetectionAutomationArgs } from "./incident-detection.types"; +import { safeRegex } from "../../../lib/string"; interface DetectionResult { causeDeploymentId: number; @@ -61,14 +62,18 @@ export const handleIncidentDetectionAutomation = async ({ if (!result) return; const existingIncident = await getPrisma(workspaceId).incident.findFirst({ - where: { causeDeploymentId: result.causeDeploymentId, workspaceId }, + where: { + causeDeploymentId: result.causeDeploymentId, + fixDeploymentId: result.fixDeploymentId, + workspaceId, + }, }); if (existingIncident) { - logger.info( - "handleDeploymentIncidentDetection: Incident already exists for cause deployment", - { causeDeploymentId: result.causeDeploymentId, existingIncident } - ); + logger.info("handleDeploymentIncidentDetection: Incident already exists", { + causeDeploymentId: result.causeDeploymentId, + existingIncident, + }); return; } @@ -167,15 +172,26 @@ const detectRevert = async ( }, orderBy: { mergedAt: "desc" }, include: { - deploymentEvents: true, + deploymentEvents: { + include: { deployment: true }, + }, }, }); if (!originalPr) continue; - const deploymentLink = originalPr.deploymentEvents.find( - (de) => de.deploymentId !== deployment.id - ); + const deploymentLink = originalPr.deploymentEvents + .filter( + (de) => + de.deployment.applicationId === deployment.applicationId && + de.deployment.environmentId === deployment.environmentId && + de.deploymentId !== deployment.id + ) + .sort( + (a, b) => + b.deployment.deployedAt.getTime() - a.deployment.deployedAt.getTime() + ) + .at(0); if (!deploymentLink) continue; @@ -209,20 +225,17 @@ const detectHotfix = async ( const { prTitleRegEx, branchRegEx, prLabelRegEx } = settings.hotfix; - const isHotfix = pullRequests.some((pr) => { - if (prTitleRegEx && new RegExp(prTitleRegEx, "i").test(pr.title)) { - return true; - } + const compiledTitleRegex = prTitleRegEx ? safeRegex(prTitleRegEx) : null; + const compiledBranchRegex = branchRegEx ? safeRegex(branchRegEx) : null; + const compiledLabelRegex = prLabelRegEx ? safeRegex(prLabelRegEx) : null; - if (branchRegEx && new RegExp(branchRegEx, "i").test(pr.sourceBranch)) { - return true; - } + const isHotfix = pullRequests.some((pr) => { + if (compiledTitleRegex?.test(pr.title)) return true; + if (compiledBranchRegex?.test(pr.sourceBranch)) return true; - if (prLabelRegEx) { + if (compiledLabelRegex) { const labels = (pr.labels as string[]) ?? []; - if (labels.some((label) => new RegExp(prLabelRegEx, "i").test(label))) { - return true; - } + if (labels.some((label) => compiledLabelRegex.test(label))) return true; } return false; diff --git a/apps/api/src/lib/string.ts b/apps/api/src/lib/string.ts new file mode 100644 index 00000000..52e65f7b --- /dev/null +++ b/apps/api/src/lib/string.ts @@ -0,0 +1,10 @@ +import { logger } from "./logger"; + +export const safeRegex = (pattern: string): RegExp | null => { + try { + return new RegExp(pattern, "i"); + } catch { + logger.warn("safeRegex: Invalid regex pattern, skipping", { pattern }); + return null; + } +}; diff --git a/apps/api/test/seed/index.ts b/apps/api/test/seed/index.ts index f08751af..010fd17d 100644 --- a/apps/api/test/seed/index.ts +++ b/apps/api/test/seed/index.ts @@ -1,4 +1,10 @@ -import { GitProvider, PullRequestState, TeamMemberRole } from "@prisma/client"; +import { + AutomationType, + GitProvider, + Prisma, + PullRequestState, + TeamMemberRole, +} from "@prisma/client"; import { randomUUID } from "crypto"; import { getPrisma } from "../../src/prisma"; @@ -212,6 +218,9 @@ export async function seedPullRequest( state?: PullRequestState; mergedAt?: Date; createdAt?: Date; + sourceBranch?: string; + targetBranch?: string; + labels?: string[]; } = {} ): Promise<{ pullRequestId: number }> { const pr = await getPrisma(ctx.workspaceId).pullRequest.create({ @@ -221,6 +230,9 @@ export async function seedPullRequest( gitUrl: `https://github.com/test/repo/pull/${input.number ?? "1"}`, title: input.title ?? "Test PR", number: input.number ?? "1", + sourceBranch: input.sourceBranch ?? "", + targetBranch: input.targetBranch ?? "main", + labels: input.labels ?? [], files: [], commentCount: 0, changedFilesCount: 0, @@ -309,3 +321,26 @@ export async function seedIncident( return { incidentId: incident.id }; } + +/** + * Creates an Automation for a workspace. + */ +export async function seedAutomation( + ctx: SeedWorkspace, + input: { + type: AutomationType; + enabled?: boolean; + settings?: object; + } +): Promise<{ automationId: number }> { + const automation = await getPrisma(ctx.workspaceId).automation.create({ + data: { + type: input.type, + enabled: input.enabled ?? true, + settings: (input.settings ?? {}) as Prisma.InputJsonValue, + workspaceId: ctx.workspaceId, + }, + }); + + return { automationId: automation.id }; +} From 0bb7b72e31867803be128cf9692320b0529bcab8 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Thu, 26 Feb 2026 02:11:59 -0300 Subject: [PATCH 4/8] feat: lazy load queues --- apps/api/src/bull-mq/bull-board.router.ts | 4 ++-- apps/api/src/bull-mq/init-bull-mq.ts | 3 +++ apps/api/src/bull-mq/queues.ts | 29 ++++++++++++++++------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/api/src/bull-mq/bull-board.router.ts b/apps/api/src/bull-mq/bull-board.router.ts index 46cde614..478375ea 100644 --- a/apps/api/src/bull-mq/bull-board.router.ts +++ b/apps/api/src/bull-mq/bull-board.router.ts @@ -2,7 +2,7 @@ import { FastifyPluginAsync } from "fastify"; import { FastifyAdapter } from "@bull-board/fastify"; import { createBullBoard } from "@bull-board/api"; import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; -import { queues } from "./queues"; +import { initQueues } from "./queues"; import auth from "basic-auth"; import { env } from "../env"; import rateLimit from "@fastify/rate-limit"; @@ -15,7 +15,7 @@ export const bullBoardRouter: FastifyPluginAsync = async (fastify) => { serverAdapter.setBasePath(env.BULLBOARD_PATH); createBullBoard({ - queues: Object.values(queues).map((queue) => new BullMQAdapter(queue)), + queues: Object.values(initQueues()).map((queue) => new BullMQAdapter(queue)), serverAdapter: serverAdapter, }); diff --git a/apps/api/src/bull-mq/init-bull-mq.ts b/apps/api/src/bull-mq/init-bull-mq.ts index edcbf914..d6b5813e 100644 --- a/apps/api/src/bull-mq/init-bull-mq.ts +++ b/apps/api/src/bull-mq/init-bull-mq.ts @@ -1,10 +1,13 @@ import { loadFilesSync } from "@graphql-tools/load-files"; import { join } from "path"; import { logger } from "../lib/logger"; +import { initQueues } from "./queues"; import { scheduleCronJobs } from "./schedule-cron-jobs"; // Automatically loads all workers export const initBullMQ = async () => { + initQueues(); + const workers = loadFilesSync(join(__dirname, "../**/*.(worker).(js|ts)")); await scheduleCronJobs(); diff --git a/apps/api/src/bull-mq/queues.ts b/apps/api/src/bull-mq/queues.ts index 9a3a3fb8..6cc510d2 100644 --- a/apps/api/src/bull-mq/queues.ts +++ b/apps/api/src/bull-mq/queues.ts @@ -62,9 +62,12 @@ export enum JobPriority { HIGH = 1, } -// Initialize Queues -export const queues: Record = (() => { - const queues = {}; +let queuesRecord: Record | null = null; + +export function initQueues(): Record { + if (queuesRecord) return queuesRecord; + + const record = {} as Record; for (const queueName of Object.values(SweetQueue)) { const queue = new Queue(queueName, { @@ -80,13 +83,21 @@ export const queues: Record = (() => { queue.on("error", bullMQErrorHandler); - queues[queueName] = queue; + record[queueName] = queue; logger.info(`🐂🧵 BullMQ: Queue ${queueName} initialized.`); } - return queues as Record; -})(); + queuesRecord = record as Record; + return queuesRecord; +} + +export { queuesRecord as queues }; + +function getQueue(queueName: SweetQueue): Queue { + const record = initQueues(); + return record[queueName]; +} export const addJob = async ( queueName: SweetQueue, @@ -95,7 +106,7 @@ export const addJob = async ( ) => { logger.info(`🐂✉️ BullMQ: Adding job to ${queueName}`); - const queue = queues[queueName]; + const queue = getQueue(queueName); return queue.add(`${queue.name}-job`, data, options); }; @@ -108,7 +119,7 @@ export const addDelayedJob = async ( ) => { logger.info(`🐂📅 BullMQ: Adding delayed job to ${queueName}`); - const queue = queues[queueName]; + const queue = getQueue(queueName); return queue.add(`${queue.name}-job`, data, { delay: date.getTime() - Date.now(), @@ -123,7 +134,7 @@ export const addJobs = async ( ) => { logger.info(`🐂✉️ BullMQ: Adding ${data.length} job to ${queueName}`); - const queue = queues[queueName]; + const queue = getQueue(queueName); return queue.addBulk( data.map((d) => ({ name: `${queue.name}-job`, data: d, opts: options })) From 36057e4470a5daca7273a60b1d406823a189fc6f Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Fri, 27 Feb 2026 02:10:45 -0300 Subject: [PATCH 5/8] fix: review feedback --- .github/workflows/test-integration.yml | 11 + apps/api/package.json | 3 +- apps/api/prisma/schema.prisma | 1 + .../incident-detection.integration.test.ts | 95 ++ .../services/incident-detection.service.ts | 2 +- apps/api/src/bull-mq/init-bull-mq.ts | 3 + apps/api/src/env.ts | 4 + apps/api/src/lib/string.ts | 12 +- package-lock.json | 983 ++++++++++-------- 9 files changed, 668 insertions(+), 446 deletions(-) diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index da38572a..d661f79a 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -66,6 +66,17 @@ jobs: - name: Run Integration Tests run: npm run test:integration working-directory: apps/api + env: DATABASE_URL: ${{ env.DATABASE_URL }} SUPERUSER_DATABASE_URL: ${{ env.SUPERUSER_DATABASE_URL }} + USE_SELF_SIGNED_SSL: false + JWT_SECRET: test-secret + REDIS_CONNECTION_STRING: redis://localhost:6379 + BULLMQ_ENABLED: false + GITHUB_CLIENT_SECRET: test + GITHUB_CLIENT_ID: test + GITHUB_APP_HANDLE: test + GITHUB_APP_ID: test + GITHUB_APP_PRIVATE_KEY: test + GITHUB_WEBHOOK_SECRET: test diff --git a/apps/api/package.json b/apps/api/package.json index c5360b17..eb7b40e8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,7 +20,7 @@ "prisma:migrate:new": "prisma migrate dev --create-only --name", "prisma:migrate:production": "prisma migrate deploy", "test:unit": "vitest run --project unit", - "test:integration": "cross-env NODE_ENV=production DATABASE_URL=postgresql://app_user:app_user@localhost:5433/sweetr_test SUPERUSER_DATABASE_URL=postgresql://postgres:postgres@localhost:5433/sweetr_test vitest run --project integration" + "test:integration": "cross-env LOG_LEVEL=warn NODE_ENV=production DATABASE_URL=postgresql://app_user:app_user@localhost:5433/sweetr_test SUPERUSER_DATABASE_URL=postgresql://postgres:postgres@localhost:5433/sweetr_test vitest run --project integration" }, "prisma": { "seed": "tsx prisma/seed/run-seeder.ts" @@ -65,6 +65,7 @@ "octokit": "^3.2.2", "pino": "^9.13.1", "radash": "^11.0.0", + "re2": "^1.23.3", "resend": "^6.0.0", "semver": "^7.5.4", "stripe": "^16.6.0", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e8502539..05da0384 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -769,6 +769,7 @@ model Incident { archivedAt DateTime? + @@unique([workspaceId, causeDeploymentId]) @@index([causeDeploymentId]) @@index([fixDeploymentId]) @@index([teamId]) diff --git a/apps/api/src/app/incidents/services/incident-detection.integration.test.ts b/apps/api/src/app/incidents/services/incident-detection.integration.test.ts index e8232e2d..d9e96d6d 100644 --- a/apps/api/src/app/incidents/services/incident-detection.integration.test.ts +++ b/apps/api/src/app/incidents/services/incident-detection.integration.test.ts @@ -684,6 +684,101 @@ describe("Incident Detection", () => { expect(incidents).toHaveLength(0); }); + it("does not pick a deployment after the rollback as the cause", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: true } }, + }); + + // T1: v1.0.0 deployed + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + // T2: v1.0.0 deployed again (rollback with no bad deploy in between) + const rollbackDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + // T3: v2.0.0 deployed later (already in DB, e.g. out-of-order ingestion) + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T16:00:00Z"), + version: "2.0.0", + }); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollbackDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + + it("does not blame a future deployment when the real cause was archived", async () => { + const { ctx, application, environment } = await setupBaseContext(); + + await seedAutomation(ctx, { + type: AutomationType.INCIDENT_DETECTION, + enabled: true, + settings: { rollback: { enabled: true } }, + }); + + // T1: v1.0.0 deployed (stable) + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + }); + + // T2: v1.1.0 deployed (bad deploy, later archived) + const archivedBadDeploy = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T12:00:00Z"), version: "1.1.0" } + ); + + await getPrisma(ctx.workspaceId).deployment.update({ + where: { id: archivedBadDeploy.deploymentId }, + data: { archivedAt: new Date() }, + }); + + // T3: v1.0.0 deployed again (rollback) + const rollbackDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date("2024-01-10T14:00:00Z"), version: "1.0.0" } + ); + + // T4: v2.0.0 deployed later (already in DB) + await seedDeployment(ctx, application.applicationId, environment.environmentId, { + deployedAt: new Date("2024-01-10T16:00:00Z"), + version: "2.0.0", + }); + + await handleIncidentDetectionAutomation({ + workspaceId: ctx.workspaceId, + deploymentId: rollbackDeployment.deploymentId, + }); + + const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ + where: { workspaceId: ctx.workspaceId }, + }); + + expect(incidents).toHaveLength(0); + }); + it("does not detect rollback across different environments", async () => { const { ctx, application } = await setupBaseContext(); const staging = await seedEnvironment(ctx, { name: "staging" }); diff --git a/apps/api/src/app/incidents/services/incident-detection.service.ts b/apps/api/src/app/incidents/services/incident-detection.service.ts index 08b3937a..f57d3981 100644 --- a/apps/api/src/app/incidents/services/incident-detection.service.ts +++ b/apps/api/src/app/incidents/services/incident-detection.service.ts @@ -126,7 +126,7 @@ const detectRollback = async ( workspaceId, applicationId: deployment.applicationId, environmentId: deployment.environmentId, - deployedAt: { gt: rolledBackTo.deployedAt }, + deployedAt: { gt: rolledBackTo.deployedAt, lt: deployment.deployedAt }, id: { not: deployment.id }, archivedAt: null, }, diff --git a/apps/api/src/bull-mq/init-bull-mq.ts b/apps/api/src/bull-mq/init-bull-mq.ts index d6b5813e..98218043 100644 --- a/apps/api/src/bull-mq/init-bull-mq.ts +++ b/apps/api/src/bull-mq/init-bull-mq.ts @@ -3,9 +3,12 @@ import { join } from "path"; import { logger } from "../lib/logger"; import { initQueues } from "./queues"; import { scheduleCronJobs } from "./schedule-cron-jobs"; +import { env } from "../env"; // Automatically loads all workers export const initBullMQ = async () => { + if (!env.BULLMQ_ENABLED) return; + initQueues(); const workers = loadFilesSync(join(__dirname, "../**/*.(worker).(js|ts)")); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index a181c310..0d13bed4 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -84,6 +84,10 @@ export const env = envsafe({ REDIS_CONNECTION_STRING: str({ desc: "The connection string to the Redis server. Used for BullMQ.", }), + BULLMQ_ENABLED: bool({ + desc: "Whether BullMQ is enabled", + default: true, + }), BULLBOARD_PATH: str({ desc: "The API path to open BullBoard", devDefault: "/bullboard", diff --git a/apps/api/src/lib/string.ts b/apps/api/src/lib/string.ts index 52e65f7b..86965fdb 100644 --- a/apps/api/src/lib/string.ts +++ b/apps/api/src/lib/string.ts @@ -1,10 +1,16 @@ +import RE2 from "re2"; import { logger } from "./logger"; -export const safeRegex = (pattern: string): RegExp | null => { +export const safeRegex = (pattern: string): RE2 | null => { try { - return new RegExp(pattern, "i"); + if (pattern.length > 255) { + logger.warn("safeRegex: Pattern is too long, skipping", { pattern }); + return null; + } + + return new RE2(pattern); } catch { - logger.warn("safeRegex: Invalid regex pattern, skipping", { pattern }); + logger.info("safeRegex: Invalid regex pattern, skipping", { pattern }); return null; } }; diff --git a/package-lock.json b/package-lock.json index df7cc86c..dc0ce683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "octokit": "^3.2.2", "pino": "^9.13.1", "radash": "^11.0.0", + "re2": "^1.23.3", "resend": "^6.0.0", "semver": "^7.5.4", "stripe": "^16.6.0", @@ -1651,6 +1652,18 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@gar/promise-retry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", + "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", + "license": "MIT", + "dependencies": { + "retry": "^0.13.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@graphql-codegen/add": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-6.0.0.tgz", @@ -3467,6 +3480,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3834,6 +3859,56 @@ "node": ">= 8" } }, + "node_modules/@npmcli/agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@octokit/app": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@octokit/app/-/app-14.1.0.tgz", @@ -5139,14 +5214,14 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -5160,14 +5235,14 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -5179,7 +5254,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -5528,44 +5603,6 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@repeaterjs/repeater": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", @@ -6480,20 +6517,6 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT", - "peer": true - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT", - "peer": true - }, "node_modules/@sweetr/email-templates": { "resolved": "packages/email-templates", "link": true @@ -6706,78 +6729,6 @@ "@types/node": "*" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT", - "peer": true - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -6901,14 +6852,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6961,13 +6912,6 @@ "@types/node": "*" } }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT", - "peer": true - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -7461,6 +7405,15 @@ "node": ">=18.0.0" } }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -8290,6 +8243,28 @@ "node": ">=8" } }, + "node_modules/cacache": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", + "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -8542,6 +8517,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -9021,138 +9005,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "peer": true, - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "peer": true, - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "peer": true, - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "peer": true, - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "peer": true, - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "peer": true, - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -9301,13 +9153,6 @@ } } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT", - "peer": true - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -9824,7 +9669,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10041,17 +9885,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", - "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", - "license": "MIT", - "peer": true, - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -10619,6 +10452,12 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -10938,7 +10777,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -11197,6 +11035,18 @@ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "license": "MIT" }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -11533,6 +11383,12 @@ "url": "https://github.com/sindresorhus/got?sponsor=1" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -12268,6 +12124,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -12340,17 +12218,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/immutable": { "version": "3.7.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", @@ -12415,7 +12282,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -12448,6 +12314,16 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/install-artifact-from-github": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/install-artifact-from-github/-/install-artifact-from-github-1.4.0.tgz", + "integrity": "sha512-+y6WywKZREw5rq7U2jvr2nmZpT7cbWbQQ0N/qfcseYnzHFz2cZz1Et52oY+XttYuYeTkI8Y+R2JNWj68MpQFSg==", + "license": "BSD-3-Clause", + "bin": { + "install-from-cache": "bin/install-from-cache.js", + "save-to-github-cache": "bin/save-to-github-cache.js" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -12463,16 +12339,6 @@ "node": ">= 0.4" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -12507,6 +12373,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -13863,6 +13738,37 @@ "node": ">=12" } }, + "node_modules/make-fetch-happen": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", + "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/map-cache": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", @@ -14033,6 +13939,119 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -14086,6 +14105,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT" + }, "node_modules/nanoclone": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", @@ -14232,6 +14257,30 @@ } } }, + "node_modules/node-gyp": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", + "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -14258,6 +14307,30 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -14271,6 +14344,21 @@ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -14712,6 +14800,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-queue": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", @@ -15003,7 +15103,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -15370,7 +15469,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -15395,6 +15494,15 @@ "node": ">=6" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -15578,6 +15686,18 @@ "node": ">= 0.10" } }, + "node_modules/re2": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/re2/-/re2-1.23.3.tgz", + "integrity": "sha512-5jh686rmj/8dYpBo72XYgwzgG8Y9HNDATYZ1x01gqZ6FvXVUP33VZ0+6GLCeavaNywz3OkXBU8iNX7LjiuisPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "install-artifact-from-github": "^1.4.0", + "nan": "^2.25.0", + "node-gyp": "^12.2.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -15771,13 +15891,6 @@ "node": ">=6" } }, - "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT", - "peer": true - }, "node_modules/react-number-format": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", @@ -15788,30 +15901,6 @@ "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -15984,37 +16073,6 @@ "node": ">= 12.13.0" } }, - "node_modules/recharts": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", - "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", - "license": "MIT", - "peer": true, - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "1.x.x || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -16045,23 +16103,6 @@ "node": ">=4" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peer": true, - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -16174,13 +16215,6 @@ "node": ">=8.6.0" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT", - "peer": true - }, "node_modules/resend": { "version": "6.9.2", "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", @@ -16831,6 +16865,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -16904,6 +16948,43 @@ "node": ">=10.0.0" } }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -16951,6 +17032,18 @@ "tslib": "^2.0.3" } }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -17506,6 +17599,31 @@ "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", "license": "MIT" }, + "node_modules/tar": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -17532,13 +17650,6 @@ "node": ">=16" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT", - "peer": true - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -17559,7 +17670,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -18503,6 +18613,30 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unique-filename": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", + "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", + "license": "ISC", + "dependencies": { + "unique-slug": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/unique-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", + "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/universal-github-app-jwt": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-1.2.0.tgz", @@ -18778,16 +18912,6 @@ } } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peer": true, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -18835,29 +18959,6 @@ "node": ">= 0.8" } }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "peer": true, - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", From 49d506c316b9782da27fcd956b6ea3459a38298b Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Fri, 27 Feb 2026 02:16:59 -0300 Subject: [PATCH 6/8] fix: env defaults --- .github/workflows/test-integration.yml | 1 + apps/api/src/env.ts | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index d661f79a..3834989d 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -73,6 +73,7 @@ jobs: USE_SELF_SIGNED_SSL: false JWT_SECRET: test-secret REDIS_CONNECTION_STRING: redis://localhost:6379 + FRONTEND_URL: http://localhost:5173 BULLMQ_ENABLED: false GITHUB_CLIENT_SECRET: test GITHUB_CLIENT_ID: test diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 0d13bed4..9a917a52 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -20,8 +20,11 @@ export const env = envsafe({ devDefault: "debug", choices: ["debug", "info", "warn", "error"], }), - PORT: port({ desc: "The port the app is running on" }), - FRONTEND_URL: url({ desc: "The URL to the frontend application" }), + PORT: port({ desc: "The port the app is running on", default: 3000 }), + FRONTEND_URL: url({ + desc: "The URL to the frontend application", + devDefault: "https://app.sweetr.local:5173", + }), CRON_GITHUB_RETRY_FAILED_WEBHOOKS_EVERY_MINUTES: num({ desc: "Run the cron for failed webhooks every X minutes", default: 30, @@ -41,31 +44,38 @@ export const env = envsafe({ STRIPE_API_KEY: str({ desc: "The secret API Key for Stripe access", default: "", + allowEmpty: true, }), STRIPE_WEBHOOK_SECRET: str({ desc: "The secret string used to sign Stripe webhooks", default: "", + allowEmpty: true, }), SLACK_CLIENT_ID: str({ desc: "The public Slack Client ID", default: "", + allowEmpty: true, }), SLACK_CLIENT_SECRET: str({ desc: "The secret API Key for Slack access", default: "", + allowEmpty: true, }), SLACK_WEBHOOK_SECRET: str({ desc: "The secret string used to sign Slack webhooks", default: "", + allowEmpty: true, }), SLACK_INSTALL_NOTIFICATION_WEBHOOK_URL: str({ desc: "The URL to the Slack install notification webhook", default: "", + allowEmpty: true, }), JWT_SECRET: str({ desc: "The secret string used to sign JWT tokens" }), SENTRY_DSN: str({ desc: "The DSN to connect to Sentry project", default: "", + allowEmpty: true, }), LOG_DRAIN: str({ desc: "The stream to log to", @@ -75,6 +85,7 @@ export const env = envsafe({ LOGTAIL_TOKEN: str({ desc: "The source token to forward logs to LogTail", default: "", + allowEmpty: true, }), USE_SELF_SIGNED_SSL: bool({ desc: "Whether the server should use self-signed certificates generated by devcert (see npm run ssl:generate)", @@ -92,20 +103,27 @@ export const env = envsafe({ desc: "The API path to open BullBoard", devDefault: "/bullboard", default: "", + allowEmpty: true, }), BULLBOARD_USERNAME: str({ desc: "The username to login to BullBoard.", default: "", + allowEmpty: true, }), BULLBOARD_PASSWORD: str({ desc: "The password to login to BullBoard.", default: "", + allowEmpty: true, }), EMAIL_ENABLED: bool({ desc: "Whether transactional emails are enabled", default: false, }), - RESEND_API_KEY: str({ desc: "The API Key for Resend.", default: "" }), + RESEND_API_KEY: str({ + desc: "The API Key for Resend.", + default: "", + allowEmpty: true, + }), APP_MODE: str({ desc: "Whether the application is being self-hosted", choices: ["self-hosted", "saas"], From 679c50d87925732055466529423cc228409b3a1b Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Fri, 27 Feb 2026 02:21:53 -0300 Subject: [PATCH 7/8] fix: review feedback --- .github/workflows/test-integration.yml | 13 +- apps/api/prisma/schema.prisma | 1 - .../incident-detection.integration.test.ts | 336 ++++++++++-------- 3 files changed, 202 insertions(+), 148 deletions(-) diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 3834989d..c8da9e49 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -30,6 +30,17 @@ jobs: --health-timeout 5s --health-retries 10 + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly + ports: + - 6379:6379 + options: >- + --ulimit memlock=-1 + --health-cmd "redis-cli ping | grep -q PONG" + --health-interval 2s + --health-timeout 5s + --health-retries 10 + env: SUPERUSER_DATABASE_URL: postgresql://postgres:postgres@localhost:5433/sweetr_test DATABASE_URL: postgresql://app_user:app_user@localhost:5433/sweetr_test @@ -72,9 +83,9 @@ jobs: SUPERUSER_DATABASE_URL: ${{ env.SUPERUSER_DATABASE_URL }} USE_SELF_SIGNED_SSL: false JWT_SECRET: test-secret + BULLMQ_ENABLED: false REDIS_CONNECTION_STRING: redis://localhost:6379 FRONTEND_URL: http://localhost:5173 - BULLMQ_ENABLED: false GITHUB_CLIENT_SECRET: test GITHUB_CLIENT_ID: test GITHUB_APP_HANDLE: test diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 05da0384..e8502539 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -769,7 +769,6 @@ model Incident { archivedAt DateTime? - @@unique([workspaceId, causeDeploymentId]) @@index([causeDeploymentId]) @@index([fixDeploymentId]) @@index([teamId]) diff --git a/apps/api/src/app/incidents/services/incident-detection.integration.test.ts b/apps/api/src/app/incidents/services/incident-detection.integration.test.ts index d9e96d6d..175938c5 100644 --- a/apps/api/src/app/incidents/services/incident-detection.integration.test.ts +++ b/apps/api/src/app/incidents/services/incident-detection.integration.test.ts @@ -82,9 +82,7 @@ describe("Incident Detection", () => { expect(incidents[0].causeDeploymentId).toBe( previousDeployment.deploymentId ); - expect(incidents[0].fixDeploymentId).toBe( - hotfixDeployment.deploymentId - ); + expect(incidents[0].fixDeploymentId).toBe(hotfixDeployment.deploymentId); }); it("detects hotfix when branch name matches regex", async () => { @@ -99,10 +97,15 @@ describe("Incident Detection", () => { }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); const pr = await seedPullRequest( ctx, @@ -153,10 +156,15 @@ describe("Incident Detection", () => { }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); const pr = await seedPullRequest( ctx, @@ -211,10 +219,15 @@ describe("Incident Detection", () => { }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); const pr = await seedPullRequest( ctx, @@ -313,10 +326,15 @@ describe("Incident Detection", () => { }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); const pr = await seedPullRequest( ctx, @@ -354,7 +372,7 @@ describe("Incident Detection", () => { expect(incidents).toHaveLength(0); }); - it("uses case-insensitive matching for hotfix patterns", async () => { + it("identifies the correct previous deployment as cause across multiple deployments", async () => { const { ctx, gitProfile, repository, application, environment } = await setupBaseContext(); @@ -366,64 +384,16 @@ describe("Incident Detection", () => { }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); - - const pr = await seedPullRequest( - ctx, - repository.repositoryId, - gitProfile.gitProfileId, - { - title: "HOTFIX: Fix critical bug", - state: PullRequestState.MERGED, - mergedAt: new Date("2024-01-10T12:00:00Z"), - } - ); - - const deployment = await seedDeployment( + await seedDeployment( ctx, application.applicationId, environment.environmentId, - { deployedAt: new Date("2024-01-10T13:00:00Z"), version: "1.0.1" } - ); - - await seedDeploymentPullRequest( - ctx, - deployment.deploymentId, - pr.pullRequestId + { + deployedAt: new Date("2024-01-08T10:00:00Z"), + version: "0.9.0", + } ); - await handleIncidentDetectionAutomation({ - workspaceId: ctx.workspaceId, - deploymentId: deployment.deploymentId, - }); - - const incidents = await getPrisma(ctx.workspaceId).incident.findMany({ - where: { workspaceId: ctx.workspaceId }, - }); - - expect(incidents).toHaveLength(1); - }); - - it("identifies the correct previous deployment as cause across multiple deployments", async () => { - const { ctx, gitProfile, repository, application, environment } = - await setupBaseContext(); - - await seedAutomation(ctx, { - type: AutomationType.INCIDENT_DETECTION, - enabled: true, - settings: { - hotfix: { enabled: true, prTitleRegEx: "hotfix" }, - }, - }); - - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-08T10:00:00Z"), - version: "0.9.0", - }); - const immediatelyPreviousDeployment = await seedDeployment( ctx, application.applicationId, @@ -482,10 +452,15 @@ describe("Incident Detection", () => { }); // v1.0.0 deployed first - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); // v1.1.0 deployed (this is the bad deploy) const badDeployment = await seedDeployment( @@ -529,10 +504,15 @@ describe("Incident Detection", () => { }); // v1.0.0 - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); // v1.1.0 — the actual cause (first after rolled-back-to) const causeDeployment = await seedDeployment( @@ -543,10 +523,15 @@ describe("Incident Detection", () => { ); // v1.2.0 — also bad, but not the cause - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T13:00:00Z"), - version: "1.2.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T13:00:00Z"), + version: "1.2.0", + } + ); // v1.0.0 again — rollback const rollbackDeployment = await seedDeployment( @@ -566,9 +551,7 @@ describe("Incident Detection", () => { }); expect(incidents).toHaveLength(1); - expect(incidents[0].causeDeploymentId).toBe( - causeDeployment.deploymentId - ); + expect(incidents[0].causeDeploymentId).toBe(causeDeployment.deploymentId); }); it("does not detect rollback when the version was never deployed before", async () => { @@ -580,10 +563,15 @@ describe("Incident Detection", () => { settings: { rollback: { enabled: true } }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); const newDeployment = await seedDeployment( ctx, @@ -613,15 +601,25 @@ describe("Incident Detection", () => { settings: { rollback: { enabled: false } }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T12:00:00Z"), - version: "1.1.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T12:00:00Z"), + version: "1.1.0", + } + ); const rollbackDeployment = await seedDeployment( ctx, @@ -694,10 +692,15 @@ describe("Incident Detection", () => { }); // T1: v1.0.0 deployed - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); // T2: v1.0.0 deployed again (rollback with no bad deploy in between) const rollbackDeployment = await seedDeployment( @@ -708,10 +711,15 @@ describe("Incident Detection", () => { ); // T3: v2.0.0 deployed later (already in DB, e.g. out-of-order ingestion) - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T16:00:00Z"), - version: "2.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T16:00:00Z"), + version: "2.0.0", + } + ); await handleIncidentDetectionAutomation({ workspaceId: ctx.workspaceId, @@ -735,10 +743,15 @@ describe("Incident Detection", () => { }); // T1: v1.0.0 deployed (stable) - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); // T2: v1.1.0 deployed (bad deploy, later archived) const archivedBadDeploy = await seedDeployment( @@ -762,10 +775,15 @@ describe("Incident Detection", () => { ); // T4: v2.0.0 deployed later (already in DB) - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T16:00:00Z"), - version: "2.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T16:00:00Z"), + version: "2.0.0", + } + ); await handleIncidentDetectionAutomation({ workspaceId: ctx.workspaceId, @@ -791,10 +809,15 @@ describe("Incident Detection", () => { }); // v1.0.0 in staging - await seedDeployment(ctx, application.applicationId, staging.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + staging.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); // v1.0.0 in production — not a rollback, different environment const prodDeployment = await seedDeployment( @@ -888,12 +911,8 @@ describe("Incident Detection", () => { }); expect(incidents).toHaveLength(1); - expect(incidents[0].causeDeploymentId).toBe( - causeDeployment.deploymentId - ); - expect(incidents[0].fixDeploymentId).toBe( - revertDeployment.deploymentId - ); + expect(incidents[0].causeDeploymentId).toBe(causeDeployment.deploymentId); + expect(incidents[0].fixDeploymentId).toBe(revertDeployment.deploymentId); }); it("does not detect revert when original PR was never deployed to the same app/env", async () => { @@ -1234,15 +1253,25 @@ describe("Incident Detection", () => { settings: { rollback: { enabled: true } }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T12:00:00Z"), - version: "1.1.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T12:00:00Z"), + version: "1.1.0", + } + ); const rollback = await seedDeployment( ctx, @@ -1272,15 +1301,25 @@ describe("Incident Detection", () => { settings: { rollback: { enabled: true } }, }); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T12:00:00Z"), - version: "1.1.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T12:00:00Z"), + version: "1.1.0", + } + ); const rollback = await seedDeployment( ctx, @@ -1322,10 +1361,15 @@ describe("Incident Detection", () => { }); // v1.0.0 deployed - await seedDeployment(ctx, application.applicationId, environment.environmentId, { - deployedAt: new Date("2024-01-10T10:00:00Z"), - version: "1.0.0", - }); + await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date("2024-01-10T10:00:00Z"), + version: "1.0.0", + } + ); // v1.1.0 deployed (bad) const badDeployment = await seedDeployment( From 8e506591826551cb070d56329da44364b7f38fd6 Mon Sep 17 00:00:00 2001 From: Walter Galvao Date: Fri, 27 Feb 2026 02:55:40 -0300 Subject: [PATCH 8/8] chore: add guard --- .../services/incident-detection.service.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/api/src/app/incidents/services/incident-detection.service.ts b/apps/api/src/app/incidents/services/incident-detection.service.ts index f57d3981..4574c6aa 100644 --- a/apps/api/src/app/incidents/services/incident-detection.service.ts +++ b/apps/api/src/app/incidents/services/incident-detection.service.ts @@ -9,6 +9,7 @@ import { isActiveCustomer } from "../../authorization.service"; import { IncidentDetectionSettings } from "../../automations/services/automation.types"; import { HandleIncidentDetectionAutomationArgs } from "./incident-detection.types"; import { safeRegex } from "../../../lib/string"; +import { DataIntegrityException } from "../../errors/exceptions/data-integrity.exception"; interface DetectionResult { causeDeploymentId: number; @@ -61,6 +62,18 @@ export const handleIncidentDetectionAutomation = async ({ if (!result) return; + if (result.causeDeploymentId === result.fixDeploymentId) { + throw new DataIntegrityException( + "handleDeploymentIncidentDetection: Cause and fix deployment IDs are the same", + { + extra: { + deploymentId, + result, + }, + } + ); + } + const existingIncident = await getPrisma(workspaceId).incident.findFirst({ where: { causeDeploymentId: result.causeDeploymentId,