diff --git a/.changeset/weak-buttons-play.md b/.changeset/weak-buttons-play.md new file mode 100644 index 0000000000..491b510f77 --- /dev/null +++ b/.changeset/weak-buttons-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-gocd': patch +--- + +Add DORA metrics insights to GoCD builds page diff --git a/plugins/gocd/docs/gocd-plugin-screenshot.png b/plugins/gocd/docs/gocd-plugin-screenshot.png index 8ddc786fa5..d1e2d28c4e 100644 Binary files a/plugins/gocd/docs/gocd-plugin-screenshot.png and b/plugins/gocd/docs/gocd-plugin-screenshot.png differ diff --git a/plugins/gocd/src/api/gocdApi.model.ts b/plugins/gocd/src/api/gocdApi.model.ts index 8bb9a481e6..d76a6fdc19 100644 --- a/plugins/gocd/src/api/gocdApi.model.ts +++ b/plugins/gocd/src/api/gocdApi.model.ts @@ -101,6 +101,23 @@ export enum GoCdBuildResultStatus { pending, } +export const toBuildResultStatus = (status: string): GoCdBuildResultStatus => { + switch (status.toLocaleLowerCase('en-US')) { + case 'passed': + return GoCdBuildResultStatus.successful; + case 'failed': + return GoCdBuildResultStatus.error; + case 'aborted': + return GoCdBuildResultStatus.aborted; + case 'building': + return GoCdBuildResultStatus.running; + case 'pending': + return GoCdBuildResultStatus.pending; + default: + return GoCdBuildResultStatus.aborted; + } +}; + export interface GoCdBuildStageResult { status: GoCdBuildResultStatus; text: string; diff --git a/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx b/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx index eb8b311896..abaaad8506 100644 --- a/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx +++ b/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx @@ -27,6 +27,7 @@ import { useApi, configApiRef } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; import { gocdApiRef } from '../../plugin'; import { GoCdBuildsTable } from '../GoCdBuildsTable/GoCdBuildsTable'; +import { GoCdBuildsInsights } from '../GoCdBuildsInsights/GoCdBuildsInsights'; import { GoCdApiError, PipelineHistory } from '../../api/gocdApi.model'; import { Item, Select } from '../Select'; @@ -120,6 +121,11 @@ export const GoCdBuildsComponent = (): JSX.Element => { items={getSelectionItems(rawPipelines)} /> + ', () => { + it('renders without exploding', () => { + const { getByText } = render(); + expect(getByText('Run Frequency')).toBeInTheDocument(); + expect(getByText('Mean Time to Recovery')).toBeInTheDocument(); + expect(getByText('Mean Time Between Failures')).toBeInTheDocument(); + expect(getByText('Failure Rate')).toBeInTheDocument(); + }); + + it('renders mean time to recovery', () => { + const { getByText } = render(); + expect(getByText('-1d -3h -21m -40s')).toBeInTheDocument(); + }); + + it('renders mean time between failures', () => { + const { getByText } = render(); + expect(getByText('2d 8h 23m 20s')).toBeInTheDocument(); + }); + + it('renders failure rate', () => { + const { getByText } = render(); + expect(getByText('11.11%')).toBeInTheDocument(); + expect(getByText('(2 out of 18 jobs)')).toBeInTheDocument(); + }); + + it('renders nothing when loading', () => { + const props = { + loading: true, + pipelineHistory: undefined, + error: undefined, + }; + const { getByTestId } = render(); + + expect(() => getByTestId('GoCdBuildsInsightsBox')).toThrow(); + }); +}); diff --git a/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx b/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx new file mode 100644 index 0000000000..20336fe6ac --- /dev/null +++ b/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx @@ -0,0 +1,255 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { groupBy, mean } from 'lodash'; +import { + PipelineHistory, + Job, + toBuildResultStatus, + GoCdBuildResultStatus, +} from '../../api/gocdApi.model'; +import { + Box, + Grid, + Card, + CardContent, + Tooltip, + Typography, +} from '@material-ui/core'; +import { DateTime, Duration } from 'luxon'; + +export type GoCdBuildsInsightsProps = { + pipelineHistory: PipelineHistory | undefined; + loading: boolean; + error: Error | undefined; +}; + +function runFrequency(pipelineHistory: PipelineHistory): string { + const lastMonth = DateTime.now().minus({ month: 1 }); + const buildLastMonth = pipelineHistory.pipelines + .map(p => p.scheduled_date) + .filter((d): d is number => !!d) + .map(d => DateTime.fromMillis(d)) + .filter(d => d > lastMonth).length; + return `${buildLastMonth} last month`; +} + +function meanTimeBetweenFailures(jobs: Job[]): string { + const timeBetweenFailures: Duration[] = []; + for (let index = 1; index < jobs.length; index++) { + const job = jobs[index]; + if (!job.result) { + continue; + } + if (toBuildResultStatus(job.result) === GoCdBuildResultStatus.error) { + let previousFailedJob: Job | null = null; + for (let j = index - 1; j >= 0; j--) { + const candidateJob = jobs[j]; + if (!candidateJob.result) { + continue; + } + if ( + toBuildResultStatus(candidateJob.result) === + GoCdBuildResultStatus.error + ) { + previousFailedJob = candidateJob; + break; + } + } + if ( + !previousFailedJob || + !job.scheduled_date || + !previousFailedJob.scheduled_date + ) { + continue; + } + const failedJobDate = DateTime.fromMillis(job.scheduled_date); + const previousFailedJobDate = DateTime.fromMillis( + previousFailedJob.scheduled_date, + ); + const timeBetweenFailure = failedJobDate.diff(previousFailedJobDate); + timeBetweenFailures.push(timeBetweenFailure); + } + } + return formatMean(timeBetweenFailures); +} + +/** + * Imagine a sequence like: + * S - S - S - F - S - F - F - S - S + * + * We iterate until finding a failure, and then iterate forward + * until we find the first immediate success to then + * calculate the time difference between the scheduling of the jobs. + */ +function meanTimeToRecovery(jobs: Job[]): string { + const timeToRecoverIntervals: Duration[] = []; + for (let index = 0; index < jobs.length; index++) { + const job = jobs[index]; + if (!job.result) { + continue; + } + if (toBuildResultStatus(job.result) === GoCdBuildResultStatus.error) { + let nextSuccessfulJob: Job | null = null; + for (let j = index + 1; j < jobs.length; j++) { + const candidateJob = jobs[j]; + if (!candidateJob.result) { + continue; + } + if ( + toBuildResultStatus(candidateJob.result) === + GoCdBuildResultStatus.successful + ) { + nextSuccessfulJob = candidateJob; + break; + } + } + if ( + !nextSuccessfulJob || + !job.scheduled_date || + !nextSuccessfulJob.scheduled_date + ) { + continue; + } + const failedJobDate = DateTime.fromMillis(job.scheduled_date); + const successfulJobDate = DateTime.fromMillis( + nextSuccessfulJob.scheduled_date, + ); + const timeToRecovery = successfulJobDate.diff(failedJobDate); + timeToRecoverIntervals.push(timeToRecovery); + } + } + + return formatMean(timeToRecoverIntervals); +} + +function formatMean(durations: Duration[]): string { + if (durations.length === 0) { + return 'N/A'; + } + + const mttr: Duration = Duration.fromMillis( + mean(durations.map(i => i.milliseconds)), + ); + return mttr.toFormat("d'd' h'h' m'm' s's'"); +} + +function failureRate(jobs: Job[]): { + title: string; + subtitle: string; +} { + const resultGroups = new Map( + Object.entries(groupBy(jobs, 'result')).map(([key, value]) => [ + toBuildResultStatus(key), + value.flat(), + ]), + ); + const failedJobs = resultGroups.get(GoCdBuildResultStatus.error); + if (!failedJobs) { + return { + title: '0', + subtitle: '(no failed jobs found)', + }; + } + + resultGroups.delete(GoCdBuildResultStatus.error); + const nonFailedJobs = Array.from(resultGroups.values()).flat(); + + const totalJobs = failedJobs.length + nonFailedJobs.length; + const percentage = (failedJobs.length / totalJobs) * 100; + const decimalPercentage = (Math.round(percentage * 100) / 100).toFixed(2); + return { + title: `${decimalPercentage}%`, + subtitle: `(${failedJobs.length} out of ${totalJobs} jobs)`, + }; +} + +export const GoCdBuildsInsights = ( + props: GoCdBuildsInsightsProps, +): JSX.Element => { + const { pipelineHistory, loading, error } = props; + + if (loading || error || !pipelineHistory) { + return <>; + } + + // We reverse the array to calculate insights to make sure jobs are ordered + // by their schedule date. + const stages = pipelineHistory.pipelines + .slice() + .reverse() + .map(p => p.stages) + .flat(); + const jobs = stages.map(s => s.jobs).flat(); + + const failureRateObj: { title: string; subtitle: string } = failureRate(jobs); + + return ( + + + + + + + Run Frequency + + {runFrequency(pipelineHistory)} + + + + + + + + + + Mean Time to Recovery + {meanTimeToRecovery(jobs)} + + + + + + + + + + Mean Time Between Failures + + + {meanTimeBetweenFailures(jobs)} + + + + + + + + + + Failure Rate + {failureRateObj.title} + + {failureRateObj.subtitle} + + + + + + + + ); +}; diff --git a/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx b/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx index 58dfbcca39..498cdc26bf 100644 --- a/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx +++ b/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx @@ -22,6 +22,7 @@ import GitHubIcon from '@material-ui/icons/GitHub'; import { GoCdBuildResult, GoCdBuildResultStatus, + toBuildResultStatus, PipelineHistory, Pipeline, } from '../../api/gocdApi.model'; @@ -157,23 +158,6 @@ const renderError = (error: Error): JSX.Element => { return {error.message}; }; -const toStageStatus = (status: string): GoCdBuildResultStatus => { - switch (status.toLocaleLowerCase('en-US')) { - case 'passed': - return GoCdBuildResultStatus.successful; - case 'failed': - return GoCdBuildResultStatus.error; - case 'aborted': - return GoCdBuildResultStatus.aborted; - case 'building': - return GoCdBuildResultStatus.running; - case 'pending': - return GoCdBuildResultStatus.pending; - default: - return GoCdBuildResultStatus.aborted; - } -}; - const toBuildResults = ( entity: Entity, builds: Pipeline[], @@ -185,7 +169,7 @@ const toBuildResults = ( id: build.counter, source: `${entitySourceLocation}/commit/${build.build_cause?.material_revisions[0]?.modifications[0].revision}`, stages: build.stages.map(s => ({ - status: toStageStatus(s.status), + status: toBuildResultStatus(s.status), text: s.name, })), buildSlug: `${build.name}/${build.counter}`,