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}`,