diff --git a/apps/api/src/app/metrics/resolvers/dora-metrics.schema.ts b/apps/api/src/app/metrics/resolvers/dora-metrics.schema.ts index aa1e516a..7e169512 100644 --- a/apps/api/src/app/metrics/resolvers/dora-metrics.schema.ts +++ b/apps/api/src/app/metrics/resolvers/dora-metrics.schema.ts @@ -36,6 +36,12 @@ export default /* GraphQL */ ` "The lead time in milliseconds for the current period" currentAmount: BigInt! + "The date range for the current period" + currentPeriod: DateTimeRangeValue! + + "The date range for the previous period" + previousPeriod: DateTimeRangeValue! + "The lead time in milliseconds before the current period" previousAmount: BigInt! @@ -47,12 +53,55 @@ export default /* GraphQL */ ` "The amounts over time for the chart" data: [BigInt!]! + + "Breakdown of lead time by development stages" + breakdown: LeadTimeBreakdown! + } + + type LeadTimeBreakdown { + "Time spent coding (first commit to PR creation)" + codingTime: BreakdownStage! + + "The date range for the current period" + currentPeriod: DateTimeRangeValue! + + "The date range for the previous period" + previousPeriod: DateTimeRangeValue! + + "Time waiting for first review" + timeToFirstReview: BreakdownStage! + + "Time from first review to approval" + timeToApprove: BreakdownStage! + + "Time from approval to merge" + timeToMerge: BreakdownStage! + + "Time from merge to deploy" + timeToDeploy: BreakdownStage! + } + + type BreakdownStage { + "Average time in milliseconds for current period" + currentAmount: BigInt! + + "Average time in milliseconds for previous period" + previousAmount: BigInt! + + "Percentage change from previous period" + change: Float! } type ChangeFailureRateMetric { "The change failure rate for the current period" currentAmount: Float! + "The date range for the current period" + currentPeriod: DateTimeRangeValue! + + "The date range for the previous period" + previousPeriod: DateTimeRangeValue! + "The change failure rate before the current period" previousAmount: Float! @@ -63,13 +112,19 @@ export default /* GraphQL */ ` columns: [DateTime!]! "The amounts over time for the chart" - data: [BigInt!]! + data: [Float!]! } type DeploymentFrequencyMetric { "The amount of deployments for the current period" currentAmount: BigInt! + "The date range for the current period" + currentPeriod: DateTimeRangeValue! + + "The date range for the previous period" + previousPeriod: DateTimeRangeValue! + "The average number of deployments per day" avg: Float! @@ -90,6 +145,12 @@ export default /* GraphQL */ ` "The mean time to recover in milliseconds for the current period" currentAmount: BigInt! + "The date range for the current period" + currentPeriod: DateTimeRangeValue! + + "The date range for the previous period" + previousPeriod: DateTimeRangeValue! + "The mean time to recover in milliseconds before the current period" previousAmount: BigInt! diff --git a/apps/api/src/app/metrics/resolvers/queries/dora-metrics.query.ts b/apps/api/src/app/metrics/resolvers/queries/dora-metrics.query.ts index 9f46387e..95983ae3 100644 --- a/apps/api/src/app/metrics/resolvers/queries/dora-metrics.query.ts +++ b/apps/api/src/app/metrics/resolvers/queries/dora-metrics.query.ts @@ -3,15 +3,15 @@ import { createFieldResolver } from "../../../../lib/graphql"; import { logger } from "../../../../lib/logger"; import { ResourceNotFoundException } from "../../../errors/exceptions/resource-not-found.exception"; import { - getLeadTimeMetric, getChangeFailureRateMetric, getDeploymentFrequencyMetric, + getLeadTimeMetric, getMeanTimeToRecoverMetric, } from "../../services/dora-metrics.service"; import { - transformLeadTimeMetric, transformChangeFailureRateMetric, transformDeploymentFrequencyMetric, + transformLeadTimeMetric, transformMeanTimeToRecoverMetric, } from "../transformers/dora-metrics.transformer"; @@ -26,7 +26,7 @@ export const doraMetricsQuery = createFieldResolver("DoraMetrics", { throw new ResourceNotFoundException("Workspace not found"); } - const result = await getLeadTimeMetric({ + const filters = { workspaceId: context.workspaceId, dateRange: { from: input.dateRange.from ?? thirtyDaysAgo().toISOString(), @@ -37,9 +37,14 @@ export const doraMetricsQuery = createFieldResolver("DoraMetrics", { applicationIds: input.applicationIds ?? undefined, environmentIds: input.environmentIds ?? undefined, repositoryIds: input.repositoryIds ?? undefined, - }); + }; + + const result = await getLeadTimeMetric(filters); - return transformLeadTimeMetric(result); + return { + ...transformLeadTimeMetric(result), + doraFilters: filters, + }; }, changeFailureRate: async (_, { input }, context) => { logger.info("query.metrics.dora.changeFailureRate", { diff --git a/apps/api/src/app/metrics/resolvers/queries/lead-time-breakdown.query.ts b/apps/api/src/app/metrics/resolvers/queries/lead-time-breakdown.query.ts new file mode 100644 index 00000000..778dd12a --- /dev/null +++ b/apps/api/src/app/metrics/resolvers/queries/lead-time-breakdown.query.ts @@ -0,0 +1,18 @@ +import { createFieldResolver } from "../../../../lib/graphql"; +import { logger } from "../../../../lib/logger"; +import { getLeadTimeBreakdown } from "../../services/lead-time-breakdown.service"; +import { GetLeadTimeBreakdownArgs } from "../../services/lead-time-breakdown.types"; +import { transformLeadTimeBreakdown } from "../transformers/lead-time-breakdown.transformer"; + +export const leadTimeBreakdownQuery = createFieldResolver("LeadTimeMetric", { + breakdown: async (parent, _, context) => { + logger.info("query.metrics.dora.leadTime.breakdown", { + workspaceId: context.workspaceId, + }); + + const args = parent["doraFilters"] as GetLeadTimeBreakdownArgs; + const result = await getLeadTimeBreakdown(args); + + return transformLeadTimeBreakdown(result); + }, +}); diff --git a/apps/api/src/app/metrics/resolvers/transformers/dora-metrics.transformer.ts b/apps/api/src/app/metrics/resolvers/transformers/dora-metrics.transformer.ts index 1b63c978..357db832 100644 --- a/apps/api/src/app/metrics/resolvers/transformers/dora-metrics.transformer.ts +++ b/apps/api/src/app/metrics/resolvers/transformers/dora-metrics.transformer.ts @@ -1,45 +1,77 @@ import { - LeadTimeMetric, ChangeFailureRateMetric, DeploymentFrequencyMetric, + LeadTimeMetric, MeanTimeToRecoverMetric, } from "../../../../graphql-types"; export const transformLeadTimeMetric = ( - metric: LeadTimeMetric -): LeadTimeMetric => { + metric: Omit +): Omit => { return { ...metric, change: parseFloat(metric.change.toFixed(2)), + currentPeriod: { + from: metric.currentPeriod.from, + to: metric.currentPeriod.to, + }, + previousPeriod: { + from: metric.previousPeriod.from, + to: metric.previousPeriod.to, + }, }; }; export const transformChangeFailureRateMetric = ( - metric: ChangeFailureRateMetric -): ChangeFailureRateMetric => { + metric: Omit +): Omit => { return { ...metric, currentAmount: parseFloat(metric.currentAmount.toFixed(2)), previousAmount: parseFloat(metric.previousAmount.toFixed(2)), - change: parseFloat(metric.change.toFixed(1)), + change: parseFloat(metric.change.toFixed(2)), + currentPeriod: { + from: metric.currentPeriod.from, + to: metric.currentPeriod.to, + }, + previousPeriod: { + from: metric.previousPeriod.from, + to: metric.previousPeriod.to, + }, }; }; export const transformDeploymentFrequencyMetric = ( - metric: DeploymentFrequencyMetric -): DeploymentFrequencyMetric => { + metric: Omit +): Omit => { return { ...metric, avg: parseFloat(metric.avg.toFixed(2)), change: parseFloat(metric.change.toFixed(2)), + currentPeriod: { + from: metric.currentPeriod.from, + to: metric.currentPeriod.to, + }, + previousPeriod: { + from: metric.previousPeriod.from, + to: metric.previousPeriod.to, + }, }; }; export const transformMeanTimeToRecoverMetric = ( - metric: MeanTimeToRecoverMetric -): MeanTimeToRecoverMetric => { + metric: Omit +): Omit => { return { ...metric, change: parseFloat(metric.change.toFixed(2)), + currentPeriod: { + from: metric.currentPeriod.from, + to: metric.currentPeriod.to, + }, + previousPeriod: { + from: metric.previousPeriod.from, + to: metric.previousPeriod.to, + }, }; }; diff --git a/apps/api/src/app/metrics/resolvers/transformers/lead-time-breakdown.transformer.ts b/apps/api/src/app/metrics/resolvers/transformers/lead-time-breakdown.transformer.ts new file mode 100644 index 00000000..b4265049 --- /dev/null +++ b/apps/api/src/app/metrics/resolvers/transformers/lead-time-breakdown.transformer.ts @@ -0,0 +1,33 @@ +import { BreakdownStage, LeadTimeBreakdown } from "../../../../graphql-types"; +import { + LeadTimeBreakdownResult, + StageResult, +} from "../../services/lead-time-breakdown.types"; + +const transformStage = (stage: StageResult): BreakdownStage => { + return { + currentAmount: stage.currentAmount, + previousAmount: stage.previousAmount, + change: parseFloat(stage.change.toFixed(2)), + }; +}; + +export const transformLeadTimeBreakdown = ( + breakdown: LeadTimeBreakdownResult +): LeadTimeBreakdown => { + return { + codingTime: transformStage(breakdown.codingTime), + timeToFirstReview: transformStage(breakdown.timeToFirstReview), + timeToApprove: transformStage(breakdown.timeToApprove), + timeToMerge: transformStage(breakdown.timeToMerge), + timeToDeploy: transformStage(breakdown.timeToDeploy), + currentPeriod: { + from: breakdown.currentPeriod.from, + to: breakdown.currentPeriod.to, + }, + previousPeriod: { + from: breakdown.previousPeriod.from, + to: breakdown.previousPeriod.to, + }, + }; +}; diff --git a/apps/api/src/app/metrics/services/dora-metrics.integration.test.ts b/apps/api/src/app/metrics/services/dora-metrics.integration.test.ts index 35f7626f..5a9e97cb 100644 --- a/apps/api/src/app/metrics/services/dora-metrics.integration.test.ts +++ b/apps/api/src/app/metrics/services/dora-metrics.integration.test.ts @@ -1032,8 +1032,8 @@ describe("DORA Metrics", () => { expect(result.currentAmount).toBeCloseTo(10, 1); // Previous: 20% (2/10) expect(result.previousAmount).toBeCloseTo(20, 1); - // Change: (10 - 20) / 20 * 100 = -50% (improvement) - expect(result.change).toBeCloseTo(-50, 1); + // Change: 10 - 20 = -10 percentage points (improvement) + expect(result.change).toBeCloseTo(-10, 1); }); it("handles 100% failure rate correctly", async () => { diff --git a/apps/api/src/app/metrics/services/dora-metrics.service.ts b/apps/api/src/app/metrics/services/dora-metrics.service.ts index 44d7d645..990540f3 100644 --- a/apps/api/src/app/metrics/services/dora-metrics.service.ts +++ b/apps/api/src/app/metrics/services/dora-metrics.service.ts @@ -1,177 +1,22 @@ import { Prisma } from "@prisma/client"; +import { getPreviousPeriod } from "../../../lib/date"; import { getPrisma } from "../../../prisma"; import { periodToDateTrunc, periodToInterval } from "./chart.service"; -import { DoraMetricsFilters } from "./dora-metrics.types"; - -interface MetricResult { - columns: string[]; - data: bigint[]; - currentAmount: bigint; - previousAmount: bigint; - change: number; -} - -interface FailureRateResult - extends Pick { - currentAmount: number; - previousAmount: number; -} - -interface DeploymentFrequencyResult extends MetricResult { - avg: number; -} - -/** - * Calculate the "before" period dates based on exact duration - */ -const calculateBeforePeriod = ( - from: string, - to: string -): { beforeFrom: string; beforeTo: string } => { - const fromDate = new Date(from); - const toDate = new Date(to); - const duration = toDate.getTime() - fromDate.getTime(); - const beforeTo = fromDate; - const beforeFrom = new Date(beforeTo.getTime() - duration); - - return { - beforeFrom: beforeFrom.toISOString(), - beforeTo: beforeTo.toISOString(), - }; -}; +import { + AggregateQueryArgs, + DeploymentFiltersResult, + DeploymentFrequencyResult, + DoraMetricsFilters, + FailureRateResult, + MetricResult, +} from "./dora-metrics.types"; -/** - * Build filter joins and conditions for DORA metrics queries using optimized JOINs - */ -const buildDeploymentFilters = ( - filters: DoraMetricsFilters, - alias: string = "d" -): { joins: Prisma.Sql[]; conditions: Prisma.Sql[] } => { - const joins: Prisma.Sql[] = []; - const conditions: Prisma.Sql[] = []; - - // Workspace filter - conditions.push( - Prisma.sql`${Prisma.raw(alias)}."workspaceId" = ${filters.workspaceId}` - ); - - // Environment filter - use JOIN for production filter - if (filters.environmentIds && filters.environmentIds.length > 0) { - conditions.push( - Prisma.sql`${Prisma.raw(alias)}."environmentId" = ANY(ARRAY[${Prisma.join( - filters.environmentIds.map((id) => Prisma.sql`${id}`), - ", " - )}])` - ); - } else { - // Join with Environment table to filter by isProduction - joins.push( - Prisma.sql`INNER JOIN "Environment" e ON e."id" = ${Prisma.raw(alias)}."environmentId" AND e."workspaceId" = ${Prisma.raw(alias)}."workspaceId"` - ); - conditions.push( - Prisma.sql`e."isProduction" = true AND e."archivedAt" IS NULL` - ); - } - - // Application filter - if (filters.applicationIds && filters.applicationIds.length > 0) { - conditions.push( - Prisma.sql`${Prisma.raw(alias)}."applicationId" = ANY(ARRAY[${Prisma.join( - filters.applicationIds.map((id) => Prisma.sql`${id}`), - ", " - )}])` - ); - } - - // Team or Repository filter - use single JOIN for Application - const needsApplicationJoin = - (filters.teamIds && filters.teamIds.length > 0) || - (filters.repositoryIds && filters.repositoryIds.length > 0); - - if (needsApplicationJoin) { - joins.push( - Prisma.sql`INNER JOIN "Application" a ON a."id" = ${Prisma.raw(alias)}."applicationId" AND a."workspaceId" = ${Prisma.raw(alias)}."workspaceId" AND a."archivedAt" IS NULL` - ); - - if (filters.teamIds && filters.teamIds.length > 0) { - conditions.push( - Prisma.sql`a."teamId" = ANY(ARRAY[${Prisma.join( - filters.teamIds.map((id) => Prisma.sql`${id}`), - ", " - )}])` - ); - } - - if (filters.repositoryIds && filters.repositoryIds.length > 0) { - conditions.push( - Prisma.sql`a."repositoryId" = ANY(ARRAY[${Prisma.join( - filters.repositoryIds.map((id) => Prisma.sql`${id}`), - ", " - )}])` - ); - } - } - - return { joins, conditions }; -}; - -/** - * Build lead time aggregate query with optimized JOINs - */ -const buildLeadTimeAggregateQuery = ({ - filters, - from, - to, - isPreviousPeriod = false, -}: { - filters: DoraMetricsFilters; - from: string; - to: string; - isPreviousPeriod?: boolean; -}): Prisma.Sql => { - const upperOperator = isPreviousPeriod ? Prisma.raw("<") : Prisma.raw("<="); - const { joins, conditions } = buildDeploymentFilters(filters, "d"); - - const allJoins = [ - ...joins, - Prisma.sql`INNER JOIN "DeploymentPullRequest" dpr ON d."id" = dpr."deploymentId"`, - Prisma.sql`INNER JOIN "PullRequest" pr ON dpr."pullRequestId" = pr."id"`, - Prisma.sql`LEFT JOIN "PullRequestTracking" prt ON pr."id" = prt."pullRequestId"`, - ]; - - conditions.push( - Prisma.sql`d."deployedAt" >= ${new Date(from)}`, - Prisma.sql`d."deployedAt" ${upperOperator} ${new Date(to)}`, - Prisma.sql`d."archivedAt" IS NULL` - ); - - const whereClause = Prisma.join(conditions, " AND "); - const joinClause = Prisma.join(allJoins, " "); - - return Prisma.sql` - SELECT AVG(deployment_lead_times.lead_time_ms) AS value - FROM ( - SELECT - d."id", - EXTRACT(EPOCH FROM (d."deployedAt" - MIN(COALESCE(prt."firstCommitAt", pr."createdAt")))) * 1000 AS lead_time_ms - FROM "Deployment" d - ${joinClause} - WHERE ${whereClause} - GROUP BY d."id", d."deployedAt" - ) AS deployment_lead_times; - `; -}; - -/** - * Calculate lead time metric - * Lead time = time from earliest PR first commit to deployment - */ export const getLeadTimeMetric = async ( filters: DoraMetricsFilters ): Promise => { const { dateRange, period } = filters; const { from, to } = dateRange; - const { beforeFrom, beforeTo } = calculateBeforePeriod(from, to); + const [beforeFrom, beforeTo] = getPreviousPeriod(from, to); const { joins, conditions } = buildDeploymentFilters(filters, "d"); const trunc = periodToDateTrunc(period); @@ -230,14 +75,12 @@ export const getLeadTimeMetric = async ( filters, from, to, - isPreviousPeriod: false, }); const previousAmountQuery = buildLeadTimeAggregateQuery({ filters, from: beforeFrom, to: beforeTo, - isPreviousPeriod: true, }); const [chartResults, amountResult, beforeResult] = await Promise.all([ @@ -272,24 +115,120 @@ export const getLeadTimeMetric = async ( currentAmount: BigInt(currentAmount), previousAmount: BigInt(previousAmount), change, + currentPeriod: { from, to }, + previousPeriod: { from: beforeFrom, to: beforeTo }, }; }; -/** - * Build change failure rate aggregate query with optimized JOINs - */ +const buildDeploymentFilters = ( + filters: DoraMetricsFilters, + alias: string = "d" +): DeploymentFiltersResult => { + const joins: Prisma.Sql[] = []; + const conditions: Prisma.Sql[] = []; + + conditions.push( + Prisma.sql`${Prisma.raw(alias)}."workspaceId" = ${filters.workspaceId}` + ); + + if (filters.environmentIds && filters.environmentIds.length > 0) { + conditions.push( + Prisma.sql`${Prisma.raw(alias)}."environmentId" = ANY(ARRAY[${Prisma.join( + filters.environmentIds.map((id) => Prisma.sql`${id}`), + ", " + )}])` + ); + } else { + joins.push( + Prisma.sql`INNER JOIN "Environment" e ON e."id" = ${Prisma.raw(alias)}."environmentId" AND e."workspaceId" = ${Prisma.raw(alias)}."workspaceId"` + ); + conditions.push( + Prisma.sql`e."isProduction" = true AND e."archivedAt" IS NULL` + ); + } + + if (filters.applicationIds && filters.applicationIds.length > 0) { + conditions.push( + Prisma.sql`${Prisma.raw(alias)}."applicationId" = ANY(ARRAY[${Prisma.join( + filters.applicationIds.map((id) => Prisma.sql`${id}`), + ", " + )}])` + ); + } + + const needsApplicationJoin = + (filters.teamIds && filters.teamIds.length > 0) || + (filters.repositoryIds && filters.repositoryIds.length > 0); + + if (needsApplicationJoin) { + joins.push( + Prisma.sql`INNER JOIN "Application" a ON a."id" = ${Prisma.raw(alias)}."applicationId" AND a."workspaceId" = ${Prisma.raw(alias)}."workspaceId" AND a."archivedAt" IS NULL` + ); + + if (filters.teamIds && filters.teamIds.length > 0) { + conditions.push( + Prisma.sql`a."teamId" = ANY(ARRAY[${Prisma.join( + filters.teamIds.map((id) => Prisma.sql`${id}`), + ", " + )}])` + ); + } + + if (filters.repositoryIds && filters.repositoryIds.length > 0) { + conditions.push( + Prisma.sql`a."repositoryId" = ANY(ARRAY[${Prisma.join( + filters.repositoryIds.map((id) => Prisma.sql`${id}`), + ", " + )}])` + ); + } + } + + return { joins, conditions }; +}; + +const buildLeadTimeAggregateQuery = ({ + filters, + from, + to, +}: AggregateQueryArgs): Prisma.Sql => { + const { joins, conditions } = buildDeploymentFilters(filters, "d"); + + const allJoins = [ + ...joins, + Prisma.sql`INNER JOIN "DeploymentPullRequest" dpr ON d."id" = dpr."deploymentId"`, + Prisma.sql`INNER JOIN "PullRequest" pr ON dpr."pullRequestId" = pr."id"`, + Prisma.sql`LEFT JOIN "PullRequestTracking" prt ON pr."id" = prt."pullRequestId"`, + ]; + + conditions.push( + Prisma.sql`d."deployedAt" >= ${new Date(from)}`, + Prisma.sql`d."deployedAt" <= ${new Date(to)}`, + Prisma.sql`d."archivedAt" IS NULL` + ); + + const whereClause = Prisma.join(conditions, " AND "); + const joinClause = Prisma.join(allJoins, " "); + + return Prisma.sql` + SELECT AVG(deployment_lead_times.lead_time_ms) AS value + FROM ( + SELECT + d."id", + EXTRACT(EPOCH FROM (d."deployedAt" - MIN(COALESCE(prt."firstCommitAt", pr."createdAt")))) * 1000 AS lead_time_ms + FROM "Deployment" d + ${joinClause} + WHERE ${whereClause} + GROUP BY d."id", d."deployedAt" + ) AS deployment_lead_times; + `; +}; + const buildChangeFailureRateAggregateQuery = ({ filters, from, to, - isPreviousPeriod = false, -}: { - filters: DoraMetricsFilters; - from: string; - to: string; - isPreviousPeriod?: boolean; -}): Prisma.Sql => { - const upperOperator = isPreviousPeriod ? Prisma.raw("<") : Prisma.raw("<="); +}: AggregateQueryArgs): Prisma.Sql => { const { joins, conditions } = buildDeploymentFilters(filters, "d"); const allJoins = [ @@ -299,7 +238,7 @@ const buildChangeFailureRateAggregateQuery = ({ conditions.push( Prisma.sql`d."deployedAt" >= ${new Date(from)}`, - Prisma.sql`d."deployedAt" ${upperOperator} ${new Date(to)}`, + Prisma.sql`d."deployedAt" <= ${new Date(to)}`, Prisma.sql`d."archivedAt" IS NULL` ); @@ -317,16 +256,12 @@ const buildChangeFailureRateAggregateQuery = ({ `; }; -/** - * Calculate change failure rate metric - * Change failure rate = (incidents / deployments) * 100 - */ export const getChangeFailureRateMetric = async ( filters: DoraMetricsFilters ): Promise => { const { dateRange, period } = filters; const { from, to } = dateRange; - const { beforeFrom, beforeTo } = calculateBeforePeriod(from, to); + const [beforeFrom, beforeTo] = getPreviousPeriod(from, to); const { joins, conditions } = buildDeploymentFilters(filters, "d"); const trunc = periodToDateTrunc(period); @@ -377,15 +312,13 @@ export const getChangeFailureRateMetric = async ( const currentAmountQuery = buildChangeFailureRateAggregateQuery({ filters, - from: from, - to: to, - isPreviousPeriod: false, + from, + to, }); const previousAmountQuery = buildChangeFailureRateAggregateQuery({ filters, from: beforeFrom, to: beforeTo, - isPreviousPeriod: true, }); const [chartResults, amountResult, beforeResult] = await Promise.all([ @@ -401,38 +334,35 @@ export const getChangeFailureRateMetric = async ( ]); const columns = chartResults.map((r) => r.period.toISOString()); - const data = chartResults.map((r) => BigInt(Math.floor(r.value || 0))); + const data = chartResults.map((r) => parseFloat(r.value?.toFixed(2) || "0")); const currentAmount = amountResult[0]?.value || 0; const previousAmount = beforeResult[0]?.value || 0; - const change = - previousAmount !== 0 - ? ((currentAmount - previousAmount) / previousAmount) * 100 - : 0; - return { columns, data, currentAmount, previousAmount, change }; + // Failure rate is already a percentage, so use the difference in percentage points + const change = currentAmount - previousAmount; + + return { + columns, + data, + currentAmount, + previousAmount, + change, + currentPeriod: { from, to }, + previousPeriod: { from: beforeFrom, to: beforeTo }, + }; }; -/** - * Build deployment frequency aggregate query with optimized JOINs - */ const buildDeploymentFrequencyAggregateQuery = ({ filters, from, to, - isPreviousPeriod = false, -}: { - filters: DoraMetricsFilters; - from: string; - to: string; - isPreviousPeriod?: boolean; -}): Prisma.Sql => { - const upperOperator = isPreviousPeriod ? Prisma.raw("<") : Prisma.raw("<="); +}: AggregateQueryArgs): Prisma.Sql => { const { joins, conditions } = buildDeploymentFilters(filters, "d"); conditions.push( Prisma.sql`d."deployedAt" >= ${new Date(from)}`, - Prisma.sql`d."deployedAt" ${upperOperator} ${new Date(to)}`, + Prisma.sql`d."deployedAt" <= ${new Date(to)}`, Prisma.sql`d."archivedAt" IS NULL` ); @@ -447,16 +377,12 @@ const buildDeploymentFrequencyAggregateQuery = ({ `; }; -/** - * Calculate deployment frequency metric - * Deployment frequency = count of deployments per period - */ export const getDeploymentFrequencyMetric = async ( filters: DoraMetricsFilters ): Promise => { const { dateRange, period } = filters; const { from, to } = dateRange; - const { beforeFrom, beforeTo } = calculateBeforePeriod(from, to); + const [beforeFrom, beforeTo] = getPreviousPeriod(from, to); const { joins, conditions } = buildDeploymentFilters(filters, "d"); const trunc = periodToDateTrunc(period); @@ -502,14 +428,12 @@ export const getDeploymentFrequencyMetric = async ( filters, from, to, - isPreviousPeriod: false, }); const previousAmountQuery = buildDeploymentFrequencyAggregateQuery({ filters, from: beforeFrom, to: beforeTo, - isPreviousPeriod: true, }); const [chartResults, amountResult, beforeResult] = await Promise.all([ @@ -534,7 +458,6 @@ export const getDeploymentFrequencyMetric = async ( ? ((currentAmount - previousAmount) / previousAmount) * 100 : 0; - // Calculate average deployments per day for current period const fromDate = new Date(from); const toDate = new Date(to); const daysDiff = Math.max( @@ -550,30 +473,24 @@ export const getDeploymentFrequencyMetric = async ( previousAmount: BigInt(previousAmount), change, avg, + currentPeriod: { from, to }, + previousPeriod: { from: beforeFrom, to: beforeTo }, }; }; -/** - * Build filter joins and conditions for incident queries using optimized JOINs - */ const buildIncidentFilters = ( filters: DoraMetricsFilters -): { joins: Prisma.Sql[]; conditions: Prisma.Sql[] } => { +): DeploymentFiltersResult => { const joins: Prisma.Sql[] = []; const conditions: Prisma.Sql[] = []; - // Workspace filter conditions.push(Prisma.sql`i."workspaceId" = ${filters.workspaceId}`); - - // Only resolved incidents conditions.push(Prisma.sql`i."resolvedAt" IS NOT NULL`); - // Always join with Deployment (causeDeployment) joins.push( Prisma.sql`INNER JOIN "Deployment" cd ON i."causeDeploymentId" = cd."id" AND cd."workspaceId" = i."workspaceId"` ); - // Environment filter - use JOIN for production filter if (filters.environmentIds && filters.environmentIds.length > 0) { conditions.push( Prisma.sql`cd."environmentId" = ANY(ARRAY[${Prisma.join( @@ -582,7 +499,6 @@ const buildIncidentFilters = ( )}])` ); } else { - // Join with Environment table to filter by isProduction joins.push( Prisma.sql`INNER JOIN "Environment" e ON e."id" = cd."environmentId" AND e."workspaceId" = cd."workspaceId"` ); @@ -591,7 +507,6 @@ const buildIncidentFilters = ( ); } - // Application filter if (filters.applicationIds && filters.applicationIds.length > 0) { conditions.push( Prisma.sql`cd."applicationId" = ANY(ARRAY[${Prisma.join( @@ -601,7 +516,7 @@ const buildIncidentFilters = ( ); } - // Team filter - can be via incident.teamId or application.teamId + // Team filter can match via incident.teamId or application.teamId const needsApplicationJoin = (filters.teamIds && filters.teamIds.length > 0) || (filters.repositoryIds && filters.repositoryIds.length > 0); @@ -635,7 +550,6 @@ const buildIncidentFilters = ( ); } } else if (filters.teamIds && filters.teamIds.length > 0) { - // Only team filter via incident.teamId, no Application join needed conditions.push( Prisma.sql`i."teamId" = ANY(ARRAY[${Prisma.join( filters.teamIds.map((id) => Prisma.sql`${id}`), @@ -647,26 +561,16 @@ const buildIncidentFilters = ( return { joins, conditions }; }; -/** - * Build mean time to recover aggregate query with optimized JOINs - */ const buildMeanTimeToRecoverAggregateQuery = ({ filters, from, to, - isPreviousPeriod = false, -}: { - filters: DoraMetricsFilters; - from: string; - to: string; - isPreviousPeriod?: boolean; -}): Prisma.Sql => { - const upperOperator = isPreviousPeriod ? Prisma.raw("<") : Prisma.raw("<="); +}: AggregateQueryArgs): Prisma.Sql => { const { joins, conditions } = buildIncidentFilters(filters); conditions.push( Prisma.sql`i."detectedAt" >= ${new Date(from)}`, - Prisma.sql`i."detectedAt" ${upperOperator} ${new Date(to)}`, + Prisma.sql`i."detectedAt" <= ${new Date(to)}`, Prisma.sql`i."archivedAt" IS NULL` ); @@ -681,16 +585,12 @@ const buildMeanTimeToRecoverAggregateQuery = ({ `; }; -/** - * Calculate mean time to recover metric - * MTTR = average time from incident detection to resolution - */ export const getMeanTimeToRecoverMetric = async ( filters: DoraMetricsFilters ): Promise => { const { dateRange, period } = filters; const { from, to } = dateRange; - const { beforeFrom, beforeTo } = calculateBeforePeriod(from, to); + const [beforeFrom, beforeTo] = getPreviousPeriod(from, to); const { joins, conditions } = buildIncidentFilters(filters); const trunc = periodToDateTrunc(period); @@ -735,14 +635,12 @@ export const getMeanTimeToRecoverMetric = async ( filters, from, to, - isPreviousPeriod: false, }); const previousAmountQuery = buildMeanTimeToRecoverAggregateQuery({ filters, from: beforeFrom, to: beforeTo, - isPreviousPeriod: true, }); const [chartResults, amountResult, beforeResult] = await Promise.all([ @@ -777,5 +675,7 @@ export const getMeanTimeToRecoverMetric = async ( currentAmount: BigInt(currentAmount), previousAmount: BigInt(previousAmount), change, + currentPeriod: { from, to }, + previousPeriod: { from: beforeFrom, to: beforeTo }, }; }; diff --git a/apps/api/src/app/metrics/services/dora-metrics.types.ts b/apps/api/src/app/metrics/services/dora-metrics.types.ts index 831e4d6c..96a8b322 100644 --- a/apps/api/src/app/metrics/services/dora-metrics.types.ts +++ b/apps/api/src/app/metrics/services/dora-metrics.types.ts @@ -12,9 +12,37 @@ export interface DoraMetricsFilters { repositoryIds?: number[]; } -export interface BuildAggregateQuery { - whereClause: Prisma.Sql; +export interface DeploymentFiltersResult { + joins: Prisma.Sql[]; + conditions: Prisma.Sql[]; +} + +export interface AggregateQueryArgs { + filters: DoraMetricsFilters; from: string; to: string; - isPreviousPeriod: boolean; +} + +export interface MetricResult { + columns: string[]; + data: bigint[]; + currentAmount: bigint; + previousAmount: bigint; + change: number; + currentPeriod: { from: string; to: string }; + previousPeriod: { from: string; to: string }; +} + +export interface FailureRateResult + extends Pick< + MetricResult, + "columns" | "change" | "currentPeriod" | "previousPeriod" + > { + data: number[]; + currentAmount: number; + previousAmount: number; +} + +export interface DeploymentFrequencyResult extends MetricResult { + avg: number; } diff --git a/apps/api/src/app/metrics/services/lead-time-breakdown.integration.test.ts b/apps/api/src/app/metrics/services/lead-time-breakdown.integration.test.ts new file mode 100644 index 00000000..b5f61e81 --- /dev/null +++ b/apps/api/src/app/metrics/services/lead-time-breakdown.integration.test.ts @@ -0,0 +1,328 @@ +import { PullRequestState } from "@prisma/client"; +import { describe, expect, it } from "vitest"; +import { createTestContextWithGitProfile } from "../../../../test/integration-setup/context"; +import { + seedApplication, + seedDeployment, + seedDeploymentPullRequest, + seedEnvironment, + seedGitProfile, + seedPullRequest, + seedRepository, + seedTeam, +} from "../../../../test/seed"; +import { Period } from "../../../graphql-types"; +import { getLeadTimeBreakdown } from "./lead-time-breakdown.service"; + +describe("Lead Time Breakdown", () => { + it("calculates breakdown metrics for a single PR", async () => { + 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, { isProduction: true }); + + const pr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId, + { + title: "Feature PR", + state: PullRequestState.MERGED, + } + ); + + // Seed tracking data with cumulative times (from PR creation) + // Durations: coding: 1h, review lag: 1h, approval: 1h, merge: 4h, deploy: 5h + // Note: timeToFirstReview and timeToFirstApproval are cumulative, so actual durations + // are calculated as deltas (e.g., review lag = 2h - 1h = 1h) + // All in milliseconds + const trackingData = { + timeToCode: BigInt(3600000), // 1h (coding duration) + timeToFirstReview: BigInt(7200000), // 2h cumulative (review lag = 2h - 1h = 1h) + timeToFirstApproval: BigInt(10800000), // 3h cumulative (approval = 3h - 2h = 1h) + timeToMerge: BigInt(14400000), // 4h (merge duration) + timeToDeploy: BigInt(18000000), // 5h (deploy duration) + }; + + await ctx.prisma.pullRequestTracking.create({ + data: { + pullRequestId: pr.pullRequestId, + workspaceId: ctx.workspaceId, + size: "MEDIUM", + ...trackingData, + }, + }); + + const deployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date(), + authorId: gitProfile.gitProfileId, + } + ); + + await seedDeploymentPullRequest( + ctx, + deployment.deploymentId, + pr.pullRequestId + ); + + const result = await getLeadTimeBreakdown({ + workspaceId: ctx.workspaceId, + dateRange: { + from: new Date(Date.now() - 86400000).toISOString(), // 1 day ago + to: new Date(Date.now() + 86400000).toISOString(), // 1 day future + }, + period: Period.DAILY, + }); + + expect(result.codingTime.currentAmount).toBe(trackingData.timeToCode); + expect(result.timeToFirstReview.currentAmount).toBe( + trackingData.timeToFirstReview - trackingData.timeToCode + ); + expect(result.timeToApprove.currentAmount).toBe( + trackingData.timeToFirstApproval - trackingData.timeToFirstReview + ); + expect(result.timeToMerge.currentAmount).toBe(trackingData.timeToMerge); + expect(result.timeToDeploy.currentAmount).toBe(trackingData.timeToDeploy); + }); + + it("calculates comparisons with previous period", async () => { + 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, { isProduction: true }); + + // Previous period deployment (2 days ago) + const prevPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId + ); + await ctx.prisma.pullRequestTracking.create({ + data: { + pullRequestId: prevPr.pullRequestId, + workspaceId: ctx.workspaceId, + size: "MEDIUM", + timeToCode: BigInt(3600000), // 1h + timeToFirstReview: BigInt(7200000), // 2h (1h code + 1h review) + timeToFirstApproval: BigInt(10800000), // 3h (2h + 1h approval) + timeToMerge: BigInt(3600000), // 1h + timeToDeploy: BigInt(3600000), // 1h + }, + }); + const prevDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date(Date.now() - 172800000), // 2 days ago + authorId: gitProfile.gitProfileId, + } + ); + await seedDeploymentPullRequest( + ctx, + prevDeployment.deploymentId, + prevPr.pullRequestId + ); + + // Current period deployment (today) + const currPr = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId + ); + await ctx.prisma.pullRequestTracking.create({ + data: { + pullRequestId: currPr.pullRequestId, + workspaceId: ctx.workspaceId, + size: "MEDIUM", + timeToCode: BigInt(7200000), // 2h (+100%) + timeToFirstReview: BigInt(9000000), // 2.5h (2h code + 0.5h review) -> 0.5h duration (-50%) + timeToFirstApproval: BigInt(12600000), // 3.5h (2.5h + 1h approval) -> 1h duration (0%) + timeToMerge: BigInt(7200000), // 2h (+100%) + timeToDeploy: BigInt(1800000), // 0.5h (-50%) + }, + }); + const currDeployment = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { + deployedAt: new Date(), + authorId: gitProfile.gitProfileId, + } + ); + await seedDeploymentPullRequest( + ctx, + currDeployment.deploymentId, + currPr.pullRequestId + ); + + const result = await getLeadTimeBreakdown({ + workspaceId: ctx.workspaceId, + dateRange: { + from: new Date(Date.now() - 86400000).toISOString(), // 1 day ago + to: new Date(Date.now() + 86400000).toISOString(), // 1 day future + }, + period: Period.DAILY, + }); + + expect(result.codingTime.change).toBeCloseTo(100, 1); + expect(result.timeToFirstReview.change).toBeCloseTo(-50, 1); + expect(result.timeToApprove.change).toBe(0); + expect(result.timeToMerge.change).toBeCloseTo(100, 1); + expect(result.timeToDeploy.change).toBeCloseTo(-50, 1); + }); + + it("respects filters (teamIds)", async () => { + const ctx = await createTestContextWithGitProfile(); + const gitProfile = await seedGitProfile(ctx); + const repository = await seedRepository(ctx); + const team1 = await seedTeam(ctx); + const team2 = await seedTeam(ctx); + const app1 = await seedApplication(ctx, repository.repositoryId, { + teamId: team1.teamId, + name: "app-1", + }); + const app2 = await seedApplication(ctx, repository.repositoryId, { + teamId: team2.teamId, + name: "app-2", + }); + const environment = await seedEnvironment(ctx, { isProduction: true }); + + // Team 1 PR: 1h coding time + const pr1 = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId + ); + await ctx.prisma.pullRequestTracking.create({ + data: { + pullRequestId: pr1.pullRequestId, + workspaceId: ctx.workspaceId, + size: "MEDIUM", + timeToCode: BigInt(3600000), + }, + }); + const dep1 = await seedDeployment( + ctx, + app1.applicationId, + environment.environmentId, + { deployedAt: new Date(), authorId: gitProfile.gitProfileId } + ); + await seedDeploymentPullRequest(ctx, dep1.deploymentId, pr1.pullRequestId); + + // Team 2 PR: 5h coding time + const pr2 = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId + ); + await ctx.prisma.pullRequestTracking.create({ + data: { + pullRequestId: pr2.pullRequestId, + workspaceId: ctx.workspaceId, + size: "MEDIUM", + timeToCode: BigInt(18000000), + }, + }); + const dep2 = await seedDeployment( + ctx, + app2.applicationId, + environment.environmentId, + { deployedAt: new Date(), authorId: gitProfile.gitProfileId } + ); + await seedDeploymentPullRequest(ctx, dep2.deploymentId, pr2.pullRequestId); + + const result = await getLeadTimeBreakdown({ + workspaceId: ctx.workspaceId, + dateRange: { + from: new Date(Date.now() - 86400000).toISOString(), + to: new Date(Date.now() + 86400000).toISOString(), + }, + period: Period.DAILY, + teamIds: [team1.teamId], + }); + + // Should only average Team 1's PRs (1h) + expect(result.codingTime.currentAmount).toBe(BigInt(3600000)); + }); + + it("handles mixed scenarios (some PRs with review, some without)", async () => { + 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, { isProduction: true }); + + // PR 1: Standard PR with review + // Coding: 1h, Review Duration: 2h + const pr1 = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId + ); + await ctx.prisma.pullRequestTracking.create({ + data: { + pullRequestId: pr1.pullRequestId, + workspaceId: ctx.workspaceId, + size: "MEDIUM", + timeToCode: BigInt(3600000), // 1h + timeToFirstReview: BigInt(10800000), // 3h (1h code + 2h review) + }, + }); + const dep1 = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date(), authorId: gitProfile.gitProfileId } + ); + await seedDeploymentPullRequest(ctx, dep1.deploymentId, pr1.pullRequestId); + + // PR 2: Hotfix without review + // Coding: 100h, Review: NULL + const pr2 = await seedPullRequest( + ctx, + repository.repositoryId, + gitProfile.gitProfileId + ); + await ctx.prisma.pullRequestTracking.create({ + data: { + pullRequestId: pr2.pullRequestId, + workspaceId: ctx.workspaceId, + size: "SMALL", + timeToCode: BigInt(360000000), // 100h + timeToFirstReview: null, + }, + }); + const dep2 = await seedDeployment( + ctx, + application.applicationId, + environment.environmentId, + { deployedAt: new Date(), authorId: gitProfile.gitProfileId } + ); + await seedDeploymentPullRequest(ctx, dep2.deploymentId, pr2.pullRequestId); + + const result = await getLeadTimeBreakdown({ + workspaceId: ctx.workspaceId, + dateRange: { + from: new Date(Date.now() - 86400000).toISOString(), + to: new Date(Date.now() + 86400000).toISOString(), + }, + period: Period.DAILY, + }); + + // Coding time should be average of both: (1h + 100h) / 2 = 50.5h = 181800000ms + expect(result.codingTime.currentAmount).toBe(BigInt(181800000)); + + // Review time should ignore PR 2 (NULL) and only count PR 1's duration + // PR 1 Duration: 3h (cumulative) - 1h (code) = 2h = 7200000ms + // If we had the bug, it might be: (3h - 50.5h) = -47.5h (Impossible/Negative) + expect(result.timeToFirstReview.currentAmount).toBe(BigInt(7200000)); + }); +}); diff --git a/apps/api/src/app/metrics/services/lead-time-breakdown.service.ts b/apps/api/src/app/metrics/services/lead-time-breakdown.service.ts new file mode 100644 index 00000000..9d3e295b --- /dev/null +++ b/apps/api/src/app/metrics/services/lead-time-breakdown.service.ts @@ -0,0 +1,178 @@ +import { Prisma } from "@prisma/client"; +import { getPreviousPeriod } from "../../../lib/date"; +import { getPrisma } from "../../../prisma"; +import { DoraMetricsFilters } from "./dora-metrics.types"; +import { + BreakdownAggregateQueryArgs, + BreakdownRow, + GetLeadTimeBreakdownArgs, + LeadTimeBreakdownResult, + StageResult, +} from "./lead-time-breakdown.types"; + +export const getLeadTimeBreakdown = async ( + args: GetLeadTimeBreakdownArgs +): Promise => { + const { workspaceId, dateRange } = args; + const { from, to } = dateRange; + const [beforeFrom, beforeTo] = getPreviousPeriod(from, to); + + const currentQuery = buildBreakdownAggregateQuery({ + args, + from, + to, + }); + + const previousQuery = buildBreakdownAggregateQuery({ + args, + from: beforeFrom, + to: beforeTo, + }); + + const [currentResults, previousResults] = await Promise.all([ + getPrisma(workspaceId).$queryRaw(currentQuery), + getPrisma(workspaceId).$queryRaw(previousQuery), + ]); + + const current = currentResults[0] || {}; + const previous = previousResults[0] || {}; + + return { + codingTime: buildStage(current.timeToCode, previous.timeToCode), + timeToFirstReview: buildStage( + current.timeToFirstReview, + previous.timeToFirstReview + ), + timeToApprove: buildStage( + current.timeToFirstApproval, + previous.timeToFirstApproval + ), + timeToMerge: buildStage(current.timeToMerge, previous.timeToMerge), + timeToDeploy: buildStage(current.timeToDeploy, previous.timeToDeploy), + currentPeriod: { from, to }, + previousPeriod: { from: beforeFrom, to: beforeTo }, + }; +}; + +const buildStage = ( + currentValue: number | null | undefined, + previousValue: number | null | undefined +): StageResult => { + const currentAmount = Math.floor(currentValue || 0); + const previousAmount = Math.floor(previousValue || 0); + + return { + currentAmount: BigInt(currentAmount), + previousAmount: BigInt(previousAmount), + change: calculateChange(currentAmount, previousAmount), + }; +}; + +const calculateChange = (current: number, previous: number): number => { + if (previous === 0) return 0; + + return ((current - previous) / previous) * 100; +}; + +const buildBreakdownAggregateQuery = ({ + args, + from, + to, +}: BreakdownAggregateQueryArgs): Prisma.Sql => { + const { joins, conditions } = buildDeploymentQueryFilters(args, "d"); + + const allJoins = [ + ...joins, + Prisma.sql`INNER JOIN "DeploymentPullRequest" dpr ON d."id" = dpr."deploymentId"`, + Prisma.sql`INNER JOIN "PullRequest" pr ON dpr."pullRequestId" = pr."id"`, + Prisma.sql`LEFT JOIN "PullRequestTracking" prt ON pr."id" = prt."pullRequestId"`, + ]; + + conditions.push( + Prisma.sql`d."deployedAt" >= ${new Date(from)}`, + Prisma.sql`d."deployedAt" <= ${new Date(to)}`, + Prisma.sql`d."archivedAt" IS NULL` + ); + + const whereClause = Prisma.join(conditions, " AND "); + const joinClause = Prisma.join(allJoins, " "); + + return Prisma.sql` + SELECT + AVG(prt."timeToCode") AS "timeToCode", + AVG(prt."timeToFirstReview" - prt."timeToCode") AS "timeToFirstReview", + AVG(prt."timeToFirstApproval" - prt."timeToFirstReview") AS "timeToFirstApproval", + AVG(prt."timeToMerge") AS "timeToMerge", + AVG(prt."timeToDeploy") AS "timeToDeploy" + FROM "Deployment" d + ${joinClause} + WHERE ${whereClause}; + `; +}; + +const buildDeploymentQueryFilters = ( + filters: DoraMetricsFilters, + alias: string +) => { + const joins: Prisma.Sql[] = []; + const conditions: Prisma.Sql[] = []; + + conditions.push( + Prisma.sql`${Prisma.raw(alias)}."workspaceId" = ${filters.workspaceId}` + ); + + if (filters.environmentIds && filters.environmentIds.length > 0) { + conditions.push( + Prisma.sql`${Prisma.raw(alias)}."environmentId" = ANY(ARRAY[${Prisma.join( + filters.environmentIds.map((id) => Prisma.sql`${id}`), + ", " + )}])` + ); + } else { + joins.push( + Prisma.sql`INNER JOIN "Environment" e ON e."id" = ${Prisma.raw(alias)}."environmentId" AND e."workspaceId" = ${Prisma.raw(alias)}."workspaceId"` + ); + conditions.push( + Prisma.sql`e."isProduction" = true AND e."archivedAt" IS NULL` + ); + } + + if (filters.applicationIds && filters.applicationIds.length > 0) { + conditions.push( + Prisma.sql`${Prisma.raw(alias)}."applicationId" = ANY(ARRAY[${Prisma.join( + filters.applicationIds.map((id) => Prisma.sql`${id}`), + ", " + )}])` + ); + } + + const needsApplicationJoin = + (filters.teamIds && filters.teamIds.length > 0) || + (filters.repositoryIds && filters.repositoryIds.length > 0); + + if (needsApplicationJoin) { + joins.push( + Prisma.sql`INNER JOIN "Application" a ON a."id" = ${Prisma.raw(alias)}."applicationId" AND a."workspaceId" = ${Prisma.raw(alias)}."workspaceId" AND a."archivedAt" IS NULL` + ); + + if (filters.teamIds && filters.teamIds.length > 0) { + conditions.push( + Prisma.sql`a."teamId" = ANY(ARRAY[${Prisma.join( + filters.teamIds.map((id) => Prisma.sql`${id}`), + ", " + )}])` + ); + } + + if (filters.repositoryIds && filters.repositoryIds.length > 0) { + conditions.push( + Prisma.sql`a."repositoryId" = ANY(ARRAY[${Prisma.join( + filters.repositoryIds.map((id) => Prisma.sql`${id}`), + ", " + )}])` + ); + } + } + + return { joins, conditions }; +}; diff --git a/apps/api/src/app/metrics/services/lead-time-breakdown.types.ts b/apps/api/src/app/metrics/services/lead-time-breakdown.types.ts new file mode 100644 index 00000000..828eb256 --- /dev/null +++ b/apps/api/src/app/metrics/services/lead-time-breakdown.types.ts @@ -0,0 +1,39 @@ +import { Prisma } from "@prisma/client"; +import { DoraMetricsFilters } from "./dora-metrics.types"; + +export interface StageResult { + currentAmount: bigint; + previousAmount: bigint; + change: number; +} + +export interface LeadTimeBreakdownResult { + codingTime: StageResult; + timeToFirstReview: StageResult; + timeToApprove: StageResult; + timeToMerge: StageResult; + timeToDeploy: StageResult; + currentPeriod: { from: string; to: string }; + previousPeriod: { from: string; to: string }; +} + +export type GetLeadTimeBreakdownArgs = DoraMetricsFilters; + +export interface BreakdownRow { + timeToCode: number | null; + timeToFirstReview: number | null; + timeToFirstApproval: number | null; + timeToMerge: number | null; + timeToDeploy: number | null; +} + +export interface BreakdownAggregateQueryArgs { + args: GetLeadTimeBreakdownArgs; + from: string; + to: string; +} + +export interface DeploymentFilters { + joins: Prisma.Sql[]; + conditions: Prisma.Sql[]; +} diff --git a/apps/api/src/app/utils.schema.ts b/apps/api/src/app/utils.schema.ts index ccf5650f..a431ebf0 100644 --- a/apps/api/src/app/utils.schema.ts +++ b/apps/api/src/app/utils.schema.ts @@ -14,4 +14,12 @@ export default /* GraphQL */ ` "The end of the date range" to: DateTime } + + type DateTimeRangeValue { + "The start of the date range" + from: DateTime + + "The end of the date range" + to: DateTime + } `; diff --git a/apps/api/src/lib/date.ts b/apps/api/src/lib/date.ts index 1ea5b6a9..fe6b816a 100644 --- a/apps/api/src/lib/date.ts +++ b/apps/api/src/lib/date.ts @@ -104,3 +104,26 @@ export const subBusinessHours = (date: Date, hours: number): Date => { export const thirtyDaysAgo = () => { return startOfDay(subDays(new UTCDate(), 30)); }; + +// Given a date range, calculate the previous date range (exclusive end becomes inclusive) +export const getPreviousPeriod = (from: string, to: string) => { + const fromDate = new Date(from); + const toDate = new Date(to); + + const fromMs = fromDate.getTime(); + const toMs = toDate.getTime(); + + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) { + throw new Error("Invalid date range: dates could not be parsed"); + } + + if (toMs <= fromMs) { + throw new Error("Invalid date range: 'to' must be after 'from'"); + } + + const duration = toMs - fromMs; + const beforeTo = new Date(fromMs - 1); + const beforeFrom = new Date(beforeTo.getTime() - duration + 1); + + return [beforeFrom.toISOString(), beforeTo.toISOString()]; +}; diff --git a/apps/web/src/api/dora-metrics.api.ts b/apps/web/src/api/dora-metrics.api.ts index 4ac9a4cc..373a9d06 100644 --- a/apps/web/src/api/dora-metrics.api.ts +++ b/apps/web/src/api/dora-metrics.api.ts @@ -1,9 +1,9 @@ +import { graphql } from "@sweetr/graphql-types/frontend"; import { DoraMetricsQuery, DoraMetricsQueryVariables, } from "@sweetr/graphql-types/frontend/graphql"; import { UseQueryOptions, useQuery } from "@tanstack/react-query"; -import { graphql } from "@sweetr/graphql-types/frontend"; import { graphQLClient } from "./clients/graphql-client"; export const useDoraMetricsQuery = ( @@ -29,6 +29,14 @@ export const useDoraMetricsQuery = ( columns data avg + currentPeriod { + from + to + } + previousPeriod { + from + to + } } leadTime(input: $input) { currentAmount @@ -36,6 +44,41 @@ export const useDoraMetricsQuery = ( change columns data + currentPeriod { + from + to + } + previousPeriod { + from + to + } + breakdown { + codingTime { + currentAmount + previousAmount + change + } + timeToFirstReview { + currentAmount + previousAmount + change + } + timeToApprove { + currentAmount + previousAmount + change + } + timeToMerge { + currentAmount + previousAmount + change + } + timeToDeploy { + currentAmount + previousAmount + change + } + } } changeFailureRate(input: $input) { currentAmount @@ -43,6 +86,14 @@ export const useDoraMetricsQuery = ( change columns data + currentPeriod { + from + to + } + previousPeriod { + from + to + } } meanTimeToRecover(input: $input) { currentAmount @@ -50,6 +101,14 @@ export const useDoraMetricsQuery = ( change columns data + currentPeriod { + from + to + } + previousPeriod { + from + to + } } } } diff --git a/apps/web/src/app/home/components/my-stats/card-stat.tsx b/apps/web/src/app/home/components/my-stats/card-stat.tsx index 82ba94e4..ab54d1f9 100644 --- a/apps/web/src/app/home/components/my-stats/card-stat.tsx +++ b/apps/web/src/app/home/components/my-stats/card-stat.tsx @@ -1,10 +1,10 @@ -import { Text, Paper, Group } from "@mantine/core"; -import classes from "./card-stat.module.css"; +import { Group, Paper, Text } from "@mantine/core"; import { IconArrowDownRight, IconArrowUpRight, IconProps, } from "@tabler/icons-react"; +import classes from "./card-stat.module.css"; interface CardStatProps { name: string; @@ -41,7 +41,7 @@ export const CardStat = ({ {!!previous && ( = 0 ? "teal" : "red"} + c={change >= 0 ? "green.4" : "red"} fz="sm" fw={500} className={classes.diff} @@ -58,9 +58,7 @@ export const CardStat = ({ )} - {previous - ? "Compared to previous UTC period." - : "No data to compare."} + {previous ? "Compared to previous period." : "No data to compare."} diff --git a/apps/web/src/app/metrics-and-insights/components/button-understand/button-understand.tsx b/apps/web/src/app/metrics-and-insights/components/button-understand/button-understand.tsx new file mode 100644 index 00000000..389fb4d0 --- /dev/null +++ b/apps/web/src/app/metrics-and-insights/components/button-understand/button-understand.tsx @@ -0,0 +1,24 @@ +import { Button, HoverCard } from "@mantine/core"; +import { IconInfoHexagon } from "@tabler/icons-react"; +import { ReactNode } from "react"; + +interface ButtonUnderstandProps { + children: ReactNode; +} + +export const ButtonUnderstand = ({ children }: ButtonUnderstandProps) => { + return ( + + + + + {children} + + ); +}; diff --git a/apps/web/src/app/metrics-and-insights/components/button-understand/index.ts b/apps/web/src/app/metrics-and-insights/components/button-understand/index.ts new file mode 100644 index 00000000..35983456 --- /dev/null +++ b/apps/web/src/app/metrics-and-insights/components/button-understand/index.ts @@ -0,0 +1 @@ +export { ButtonUnderstand } from "./button-understand"; diff --git a/apps/web/src/app/metrics-and-insights/components/card-dora-metric/dora-card-stat.tsx b/apps/web/src/app/metrics-and-insights/components/card-dora-metric/dora-card-stat.tsx index fa1b2043..ac8b8061 100644 --- a/apps/web/src/app/metrics-and-insights/components/card-dora-metric/dora-card-stat.tsx +++ b/apps/web/src/app/metrics-and-insights/components/card-dora-metric/dora-card-stat.tsx @@ -1,18 +1,26 @@ -import { Text, Paper, Group, Stack } from "@mantine/core"; +import { Group, HoverCard, Paper, Stack, Text } from "@mantine/core"; import { IconArrowDownRight, + IconArrowNarrowRightDashed, IconArrowUpRight, IconProps, } from "@tabler/icons-react"; import { Link, useLocation } from "react-router-dom"; +import { + DateTimeRange, + formatLocaleDate, +} from "../../../../providers/date.provider"; interface CardDoraMetricProps { name: string; amount: string; amountDescription?: string; + previousAmount: string; change: number; icon: React.ComponentType; href: string; + higherIsBetter: boolean; + previousPeriod?: Partial; } export const CardDoraMetric = ({ @@ -20,12 +28,26 @@ export const CardDoraMetric = ({ amount, change, amountDescription, + previousAmount, icon: Icon, href, + higherIsBetter, + previousPeriod, }: CardDoraMetricProps) => { const { pathname } = useLocation(); const isActive = pathname === href; + const getTrendColor = () => { + if (change === 0) { + return "dimmed"; + } + + const isPositiveChange = change >= 0; + const isGood = higherIsBetter ? isPositiveChange : !isPositiveChange; + + return isGood ? "green.4" : "red"; + }; + return ( - - - = 0 ? "teal" : "red"} - fz="sm" - fw={500} - style={{ display: "flex", alignItems: "center", gap: 4 }} - > - - {change >= 0 ? "+" : ""} - {change}% - - {change >= 0 ? ( - + + + + + {change === 0 && ( + <> + 0% + + + )} + + {change !== 0 && ( + <> + + {change >= 0 ? "+" : ""} + {change}% + + {change >= 0 ? ( + + ) : ( + + )} + + )} + + + Compared to previous period. + + + + + + {previousPeriod?.from && previousPeriod?.to ? ( + <> + {formatLocaleDate(new Date(previousPeriod.from), { + year: "numeric", + month: "short", + day: "numeric", + })}{" "} + -{" "} + {formatLocaleDate(new Date(previousPeriod.to), { + year: "numeric", + month: "short", + day: "numeric", + })} + ) : ( - + "Previous period unavailable" )} - - - Compared to previous UTC period. - - + + {previousAmount} + + + ); }; diff --git a/apps/web/src/app/metrics-and-insights/deployment-frequency/page.tsx b/apps/web/src/app/metrics-and-insights/deployment-frequency/page.tsx index dedc29fc..2f9c4388 100644 --- a/apps/web/src/app/metrics-and-insights/deployment-frequency/page.tsx +++ b/apps/web/src/app/metrics-and-insights/deployment-frequency/page.tsx @@ -1,12 +1,17 @@ -import { Paper, Skeleton } from "@mantine/core"; +import { Group, Paper, Skeleton, Stack, Text } from "@mantine/core"; +import { Period } from "@sweetr/graphql-types/frontend/graphql"; +import { IconRefresh } from "@tabler/icons-react"; import { useOutletContext } from "react-router-dom"; +import { FilterSelect } from "../../../components/filter-select"; import { useWorkspace } from "../../../providers/workspace.provider"; -import { DoraMetricFilters } from "../types"; +import { ButtonUnderstand } from "../components/button-understand"; +import { DoraMetricOutletContext } from "../types"; import { useDoraMetrics } from "../useDoraMetrics"; import { ChartDeploymentFrequency } from "./chart-deployment-frequency/chart-deployment-frequency"; export const DoraDeploymentFrequencyPage = () => { - const filters = useOutletContext(); + const { filters, onPeriodChange } = + useOutletContext(); const { workspace } = useWorkspace(); const { isLoading, metrics } = useDoraMetrics({ @@ -20,6 +25,36 @@ export const DoraDeploymentFrequencyPage = () => { return ( <> + + onPeriodChange(value as Period)} + /> + + + + Deployment Frequency shows how often your team ships code to + production. Higher frequency typically indicates smaller, safer + changes and a healthy CI/CD pipeline. + + + Elite teams deploy multiple times per day. If your frequency is + low, look for large batch sizes, manual processes, or fear of + deploying that might be slowing you down. + + + + + { const { workspace } = useWorkspace(); - const filters = useOutletContext(); + const { filters, onPeriodChange } = + useOutletContext(); const { isLoading, metrics } = useDoraMetrics({ workspaceId: workspace.id, filters, @@ -19,6 +24,36 @@ export const DoraFailureRatePage = () => { return ( <> + + onPeriodChange(value as Period)} + /> + + + + Change Failure Rate measures the percentage of deployments that + cause a failure in production. Lower rates indicate better + testing, code review practices, and overall code quality. + + + Elite teams maintain a failure rate below 15%. If yours is higher, + consider improving test coverage, adding staging environments, or + implementing feature flags for safer rollouts. + + + + + ; +} + +export const LeadTimeBreakdown = ({ + breakdown, + previousPeriod, +}: LeadTimeBreakdownProps) => { + return ( + + + + + + + + ); +}; diff --git a/apps/web/src/app/metrics-and-insights/lead-time/components/lead-time-breakdown/step.tsx b/apps/web/src/app/metrics-and-insights/lead-time/components/lead-time-breakdown/step.tsx new file mode 100644 index 00000000..bdafea87 --- /dev/null +++ b/apps/web/src/app/metrics-and-insights/lead-time/components/lead-time-breakdown/step.tsx @@ -0,0 +1,131 @@ +import { + Box, + Group, + HoverCard, + Stack, + Stepper, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { BreakdownStage } from "@sweetr/graphql-types/frontend/graphql"; +import { + IconArrowDownRight, + IconArrowNarrowRightDashed, + IconArrowUpRight, +} from "@tabler/icons-react"; +import { + DateTimeRange, + formatLocaleDate, + formatMsDuration, + getAbbreviatedDuration, +} from "../../../../../providers/date.provider"; +import { UTCDate } from "@date-fns/utc"; + +interface StepProps { + stage: BreakdownStage; + label: string; + icon: React.ElementType; + previousPeriod?: Partial; +} + +export const Step = ({ + stage, + label, + icon: Icon, + previousPeriod, +}: StepProps) => { + const duration = stage.currentAmount + ? getAbbreviatedDuration(stage.currentAmount) + : "0s"; + const exactDuration = stage.currentAmount + ? formatMsDuration(stage.currentAmount) + : "0s"; + const change = stage.change; + + return ( + + {label} + + } + description={ + + + + {duration} + + + + + + {change !== 0 && ( + + 0 ? "red" : "green.4"}> + {change > 0 ? ( + + ) : ( + + )} + + {Math.abs(change)}% + + )} + {change === 0 && ( + + + + {Math.abs(change)}% + + + )} + + + + {previousPeriod?.from && previousPeriod?.to && ( + + {formatLocaleDate(new UTCDate(previousPeriod.from), { + year: "numeric", + month: "short", + day: "numeric", + })}{" "} + -{" "} + {formatLocaleDate(new UTCDate(previousPeriod.to), { + year: "numeric", + month: "short", + day: "numeric", + })} + + )} + + {stage.previousAmount + ? getAbbreviatedDuration(stage.previousAmount) + : "0s"} + + + + + } + icon={ + + + + } + /> + ); +}; diff --git a/apps/web/src/app/metrics-and-insights/lead-time/page.tsx b/apps/web/src/app/metrics-and-insights/lead-time/page.tsx index 9b5bcaa1..bea02fd8 100644 --- a/apps/web/src/app/metrics-and-insights/lead-time/page.tsx +++ b/apps/web/src/app/metrics-and-insights/lead-time/page.tsx @@ -1,13 +1,19 @@ -import { Paper, Skeleton } from "@mantine/core"; +import { Box, Group, Paper, Skeleton, Stack, Text } from "@mantine/core"; +import { Period } from "@sweetr/graphql-types/frontend/graphql"; +import { IconRefresh } from "@tabler/icons-react"; import { useOutletContext } from "react-router-dom"; +import { FilterSelect } from "../../../components/filter-select"; import { useWorkspace } from "../../../providers/workspace.provider"; -import { ChartAverageTime } from "../../humans/teams/[id]/health-and-performance/components/chart-average-time"; -import { DoraMetricFilters } from "../types"; +import { ButtonUnderstand } from "../components/button-understand"; +import { DoraMetricOutletContext } from "../types"; import { useDoraMetrics } from "../useDoraMetrics"; +import { ChartAverageTime } from "../../humans/teams/[id]/health-and-performance/components/chart-average-time"; +import { LeadTimeBreakdown } from "./components/lead-time-breakdown"; export const DoraLeadTimePage = () => { const { workspace } = useWorkspace(); - const filters = useOutletContext(); + const { filters, onPeriodChange } = + useOutletContext(); const { isLoading, metrics } = useDoraMetrics({ workspaceId: workspace.id, filters, @@ -19,6 +25,36 @@ export const DoraLeadTimePage = () => { return ( <> + + onPeriodChange(value as Period)} + /> + + + + Lead Time measures how long it takes for code to go from first + commit to production. Shorter lead times mean your team can + deliver value faster and respond quickly to customer needs. + + + Use the breakdown below to identify bottlenecks: Is code waiting + too long for review? Are approvals slow? This helps you focus + improvement efforts where they matter most. + + + + + { period={filters.period} /> + + {metrics.leadTime.breakdown && ( + + + + )} ); }; diff --git a/apps/web/src/app/metrics-and-insights/mttr/page.tsx b/apps/web/src/app/metrics-and-insights/mttr/page.tsx index aaa6f97f..d84d94a2 100644 --- a/apps/web/src/app/metrics-and-insights/mttr/page.tsx +++ b/apps/web/src/app/metrics-and-insights/mttr/page.tsx @@ -1,13 +1,18 @@ -import { Paper, Skeleton } from "@mantine/core"; +import { Group, Paper, Skeleton, Stack, Text } from "@mantine/core"; +import { Period } from "@sweetr/graphql-types/frontend/graphql"; +import { IconRefresh } from "@tabler/icons-react"; import { useOutletContext } from "react-router-dom"; +import { FilterSelect } from "../../../components/filter-select"; import { useWorkspace } from "../../../providers/workspace.provider"; -import { ChartAverageTime } from "../../humans/teams/[id]/health-and-performance/components/chart-average-time"; -import { DoraMetricFilters } from "../types"; +import { ButtonUnderstand } from "../components/button-understand"; +import { DoraMetricOutletContext } from "../types"; import { useDoraMetrics } from "../useDoraMetrics"; +import { ChartAverageTime } from "../../humans/teams/[id]/health-and-performance/components/chart-average-time"; export const DoraMttrPage = () => { const { workspace } = useWorkspace(); - const filters = useOutletContext(); + const { filters, onPeriodChange } = + useOutletContext(); const { isLoading, metrics } = useDoraMetrics({ workspaceId: workspace.id, filters, @@ -19,6 +24,36 @@ export const DoraMttrPage = () => { return ( <> + + onPeriodChange(value as Period)} + /> + + + + Mean Time to Recovery (MTTR) measures how quickly your team + restores service after a failure. Shorter recovery times mean less + impact on users and business. + + + Elite teams recover in under an hour. To improve MTTR, invest in + monitoring and alerting, practice incident response, and ensure + quick rollback capabilities are in place. + + + + + { @@ -121,7 +119,7 @@ export const MetricsAndInsightsPage = () => { /> - + {!isLoading && ( @@ -130,39 +128,61 @@ export const MetricsAndInsightsPage = () => { amount={ metrics.deploymentFrequency?.currentAmount?.toString() || "0" } + previousAmount={ + metrics.deploymentFrequency?.previousAmount?.toString() || "0" + } amountDescription={`${metrics.deploymentFrequency?.avg} per day`} change={metrics.deploymentFrequency?.change || 0} icon={IconDeployment} href="/metrics-and-insights/deployment-frequency" + higherIsBetter={true} + previousPeriod={metrics.deploymentFrequency?.previousPeriod} /> )} @@ -176,30 +196,21 @@ export const MetricsAndInsightsPage = () => { )} - + - { - filters.setFieldValue("period", value as Period); - searchParams.set("period", value); - }} + { + filters.setFieldValue("period", period); + searchParams.set("period", period); + }, + } satisfies DoraMetricOutletContext + } /> - - - - ); diff --git a/apps/web/src/app/metrics-and-insights/types.ts b/apps/web/src/app/metrics-and-insights/types.ts index 12c1d90e..a831acee 100644 --- a/apps/web/src/app/metrics-and-insights/types.ts +++ b/apps/web/src/app/metrics-and-insights/types.ts @@ -8,3 +8,8 @@ export type DoraMetricFilters = { environmentIds: string[]; period: Period; }; + +export type DoraMetricOutletContext = { + filters: DoraMetricFilters; + onPeriodChange: (period: Period) => void; +}; diff --git a/apps/web/src/providers/date.provider.ts b/apps/web/src/providers/date.provider.ts index 9351e6aa..0f9c89d3 100644 --- a/apps/web/src/providers/date.provider.ts +++ b/apps/web/src/providers/date.provider.ts @@ -1,7 +1,7 @@ -import { UTCDate } from "@date-fns/utc"; import { DayOfTheWeek } from "@sweetr/graphql-types/frontend/graphql"; import { differenceInDays, + Duration, DurationUnit, format, formatDistanceToNow, @@ -14,6 +14,11 @@ import { subDays, } from "date-fns"; +export interface DateTimeRange { + from: string | null; + to: string | null; +} + export const msToHour = 1000 * 60 * 60; export const humanizeDuration = (durationInMs: number) => { @@ -40,6 +45,52 @@ export const formatMsDuration = ( return formatDuration(duration, { format, delimiter: ", " }); }; +export const getDurationHighestUnit = (durationInMs: number) => { + const duration = formatMsDuration(durationInMs); + + return duration.split(",").at(0); +}; + +export const getAbbreviatedDuration = (durationInMs: number): string => { + const duration = intervalToDuration({ + start: 0, + end: durationInMs, + }); + + // Units in order of magnitude + const units: Array = [ + "years", + "months", + "days", + "hours", + "minutes", + "seconds", + ]; + + const abbreviations: Record = { + years: "y", + months: "mo", + days: "d", + hours: "h", + minutes: "m", + seconds: "s", + }; + + const parts = []; + + for (const unit of units) { + const value = duration[unit]; + if (value && value > 0) { + parts.push(`${value}${abbreviations[unit]}`); + } + if (parts.length >= 2) break; + } + + if (parts.length === 0) return "0s"; + + return parts.join(" "); +}; + export const parseNullableISO = ( date: string | null | undefined, ): Date | undefined => (date ? parseISO(date) : undefined); @@ -98,5 +149,5 @@ export const formatDateAgo = (date: Date, type: "relative" | "ago") => { }; export const thirtyDaysAgo = () => { - return startOfDay(subDays(new UTCDate(), 30)); + return startOfDay(subDays(new Date(), 30)); }; diff --git a/packages/graphql-types/api.ts b/packages/graphql-types/api.ts index b064daad..6a04c3d8 100644 --- a/packages/graphql-types/api.ts +++ b/packages/graphql-types/api.ts @@ -175,6 +175,16 @@ export type Billing = { trial?: Maybe; }; +export type BreakdownStage = { + __typename?: 'BreakdownStage'; + /** Percentage change from previous period */ + change: Scalars['Float']['output']; + /** Average time in milliseconds for current period */ + currentAmount: Scalars['BigInt']['output']; + /** Average time in milliseconds for previous period */ + previousAmount: Scalars['BigInt']['output']; +}; + export type ChangeFailureRateMetric = { __typename?: 'ChangeFailureRateMetric'; /** The change in change failure rate */ @@ -183,10 +193,14 @@ export type ChangeFailureRateMetric = { columns: Array; /** The change failure rate for the current period */ currentAmount: Scalars['Float']['output']; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; /** The amounts over time for the chart */ - data: Array; + data: Array; /** The change failure rate before the current period */ previousAmount: Scalars['Float']['output']; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; }; export type ChartNumericSeries = { @@ -256,6 +270,14 @@ export type DateTimeRange = { to?: InputMaybe; }; +export type DateTimeRangeValue = { + __typename?: 'DateTimeRangeValue'; + /** The start of the date range */ + from?: Maybe; + /** The end of the date range */ + to?: Maybe; +}; + export enum DayOfTheWeek { FRIDAY = 'FRIDAY', MONDAY = 'MONDAY', @@ -299,10 +321,14 @@ export type DeploymentFrequencyMetric = { columns: Array; /** The amount of deployments for the current period */ currentAmount: Scalars['BigInt']['output']; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; /** The amounts over time for the chart */ data: Array; /** The number of deployments before the current period */ previousAmount: Scalars['BigInt']['output']; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; }; export type DeploymentSettings = { @@ -491,18 +517,42 @@ export enum IntegrationApp { SLACK = 'SLACK' } +export type LeadTimeBreakdown = { + __typename?: 'LeadTimeBreakdown'; + /** Time spent coding (first commit to PR creation) */ + codingTime: BreakdownStage; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; + /** Time from first review to approval */ + timeToApprove: BreakdownStage; + /** Time from merge to deploy */ + timeToDeploy: BreakdownStage; + /** Time waiting for first review */ + timeToFirstReview: BreakdownStage; + /** Time from approval to merge */ + timeToMerge: BreakdownStage; +}; + export type LeadTimeMetric = { __typename?: 'LeadTimeMetric'; + /** Breakdown of lead time by development stages */ + breakdown: LeadTimeBreakdown; /** The change in lead time */ change: Scalars['Float']['output']; /** The columns for the chart */ columns: Array; /** The lead time in milliseconds for the current period */ currentAmount: Scalars['BigInt']['output']; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; /** The amounts over time for the chart */ data: Array; /** The lead time in milliseconds before the current period */ previousAmount: Scalars['BigInt']['output']; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; }; export type LoginToStripeInput = { @@ -528,10 +578,14 @@ export type MeanTimeToRecoverMetric = { columns: Array; /** The mean time to recover in milliseconds for the current period */ currentAmount: Scalars['BigInt']['output']; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; /** The amounts over time for the chart */ data: Array; /** The mean time to recover in milliseconds before the current period */ previousAmount: Scalars['BigInt']['output']; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; }; export type Metrics = { @@ -1453,6 +1507,7 @@ export type ResolversTypes = { BigInt: ResolverTypeWrapper>; Billing: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper>; + BreakdownStage: ResolverTypeWrapper>; ChangeFailureRateMetric: ResolverTypeWrapper>; ChartNumericSeries: ResolverTypeWrapper>; CodeReview: ResolverTypeWrapper>; @@ -1463,6 +1518,7 @@ export type ResolversTypes = { CodeReviewsInput: ResolverTypeWrapper>; DateTime: ResolverTypeWrapper>; DateTimeRange: ResolverTypeWrapper>; + DateTimeRangeValue: ResolverTypeWrapper>; DayOfTheWeek: ResolverTypeWrapper>; Deployment: ResolverTypeWrapper>; DeploymentFrequencyMetric: ResolverTypeWrapper>; @@ -1487,6 +1543,7 @@ export type ResolversTypes = { Integration: ResolverTypeWrapper>; IntegrationApp: ResolverTypeWrapper>; JSONObject: ResolverTypeWrapper>; + LeadTimeBreakdown: ResolverTypeWrapper>; LeadTimeMetric: ResolverTypeWrapper>; LoginToStripeInput: ResolverTypeWrapper>; LoginWithGithubInput: ResolverTypeWrapper>; @@ -1579,6 +1636,7 @@ export type ResolversParentTypes = { BigInt: DeepPartial; Billing: DeepPartial; Boolean: DeepPartial; + BreakdownStage: DeepPartial; ChangeFailureRateMetric: DeepPartial; ChartNumericSeries: DeepPartial; CodeReview: DeepPartial; @@ -1588,6 +1646,7 @@ export type ResolversParentTypes = { CodeReviewsInput: DeepPartial; DateTime: DeepPartial; DateTimeRange: DeepPartial; + DateTimeRangeValue: DeepPartial; Deployment: DeepPartial; DeploymentFrequencyMetric: DeepPartial; DeploymentSettings: DeepPartial; @@ -1607,6 +1666,7 @@ export type ResolversParentTypes = { Int: DeepPartial; Integration: DeepPartial; JSONObject: DeepPartial; + LeadTimeBreakdown: DeepPartial; LeadTimeMetric: DeepPartial; LoginToStripeInput: DeepPartial; LoginWithGithubInput: DeepPartial; @@ -1753,12 +1813,21 @@ export type BillingResolvers; }; +export type BreakdownStageResolvers = { + change?: Resolver; + currentAmount?: Resolver; + previousAmount?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ChangeFailureRateMetricResolvers = { change?: Resolver; columns?: Resolver, ParentType, ContextType>; currentAmount?: Resolver; - data?: Resolver, ParentType, ContextType>; + currentPeriod?: Resolver; + data?: Resolver, ParentType, ContextType>; previousAmount?: Resolver; + previousPeriod?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -1805,6 +1874,12 @@ export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig = { + from?: Resolver, ParentType, ContextType>; + to?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeploymentResolvers = { application?: Resolver; archivedAt?: Resolver, ParentType, ContextType>; @@ -1824,8 +1899,10 @@ export type DeploymentFrequencyMetricResolvers; columns?: Resolver, ParentType, ContextType>; currentAmount?: Resolver; + currentPeriod?: Resolver; data?: Resolver, ParentType, ContextType>; previousAmount?: Resolver; + previousPeriod?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -1901,12 +1978,26 @@ export interface JsonObjectScalarConfig extends GraphQLScalarTypeConfig = { + codingTime?: Resolver; + currentPeriod?: Resolver; + previousPeriod?: Resolver; + timeToApprove?: Resolver; + timeToDeploy?: Resolver; + timeToFirstReview?: Resolver; + timeToMerge?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type LeadTimeMetricResolvers = { + breakdown?: Resolver; change?: Resolver; columns?: Resolver, ParentType, ContextType>; currentAmount?: Resolver; + currentPeriod?: Resolver; data?: Resolver, ParentType, ContextType>; previousAmount?: Resolver; + previousPeriod?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -1920,8 +2011,10 @@ export type MeanTimeToRecoverMetricResolvers; columns?: Resolver, ParentType, ContextType>; currentAmount?: Resolver; + currentPeriod?: Resolver; data?: Resolver, ParentType, ContextType>; previousAmount?: Resolver; + previousPeriod?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2207,6 +2300,7 @@ export type Resolvers = { AutomationBenefits?: AutomationBenefitsResolvers; BigInt?: GraphQLScalarType; Billing?: BillingResolvers; + BreakdownStage?: BreakdownStageResolvers; ChangeFailureRateMetric?: ChangeFailureRateMetricResolvers; ChartNumericSeries?: ChartNumericSeriesResolvers; CodeReview?: CodeReviewResolvers; @@ -2214,6 +2308,7 @@ export type Resolvers = { CodeReviewDistributionEntity?: CodeReviewDistributionEntityResolvers; CodeReviewSubmittedEvent?: CodeReviewSubmittedEventResolvers; DateTime?: GraphQLScalarType; + DateTimeRangeValue?: DateTimeRangeValueResolvers; Deployment?: DeploymentResolvers; DeploymentFrequencyMetric?: DeploymentFrequencyMetricResolvers; DeploymentSettings?: DeploymentSettingsResolvers; @@ -2225,6 +2320,7 @@ export type Resolvers = { Incident?: IncidentResolvers; Integration?: IntegrationResolvers; JSONObject?: GraphQLScalarType; + LeadTimeBreakdown?: LeadTimeBreakdownResolvers; LeadTimeMetric?: LeadTimeMetricResolvers; LoginWithGithubResponse?: LoginWithGithubResponseResolvers; MeanTimeToRecoverMetric?: MeanTimeToRecoverMetricResolvers; diff --git a/packages/graphql-types/frontend/gql.ts b/packages/graphql-types/frontend/gql.ts index 092b6e5c..2ee45c0f 100644 --- a/packages/graphql-types/frontend/gql.ts +++ b/packages/graphql-types/frontend/gql.ts @@ -40,7 +40,7 @@ const documents = { "\n query TeamDigests($workspaceId: SweetID!, $teamId: SweetID!) {\n workspace(workspaceId: $workspaceId) {\n team(teamId: $teamId) {\n digests {\n type\n enabled\n }\n }\n }\n }\n ": types.TeamDigestsDocument, "\n query TeamDigest(\n $workspaceId: SweetID!\n $teamId: SweetID!\n $input: DigestQueryInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n team(teamId: $teamId) {\n digest(input: $input) {\n type\n enabled\n channel\n frequency\n dayOfTheWeek\n timeOfDay\n timezone\n settings\n }\n }\n }\n }\n ": types.TeamDigestDocument, "\n mutation UpdateDigest($input: UpdateDigestInput!) {\n updateDigest(input: $input) {\n type\n enabled\n }\n }\n ": types.UpdateDigestDocument, - "\n query DoraMetrics(\n $workspaceId: SweetID!\n $input: WorkspaceMetricInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n metrics {\n dora {\n deploymentFrequency(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n avg\n }\n leadTime(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n changeFailureRate(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n meanTimeToRecover(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n }\n }\n }\n }\n ": types.DoraMetricsDocument, + "\n query DoraMetrics(\n $workspaceId: SweetID!\n $input: WorkspaceMetricInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n metrics {\n dora {\n deploymentFrequency(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n avg\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n leadTime(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n breakdown {\n codingTime {\n currentAmount\n previousAmount\n change\n }\n timeToFirstReview {\n currentAmount\n previousAmount\n change\n }\n timeToApprove {\n currentAmount\n previousAmount\n change\n }\n timeToMerge {\n currentAmount\n previousAmount\n change\n }\n timeToDeploy {\n currentAmount\n previousAmount\n change\n }\n }\n }\n changeFailureRate(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n meanTimeToRecover(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n }\n }\n }\n }\n ": types.DoraMetricsDocument, "\n query EnvironmentOptions(\n $workspaceId: SweetID!\n $input: EnvironmentsQueryInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n environments(input: $input) {\n id\n name\n isProduction\n }\n }\n }\n ": types.EnvironmentOptionsDocument, "\n query Environments(\n $workspaceId: SweetID!\n $input: EnvironmentsQueryInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n environments(input: $input) {\n id\n name\n isProduction\n archivedAt\n }\n }\n }\n ": types.EnvironmentsDocument, "\n mutation ArchiveEnvironment($input: ArchiveEnvironmentInput!) {\n archiveEnvironment(input: $input) {\n id\n name\n isProduction\n archivedAt\n }\n }\n ": types.ArchiveEnvironmentDocument, @@ -209,7 +209,7 @@ export function graphql(source: "\n mutation UpdateDigest($input: Updat /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query DoraMetrics(\n $workspaceId: SweetID!\n $input: WorkspaceMetricInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n metrics {\n dora {\n deploymentFrequency(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n avg\n }\n leadTime(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n changeFailureRate(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n meanTimeToRecover(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n }\n }\n }\n }\n "): (typeof documents)["\n query DoraMetrics(\n $workspaceId: SweetID!\n $input: WorkspaceMetricInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n metrics {\n dora {\n deploymentFrequency(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n avg\n }\n leadTime(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n changeFailureRate(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n meanTimeToRecover(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n }\n }\n }\n }\n }\n "]; +export function graphql(source: "\n query DoraMetrics(\n $workspaceId: SweetID!\n $input: WorkspaceMetricInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n metrics {\n dora {\n deploymentFrequency(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n avg\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n leadTime(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n breakdown {\n codingTime {\n currentAmount\n previousAmount\n change\n }\n timeToFirstReview {\n currentAmount\n previousAmount\n change\n }\n timeToApprove {\n currentAmount\n previousAmount\n change\n }\n timeToMerge {\n currentAmount\n previousAmount\n change\n }\n timeToDeploy {\n currentAmount\n previousAmount\n change\n }\n }\n }\n changeFailureRate(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n meanTimeToRecover(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n }\n }\n }\n }\n "): (typeof documents)["\n query DoraMetrics(\n $workspaceId: SweetID!\n $input: WorkspaceMetricInput!\n ) {\n workspace(workspaceId: $workspaceId) {\n metrics {\n dora {\n deploymentFrequency(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n avg\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n leadTime(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n breakdown {\n codingTime {\n currentAmount\n previousAmount\n change\n }\n timeToFirstReview {\n currentAmount\n previousAmount\n change\n }\n timeToApprove {\n currentAmount\n previousAmount\n change\n }\n timeToMerge {\n currentAmount\n previousAmount\n change\n }\n timeToDeploy {\n currentAmount\n previousAmount\n change\n }\n }\n }\n changeFailureRate(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n meanTimeToRecover(input: $input) {\n currentAmount\n previousAmount\n change\n columns\n data\n currentPeriod {\n from\n to\n }\n previousPeriod {\n from\n to\n }\n }\n }\n }\n }\n }\n "]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/graphql-types/frontend/graphql.ts b/packages/graphql-types/frontend/graphql.ts index 14878867..2fe8a7f3 100644 --- a/packages/graphql-types/frontend/graphql.ts +++ b/packages/graphql-types/frontend/graphql.ts @@ -172,6 +172,16 @@ export type Billing = { trial?: Maybe; }; +export type BreakdownStage = { + __typename?: 'BreakdownStage'; + /** Percentage change from previous period */ + change: Scalars['Float']['output']; + /** Average time in milliseconds for current period */ + currentAmount: Scalars['BigInt']['output']; + /** Average time in milliseconds for previous period */ + previousAmount: Scalars['BigInt']['output']; +}; + export type ChangeFailureRateMetric = { __typename?: 'ChangeFailureRateMetric'; /** The change in change failure rate */ @@ -180,10 +190,14 @@ export type ChangeFailureRateMetric = { columns: Array; /** The change failure rate for the current period */ currentAmount: Scalars['Float']['output']; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; /** The amounts over time for the chart */ - data: Array; + data: Array; /** The change failure rate before the current period */ previousAmount: Scalars['Float']['output']; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; }; export type ChartNumericSeries = { @@ -253,6 +267,14 @@ export type DateTimeRange = { to?: InputMaybe; }; +export type DateTimeRangeValue = { + __typename?: 'DateTimeRangeValue'; + /** The start of the date range */ + from?: Maybe; + /** The end of the date range */ + to?: Maybe; +}; + export enum DayOfTheWeek { FRIDAY = 'FRIDAY', MONDAY = 'MONDAY', @@ -296,10 +318,14 @@ export type DeploymentFrequencyMetric = { columns: Array; /** The amount of deployments for the current period */ currentAmount: Scalars['BigInt']['output']; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; /** The amounts over time for the chart */ data: Array; /** The number of deployments before the current period */ previousAmount: Scalars['BigInt']['output']; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; }; export type DeploymentSettings = { @@ -488,18 +514,42 @@ export enum IntegrationApp { SLACK = 'SLACK' } +export type LeadTimeBreakdown = { + __typename?: 'LeadTimeBreakdown'; + /** Time spent coding (first commit to PR creation) */ + codingTime: BreakdownStage; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; + /** Time from first review to approval */ + timeToApprove: BreakdownStage; + /** Time from merge to deploy */ + timeToDeploy: BreakdownStage; + /** Time waiting for first review */ + timeToFirstReview: BreakdownStage; + /** Time from approval to merge */ + timeToMerge: BreakdownStage; +}; + export type LeadTimeMetric = { __typename?: 'LeadTimeMetric'; + /** Breakdown of lead time by development stages */ + breakdown: LeadTimeBreakdown; /** The change in lead time */ change: Scalars['Float']['output']; /** The columns for the chart */ columns: Array; /** The lead time in milliseconds for the current period */ currentAmount: Scalars['BigInt']['output']; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; /** The amounts over time for the chart */ data: Array; /** The lead time in milliseconds before the current period */ previousAmount: Scalars['BigInt']['output']; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; }; export type LoginToStripeInput = { @@ -525,10 +575,14 @@ export type MeanTimeToRecoverMetric = { columns: Array; /** The mean time to recover in milliseconds for the current period */ currentAmount: Scalars['BigInt']['output']; + /** The date range for the current period */ + currentPeriod: DateTimeRangeValue; /** The amounts over time for the chart */ data: Array; /** The mean time to recover in milliseconds before the current period */ previousAmount: Scalars['BigInt']['output']; + /** The date range for the previous period */ + previousPeriod: DateTimeRangeValue; }; export type Metrics = { @@ -1562,7 +1616,7 @@ export type DoraMetricsQueryVariables = Exact<{ }>; -export type DoraMetricsQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', metrics: { __typename?: 'Metrics', dora: { __typename?: 'DoraMetrics', deploymentFrequency: { __typename?: 'DeploymentFrequencyMetric', currentAmount: number, previousAmount: number, change: number, columns: Array, data: Array, avg: number }, leadTime: { __typename?: 'LeadTimeMetric', currentAmount: number, previousAmount: number, change: number, columns: Array, data: Array }, changeFailureRate: { __typename?: 'ChangeFailureRateMetric', currentAmount: number, previousAmount: number, change: number, columns: Array, data: Array }, meanTimeToRecover: { __typename?: 'MeanTimeToRecoverMetric', currentAmount: number, previousAmount: number, change: number, columns: Array, data: Array } } } } }; +export type DoraMetricsQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', metrics: { __typename?: 'Metrics', dora: { __typename?: 'DoraMetrics', deploymentFrequency: { __typename?: 'DeploymentFrequencyMetric', currentAmount: number, previousAmount: number, change: number, columns: Array, data: Array, avg: number, currentPeriod: { __typename?: 'DateTimeRangeValue', from?: string | null, to?: string | null }, previousPeriod: { __typename?: 'DateTimeRangeValue', from?: string | null, to?: string | null } }, leadTime: { __typename?: 'LeadTimeMetric', currentAmount: number, previousAmount: number, change: number, columns: Array, data: Array, currentPeriod: { __typename?: 'DateTimeRangeValue', from?: string | null, to?: string | null }, previousPeriod: { __typename?: 'DateTimeRangeValue', from?: string | null, to?: string | null }, breakdown: { __typename?: 'LeadTimeBreakdown', codingTime: { __typename?: 'BreakdownStage', currentAmount: number, previousAmount: number, change: number }, timeToFirstReview: { __typename?: 'BreakdownStage', currentAmount: number, previousAmount: number, change: number }, timeToApprove: { __typename?: 'BreakdownStage', currentAmount: number, previousAmount: number, change: number }, timeToMerge: { __typename?: 'BreakdownStage', currentAmount: number, previousAmount: number, change: number }, timeToDeploy: { __typename?: 'BreakdownStage', currentAmount: number, previousAmount: number, change: number } } }, changeFailureRate: { __typename?: 'ChangeFailureRateMetric', currentAmount: number, previousAmount: number, change: number, columns: Array, data: Array, currentPeriod: { __typename?: 'DateTimeRangeValue', from?: string | null, to?: string | null }, previousPeriod: { __typename?: 'DateTimeRangeValue', from?: string | null, to?: string | null } }, meanTimeToRecover: { __typename?: 'MeanTimeToRecoverMetric', currentAmount: number, previousAmount: number, change: number, columns: Array, data: Array, currentPeriod: { __typename?: 'DateTimeRangeValue', from?: string | null, to?: string | null }, previousPeriod: { __typename?: 'DateTimeRangeValue', from?: string | null, to?: string | null } } } } } }; export type EnvironmentOptionsQueryVariables = Exact<{ workspaceId: Scalars['SweetID']['input']; @@ -1902,7 +1956,7 @@ export const UnarchiveDeploymentDocument = {"kind":"Document","definitions":[{"k export const TeamDigestsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TeamDigests"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SweetID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SweetID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"digests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const TeamDigestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TeamDigest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SweetID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SweetID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DigestQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"digest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"channel"}},{"kind":"Field","name":{"kind":"Name","value":"frequency"}},{"kind":"Field","name":{"kind":"Name","value":"dayOfTheWeek"}},{"kind":"Field","name":{"kind":"Name","value":"timeOfDay"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateDigestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDigest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateDigestInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDigest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}}]}}]}}]} as unknown as DocumentNode; -export const DoraMetricsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DoraMetrics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SweetID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMetricInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"metrics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dora"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deploymentFrequency"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}},{"kind":"Field","name":{"kind":"Name","value":"columns"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"avg"}}]}},{"kind":"Field","name":{"kind":"Name","value":"leadTime"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}},{"kind":"Field","name":{"kind":"Name","value":"columns"}},{"kind":"Field","name":{"kind":"Name","value":"data"}}]}},{"kind":"Field","name":{"kind":"Name","value":"changeFailureRate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}},{"kind":"Field","name":{"kind":"Name","value":"columns"}},{"kind":"Field","name":{"kind":"Name","value":"data"}}]}},{"kind":"Field","name":{"kind":"Name","value":"meanTimeToRecover"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}},{"kind":"Field","name":{"kind":"Name","value":"columns"}},{"kind":"Field","name":{"kind":"Name","value":"data"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const DoraMetricsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DoraMetrics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SweetID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceMetricInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"metrics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dora"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deploymentFrequency"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}},{"kind":"Field","name":{"kind":"Name","value":"columns"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"avg"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"from"}},{"kind":"Field","name":{"kind":"Name","value":"to"}}]}},{"kind":"Field","name":{"kind":"Name","value":"previousPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"from"}},{"kind":"Field","name":{"kind":"Name","value":"to"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"leadTime"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}},{"kind":"Field","name":{"kind":"Name","value":"columns"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"from"}},{"kind":"Field","name":{"kind":"Name","value":"to"}}]}},{"kind":"Field","name":{"kind":"Name","value":"previousPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"from"}},{"kind":"Field","name":{"kind":"Name","value":"to"}}]}},{"kind":"Field","name":{"kind":"Name","value":"breakdown"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"codingTime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}}]}},{"kind":"Field","name":{"kind":"Name","value":"timeToFirstReview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}}]}},{"kind":"Field","name":{"kind":"Name","value":"timeToApprove"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}}]}},{"kind":"Field","name":{"kind":"Name","value":"timeToMerge"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}}]}},{"kind":"Field","name":{"kind":"Name","value":"timeToDeploy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"changeFailureRate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}},{"kind":"Field","name":{"kind":"Name","value":"columns"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"from"}},{"kind":"Field","name":{"kind":"Name","value":"to"}}]}},{"kind":"Field","name":{"kind":"Name","value":"previousPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"from"}},{"kind":"Field","name":{"kind":"Name","value":"to"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"meanTimeToRecover"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"previousAmount"}},{"kind":"Field","name":{"kind":"Name","value":"change"}},{"kind":"Field","name":{"kind":"Name","value":"columns"}},{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"from"}},{"kind":"Field","name":{"kind":"Name","value":"to"}}]}},{"kind":"Field","name":{"kind":"Name","value":"previousPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"from"}},{"kind":"Field","name":{"kind":"Name","value":"to"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const EnvironmentOptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnvironmentOptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SweetID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentsQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isProduction"}}]}}]}}]}}]} as unknown as DocumentNode; export const EnvironmentsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Environments"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SweetID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentsQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isProduction"}},{"kind":"Field","name":{"kind":"Name","value":"archivedAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const ArchiveEnvironmentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveEnvironment"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ArchiveEnvironmentInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveEnvironment"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isProduction"}},{"kind":"Field","name":{"kind":"Name","value":"archivedAt"}}]}}]}}]} as unknown as DocumentNode;