Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 '';
Comment thread
waltergalvao marked this conversation as resolved.
3 changes: 3 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -78,6 +80,7 @@ describe("groupSerialReviews", () => {
authorId: John,
repositoryId: 1,
workspaceId: 1,
labels: [],
...pullRequest,
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/app/github/services/github-pull-request.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ const fetchPullRequest = async (
closedAt
mergedAt
baseRefName
headRefName
body

labels(first: 100) {
nodes {
name
}
}

createdAt
updatedAt
Expand Down Expand Up @@ -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,
Expand Down
256 changes: 256 additions & 0 deletions apps/api/src/app/incidents/services/incident-detection.service.ts
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,
},
});
Comment thread
waltergalvao marked this conversation as resolved.
Comment thread
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" },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Comment thread
waltergalvao marked this conversation as resolved.
Comment thread
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 "(.+)"$/;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 (revert:), different casing, or manual revert messages. Unlike hotfix detection (which has configurable regex), revert detection lacks configurability.


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,
},
});
Comment thread
waltergalvao marked this conversation as resolved.

if (!originalPr) continue;

const deploymentLink = originalPr.deploymentEvents.find(
(de) => de.deploymentId !== deployment.id
);
Comment thread
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,
};
Comment thread
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)) {
Comment thread
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[]) ?? [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type cast to string[] without runtime validation. If pr.labels contains malformed data (non-array), .some() on line 251 will throw. Consider Array.isArray() check:

const labels = Array.isArray(pr.labels) ? pr.labels : [];

if (labels.some((label) => new RegExp(prLabelRegEx, "i").test(label))) {
Comment thread
waltergalvao marked this conversation as resolved.
Outdated
return true;
}
}

return false;
Comment thread
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,
});
}
);
1 change: 1 addition & 0 deletions apps/api/src/bull-mq/queues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down