Commit 14d1248a authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Scott Hampton

Add MTTM to MR analytics

Introduce a single stat container which will
house all of the planned stats for the MR
analytics feature.

Add the MTTM stat in the first iteration.
parent a6080c29
...@@ -6,7 +6,8 @@ import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleto ...@@ -6,7 +6,8 @@ import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleto
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import throughputChartQueryBuilder from '../graphql/throughput_chart_query_builder'; import throughputChartQueryBuilder from '../graphql/throughput_chart_query_builder';
import { THROUGHPUT_CHART_STRINGS } from '../constants'; import { THROUGHPUT_CHART_STRINGS } from '../constants';
import { formatThroughputChartData } from '../utils'; import { formatThroughputChartData, computeMttmData } from '../utils';
import ThroughputStats from './throughput_stats.vue';
export default { export default {
name: 'ThroughputChart', name: 'ThroughputChart',
...@@ -14,6 +15,7 @@ export default { ...@@ -14,6 +15,7 @@ export default {
GlAreaChart, GlAreaChart,
GlAlert, GlAlert,
ChartSkeletonLoader, ChartSkeletonLoader,
ThroughputStats,
}, },
inject: ['fullPath'], inject: ['fullPath'],
props: { props: {
...@@ -88,7 +90,7 @@ export default { ...@@ -88,7 +90,7 @@ export default {
formattedThroughputChartData() { formattedThroughputChartData() {
return formatThroughputChartData(this.throughputChartData); return formatThroughputChartData(this.throughputChartData);
}, },
chartDataLoading() { isLoading() {
return !this.hasError && this.$apollo.queries.throughputChartData.loading; return !this.hasError && this.$apollo.queries.throughputChartData.loading;
}, },
chartDataAvailable() { chartDataAvailable() {
...@@ -102,6 +104,9 @@ export default { ...@@ -102,6 +104,9 @@ export default {
: THROUGHPUT_CHART_STRINGS.NO_DATA, : THROUGHPUT_CHART_STRINGS.NO_DATA,
}; };
}, },
singleStatsValues() {
return [computeMttmData(this.throughputChartData)];
},
}, },
strings: { strings: {
chartTitle: THROUGHPUT_CHART_STRINGS.CHART_TITLE, chartTitle: THROUGHPUT_CHART_STRINGS.CHART_TITLE,
...@@ -111,11 +116,12 @@ export default { ...@@ -111,11 +116,12 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<throughput-stats :stats="singleStatsValues" :is-loading="isLoading" />
<h4 data-testid="chartTitle">{{ $options.strings.chartTitle }}</h4> <h4 data-testid="chartTitle">{{ $options.strings.chartTitle }}</h4>
<div class="gl-text-gray-500" data-testid="chartDescription"> <div class="gl-text-gray-500" data-testid="chartDescription">
{{ $options.strings.chartDescription }} {{ $options.strings.chartDescription }}
</div> </div>
<chart-skeleton-loader v-if="chartDataLoading" /> <chart-skeleton-loader v-if="isLoading" />
<gl-area-chart <gl-area-chart
v-else-if="chartDataAvailable" v-else-if="chartDataAvailable"
:data="formattedThroughputChartData" :data="formattedThroughputChartData"
......
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { STAT_LOADER_HEIGHT } from '../constants';
export default {
name: 'ThroughputStats',
components: {
GlSingleStat,
GlSkeletonLoader,
},
props: {
stats: {
type: Array,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
loaderHeight: STAT_LOADER_HEIGHT,
};
</script>
<template>
<div class="gl-my-7 gl-display-flex">
<div v-for="stat in stats" :key="stat.title">
<gl-skeleton-loader v-if="isLoading" :height="$options.loaderHeight" />
<gl-single-stat v-else :value="stat.value" :title="stat.title" :unit="stat.unit" />
</div>
</div>
</template>
import { __ } from '~/locale'; import { __ } from '~/locale';
export const DEFAULT_NUMBER_OF_DAYS = 365; export const DEFAULT_NUMBER_OF_DAYS = 365;
export const STAT_LOADER_HEIGHT = 46;
export const PER_PAGE = 20; export const PER_PAGE = 20;
export const ASSIGNEES_VISIBLE = 2; export const ASSIGNEES_VISIBLE = 2;
export const AVATAR_SIZE = 24; export const AVATAR_SIZE = 24;
...@@ -14,6 +15,7 @@ export const THROUGHPUT_CHART_STRINGS = { ...@@ -14,6 +15,7 @@ export const THROUGHPUT_CHART_STRINGS = {
ERROR_FETCHING_DATA: __( ERROR_FETCHING_DATA: __(
'There was an error while fetching the chart data. Please refresh the page to try again.', 'There was an error while fetching the chart data. Please refresh the page to try again.',
), ),
MTTM: __('Mean time to merge'),
}; };
export const THROUGHPUT_TABLE_STRINGS = { export const THROUGHPUT_TABLE_STRINGS = {
...@@ -51,3 +53,7 @@ export const PIPELINE_STATUS_ICON_CLASSES = { ...@@ -51,3 +53,7 @@ export const PIPELINE_STATUS_ICON_CLASSES = {
status_pending: 'gl-text-orange-500', status_pending: 'gl-text-orange-500',
default: 'gl-text-grey-500', default: 'gl-text-grey-500',
}; };
export const UNITS = {
DAYS: __('days'),
};
...@@ -32,7 +32,7 @@ export default (startDate = null, endDate = null) => { ...@@ -32,7 +32,7 @@ export default (startDate = null, endDate = null) => {
milestoneTitle: $milestoneTitle, milestoneTitle: $milestoneTitle,
sourceBranches: $sourceBranches, sourceBranches: $sourceBranches,
targetBranches: $targetBranches targetBranches: $targetBranches
) { count } ) { count, totalTimeToMerge }
`; `;
}); });
......
...@@ -4,9 +4,10 @@ import { ...@@ -4,9 +4,10 @@ import {
dateFromParams, dateFromParams,
getDateInPast, getDateInPast,
getDayDifference, getDayDifference,
secondsToDays,
} from '~/lib/utils/datetime_utility'; } from '~/lib/utils/datetime_utility';
import { dateFormats } from '../shared/constants'; import { dateFormats } from '../shared/constants';
import { THROUGHPUT_CHART_STRINGS, DEFAULT_NUMBER_OF_DAYS } from './constants'; import { THROUGHPUT_CHART_STRINGS, DEFAULT_NUMBER_OF_DAYS, UNITS } from './constants';
/** /**
* A utility function which accepts a date range and returns * A utility function which accepts a date range and returns
...@@ -73,6 +74,42 @@ export const formatThroughputChartData = (chartData) => { ...@@ -73,6 +74,42 @@ export const formatThroughputChartData = (chartData) => {
]; ];
}; };
/**
* A utility function which accepts the raw throughput data
* and computes the mean time to merge.
*
* @param {Object} rawData the raw throughput data
*
* @return {Object} the computed MTTM data
*/
export const computeMttmData = (rawData) => {
if (!rawData) return {};
const mttmData = Object.values(rawData)
// eslint-disable-next-line @gitlab/require-i18n-strings
.filter((value) => value !== 'Project')
.reduce(
(total, monthData) => {
return {
count: total.count + monthData.count,
totalTimeToMerge: total.totalTimeToMerge + monthData.totalTimeToMerge,
};
},
{
count: 0,
totalTimeToMerge: 0,
},
);
// GlSingleStat expects a String for the 'value' prop
// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1152
return {
title: THROUGHPUT_CHART_STRINGS.MTTM,
value: `${secondsToDays(mttmData.totalTimeToMerge / mttmData.count)}`,
unit: UNITS.DAYS,
};
};
/** /**
* A utility function which accepts start and end date params * A utility function which accepts start and end date params
* and validates that the date range does not exceed the bounds * and validates that the date range does not exceed the bounds
......
---
title: Add MTTM stat to MR Analytics
merge_request: 52316
author:
type: added
...@@ -3,6 +3,7 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts'; ...@@ -3,6 +3,7 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import ThroughputChart from 'ee/analytics/merge_request_analytics/components/throughput_chart.vue'; import ThroughputChart from 'ee/analytics/merge_request_analytics/components/throughput_chart.vue';
import ThroughputStats from 'ee/analytics/merge_request_analytics/components/throughput_stats.vue';
import { THROUGHPUT_CHART_STRINGS } from 'ee/analytics/merge_request_analytics/constants'; import { THROUGHPUT_CHART_STRINGS } from 'ee/analytics/merge_request_analytics/constants';
import store from 'ee/analytics/merge_request_analytics/store'; import store from 'ee/analytics/merge_request_analytics/store';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
...@@ -67,6 +68,10 @@ describe('ThroughputChart', () => { ...@@ -67,6 +68,10 @@ describe('ThroughputChart', () => {
wrapper = createComponent(); wrapper = createComponent();
}); });
it('displays the throughput stats component', () => {
expect(wrapper.find(ThroughputStats).exists()).toBe(true);
});
it('displays the chart title', () => { it('displays the chart title', () => {
const chartTitle = wrapper.find('[data-testid="chartTitle"').text(); const chartTitle = wrapper.find('[data-testid="chartTitle"').text();
......
import { shallowMount } from '@vue/test-utils';
import { GlSkeletonLoader } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import ThroughputStats from 'ee/analytics/merge_request_analytics/components/throughput_stats';
import { stats } from '../mock_data';
describe('ThroughputStats', () => {
let wrapper;
const createWrapper = (props) => {
return shallowMount(ThroughputStats, {
propsData: {
stats,
...props,
},
});
};
describe('default behaviour', () => {
beforeEach(() => {
wrapper = createWrapper();
});
it('displays a GlSingleStat component for each stat entry', () => {
expect(wrapper.findAll(GlSingleStat)).toHaveLength(stats.length);
});
it('passes the GlSingleStat the correct props', () => {
const component = wrapper.findAll(GlSingleStat).at(0);
const { title, unit, value } = stats[0];
expect(component.props('title')).toBe(title);
expect(component.props('unit')).toBe(unit);
expect(component.props('value')).toBe(value);
});
it('does not display any GlSkeletonLoader components', () => {
expect(wrapper.findAll(GlSkeletonLoader)).toHaveLength(0);
});
});
describe('loading', () => {
beforeEach(() => {
wrapper = createWrapper({ isLoading: true });
});
it('displays a GlSkeletonLoader component for each stat entry', () => {
expect(wrapper.findAll(GlSkeletonLoader)).toHaveLength(stats.length);
});
it('does not display any GlSingleStat components', () => {
expect(wrapper.findAll(GlSingleStat)).toHaveLength(0);
});
});
});
...@@ -8,9 +8,9 @@ export const fullPath = 'gitlab-org/gitlab'; ...@@ -8,9 +8,9 @@ export const fullPath = 'gitlab-org/gitlab';
// We should update our tests to use fixtures instead of hardcoded mock data. // We should update our tests to use fixtures instead of hardcoded mock data.
// https://gitlab.com/gitlab-org/gitlab/-/issues/270544 // https://gitlab.com/gitlab-org/gitlab/-/issues/270544
export const throughputChartData = { export const throughputChartData = {
May_2020: { count: 2, __typename: 'MergeRequestConnection' }, May_2020: { count: 2, totalTimeToMerge: 1234567, __typename: 'MergeRequestConnection' },
Jun_2020: { count: 4, __typename: 'MergeRequestConnection' }, Jun_2020: { count: 4, totalTimeToMerge: 2345678, __typename: 'MergeRequestConnection' },
Jul_2020: { count: 3, __typename: 'MergeRequestConnection' }, Jul_2020: { count: 3, totalTimeToMerge: 3456789, __typename: 'MergeRequestConnection' },
__typename: 'Project', __typename: 'Project',
}; };
...@@ -32,6 +32,12 @@ export const formattedThroughputChartData = [ ...@@ -32,6 +32,12 @@ export const formattedThroughputChartData = [
}, },
]; ];
export const formattedMttmData = {
title: 'Mean time to merge',
value: '9',
unit: 'days',
};
export const expectedMonthData = [ export const expectedMonthData = [
{ {
year: 2020, year: 2020,
...@@ -67,6 +73,7 @@ export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!], ...@@ -67,6 +73,7 @@ export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!],
targetBranches: $targetBranches targetBranches: $targetBranches
) { ) {
count count
totalTimeToMerge
} }
Jun_2020: mergeRequests( Jun_2020: mergeRequests(
first: 0 first: 0
...@@ -80,6 +87,7 @@ export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!], ...@@ -80,6 +87,7 @@ export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!],
targetBranches: $targetBranches targetBranches: $targetBranches
) { ) {
count count
totalTimeToMerge
} }
Jul_2020: mergeRequests( Jul_2020: mergeRequests(
first: 0 first: 0
...@@ -93,6 +101,7 @@ export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!], ...@@ -93,6 +101,7 @@ export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!],
targetBranches: $targetBranches targetBranches: $targetBranches
) { ) {
count count
totalTimeToMerge
} }
} }
} }
...@@ -148,3 +157,16 @@ export const throughputTableData = [ ...@@ -148,3 +157,16 @@ export const throughputTableData = [
}, },
}, },
]; ];
export const stats = [
{
title: 'Mean time to merge',
unit: 'days',
value: '10',
},
{
title: 'MRs per engineer',
unit: 'MRs per engineer (per month)',
value: '23',
},
];
import * as utils from 'ee/analytics/merge_request_analytics/utils'; import * as utils from 'ee/analytics/merge_request_analytics/utils';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import { expectedMonthData, throughputChartData, formattedThroughputChartData } from './mock_data'; import {
expectedMonthData,
throughputChartData,
formattedThroughputChartData,
formattedMttmData,
} from './mock_data';
describe('computeMonthRangeData', () => { describe('computeMonthRangeData', () => {
const start = new Date('2020-05-17T00:00:00.000Z'); const start = new Date('2020-05-17T00:00:00.000Z');
...@@ -33,6 +38,14 @@ describe('formatThroughputChartData', () => { ...@@ -33,6 +38,14 @@ describe('formatThroughputChartData', () => {
}); });
}); });
describe('computeMttmData', () => {
it('returns the data as expected', () => {
const mttmData = utils.computeMttmData(throughputChartData);
expect(mttmData).toStrictEqual(formattedMttmData);
});
});
describe('parseAndValidateDates', () => { describe('parseAndValidateDates', () => {
useFakeDate('2021-01-21'); useFakeDate('2021-01-21');
......
...@@ -17556,6 +17556,9 @@ msgstr "" ...@@ -17556,6 +17556,9 @@ msgstr ""
msgid "May" msgid "May"
msgstr "" msgstr ""
msgid "Mean time to merge"
msgstr ""
msgid "Measured in bytes of code. Excludes generated and vendored code." msgid "Measured in bytes of code. Excludes generated and vendored code."
msgstr "" msgstr ""
...@@ -33587,6 +33590,9 @@ msgid_plural "days" ...@@ -33587,6 +33590,9 @@ msgid_plural "days"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "days"
msgstr ""
msgid "default branch" msgid "default branch"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment