-
Notifications
You must be signed in to change notification settings - Fork 6
feat: incident detection automation #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
fc4664c
17460ad
ab36ae3
0bb7b72
36057e4
49d506c
679c50d
8e50659
c9d7a08
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ''; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }, | ||
| }); | ||
|
waltergalvao marked this conversation as resolved.
waltergalvao marked this conversation as resolved.
|
||
|
|
||
| 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<DetectionResult | null> => { | ||
| 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" }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }); | ||
|
waltergalvao marked this conversation as resolved.
waltergalvao marked this conversation as resolved.
|
||
|
|
||
| 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<DetectionResult | null> => { | ||
| if (!settings.revert?.enabled) return null; | ||
|
|
||
| const revertPattern = /^Revert "(.+)"$/; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded revert pattern only matches GitHub's exact "Revert "..."" format. Won't detect reverts using conventional commit style ( |
||
|
|
||
| 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, | ||
| }, | ||
| }); | ||
|
waltergalvao marked this conversation as resolved.
|
||
|
|
||
| if (!originalPr) continue; | ||
|
|
||
| const deploymentLink = originalPr.deploymentEvents.find( | ||
| (de) => de.deploymentId !== deployment.id | ||
| ); | ||
|
waltergalvao marked this conversation as resolved.
Outdated
|
||
|
|
||
| if (!deploymentLink) continue; | ||
|
|
||
| logger.info("detectRevert: Revert detected", { | ||
| revertPrId: pr.id, | ||
| originalPrId: originalPr.id, | ||
| causeDeploymentId: deploymentLink.deploymentId, | ||
| }); | ||
|
|
||
| return { | ||
| causeDeploymentId: deploymentLink.deploymentId, | ||
| fixDeploymentId: deployment.id, | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| return null; | ||
| }; | ||
|
|
||
| const detectHotfix = async ( | ||
| settings: IncidentDetectionSettings, | ||
| pullRequests: PullRequest[], | ||
| deployment: { | ||
| id: number; | ||
| applicationId: number; | ||
| environmentId: number; | ||
| deployedAt: Date; | ||
| }, | ||
| workspaceId: number | ||
| ): Promise<DetectionResult | null> => { | ||
| 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)) { | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| return true; | ||
| } | ||
|
|
||
| if (branchRegEx && new RegExp(branchRegEx, "i").test(pr.sourceBranch)) { | ||
| return true; | ||
| } | ||
|
|
||
| if (prLabelRegEx) { | ||
| const labels = (pr.labels as string[]) ?? []; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Type cast to const labels = Array.isArray(pr.labels) ? pr.labels : []; |
||
| if (labels.some((label) => new RegExp(prLabelRegEx, "i").test(label))) { | ||
|
waltergalvao marked this conversation as resolved.
Outdated
|
||
| return true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| 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, | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export interface HandleIncidentDetectionAutomationArgs { | ||
| workspaceId: number; | ||
| deploymentId: number; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AutomationIncidentDetectionJobData>) => { | ||
| logger.info("[AUTOMATION_INCIDENT_DETECTION]", { data: job.data }); | ||
|
|
||
| await handleIncidentDetectionAutomation({ | ||
| workspaceId: job.data.workspaceId, | ||
| deploymentId: job.data.deploymentId, | ||
| }); | ||
| } | ||
| ); |
Uh oh!
There was an error while loading. Please reload this page.