From c5bd6be684268917377b235373b8db80c2223c0d Mon Sep 17 00:00:00 2001 From: Erik Engervall Date: Sun, 25 Apr 2021 17:59:01 +0200 Subject: [PATCH] Create additional stats for Stats component Add button for fetching and calculating average release times Signed-off-by: Erik Engervall --- plugins/github-release-manager/package.json | 2 + .../src/api/PluginApiClient.ts | 1 - .../src/features/Stats/DialogBody.tsx | 47 +++--- .../Stats/Info/InDepth/AverageReleaseTime.tsx | 49 ++++++ .../features/Stats/Info/InDepth/InDepth.tsx | 132 ++++++++++++++++ .../Stats/Info/InDepth/LongestReleaseTime.tsx | 43 ++++++ .../src/features/Stats/Info/Info.tsx | 41 +++++ .../src/features/Stats/Info/Summary.tsx | 108 +++++++++++++ .../helpers/getReleaseCommitPairs.test.tsx | 109 +++++++++++++ .../Info/helpers/getReleaseCommitPairs.tsx | 65 ++++++++ .../Stats/Info/hooks/useGetReleaseTimes.tsx | 127 +++++++++++++++ .../src/features/Stats/Row/Row.tsx | 28 ++-- .../Stats/Row/RowCollapsed/ReleaseTagList.tsx | 16 +- .../Stats/Row/RowCollapsed/ReleaseTime.tsx | 39 ++--- .../Stats/Row/RowCollapsed/RowCollapsed.tsx | 14 +- .../src/features/Stats/Summary.tsx | 89 ----------- .../src/features/Stats/Warn.tsx | 36 ++--- .../Stats/contexts/ReleaseStatsContext.tsx | 64 ++++++++ .../Stats/helpers/getDecimalNumber.test.tsx | 43 ++++++ .../Stats/helpers/getDecimalNumber.tsx | 4 +- .../Stats/helpers/getMappedReleases.test.tsx | 71 +++++++++ ...{mapReleases.tsx => getMappedReleases.tsx} | 51 +++--- .../Stats/helpers/getReleaseStats.test.tsx | 124 +++++++++++++++ .../Stats/helpers/getReleaseStats.tsx | 81 ++++++++++ .../Stats/helpers/getReleasesWithTags.tsx | 101 ------------ .../Stats/helpers/getSummary.test.tsx | 34 ++++ .../src/features/Stats/helpers/getSummary.tsx | 55 +++---- .../src/features/Stats/hooks/useGetCommit.ts | 15 +- .../src/test-helpers/stats.ts | 70 +++++++++ .../src/test-helpers/test-helpers.test.ts | 146 ------------------ .../src/test-helpers/test-helpers.ts | 27 ++-- .../src/test-helpers/test-ids.test.ts | 88 ----------- 32 files changed, 1319 insertions(+), 601 deletions(-) create mode 100644 plugins/github-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/Info/Info.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/Info/Summary.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx delete mode 100644 plugins/github-release-manager/src/features/Stats/Summary.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx rename plugins/github-release-manager/src/features/Stats/helpers/{mapReleases.tsx => getMappedReleases.tsx} (68%) create mode 100644 plugins/github-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/helpers/getReleaseStats.tsx delete mode 100644 plugins/github-release-manager/src/features/Stats/helpers/getReleasesWithTags.tsx create mode 100644 plugins/github-release-manager/src/features/Stats/helpers/getSummary.test.tsx create mode 100644 plugins/github-release-manager/src/test-helpers/stats.ts delete mode 100644 plugins/github-release-manager/src/test-helpers/test-helpers.test.ts delete mode 100644 plugins/github-release-manager/src/test-helpers/test-ids.test.ts diff --git a/plugins/github-release-manager/package.json b/plugins/github-release-manager/package.json index c73941ec54..be544bc1fb 100644 --- a/plugins/github-release-manager/package.json +++ b/plugins/github-release-manager/package.json @@ -23,6 +23,7 @@ "@backstage/core": "^0.7.5", "@backstage/integration": "^0.5.1", "@backstage/theme": "^0.2.5", + "recharts": "^1.8.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -39,6 +40,7 @@ "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.10", "@testing-library/jest-dom": "^5.10.1", + "@types/recharts": "^1.8.15", "@testing-library/react-hooks": "^3.4.2", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-release-manager/src/api/PluginApiClient.ts b/plugins/github-release-manager/src/api/PluginApiClient.ts index 033e074bcf..52764c6613 100644 --- a/plugins/github-release-manager/src/api/PluginApiClient.ts +++ b/plugins/github-release-manager/src/api/PluginApiClient.ts @@ -591,7 +591,6 @@ ${selectedPatchCommit.commit.message}`, }); return releases.map(release => ({ - release, id: release.id, name: release.name, tagName: release.tag_name, diff --git a/plugins/github-release-manager/src/features/Stats/DialogBody.tsx b/plugins/github-release-manager/src/features/Stats/DialogBody.tsx index 4248cdc1e8..8de40d6ac9 100644 --- a/plugins/github-release-manager/src/features/Stats/DialogBody.tsx +++ b/plugins/github-release-manager/src/features/Stats/DialogBody.tsx @@ -19,7 +19,6 @@ import { Alert } from '@material-ui/lab'; import { Box, makeStyles, - Paper, Table, TableBody, TableCell, @@ -29,11 +28,11 @@ import { } from '@material-ui/core'; import { CenteredCircularProgress } from '../../components/CenteredCircularProgress'; -import { getMappedReleases } from './helpers/mapReleases'; -import { getReleasesWithTags } from './helpers/getReleasesWithTags'; -import { getSummary } from './helpers/getSummary'; +import { getMappedReleases } from './helpers/getMappedReleases'; +import { getReleaseStats } from './helpers/getReleaseStats'; +import { Info } from './Info/Info'; +import { ReleaseStatsContext } from './contexts/ReleaseStatsContext'; import { Row } from './Row/Row'; -import { Summary } from './Summary'; import { useGetStats } from './hooks/useGetStats'; import { useProjectContext } from '../../contexts/ProjectContext'; import { Warn } from './Warn'; @@ -65,31 +64,31 @@ export function DialogBody() { const { allReleases, allTags } = stats.value; const { mappedReleases } = getMappedReleases({ allReleases, project }); - const { releasesWithTags } = getReleasesWithTags({ + const { releaseStats } = getReleaseStats({ mappedReleases, allTags, project, }); - const summary = getSummary({ releasesWithTags }); + const shouldWarn = - releasesWithTags.unmappableTags.length > 0 || - releasesWithTags.unmatchedTags.length > 0 || - releasesWithTags.unmatched.length > 0; + releaseStats.unmappableTags.length > 0 || + releaseStats.unmatchedTags.length > 0 || + releaseStats.unmatchedReleases.length > 0; if (shouldWarn) { // eslint-disable-next-line no-console console.log("⚠️ Here's a summary of unmapped/unmatched tags/releases", { - unmappableTags: releasesWithTags.unmappableTags, - unmatchedTags: releasesWithTags.unmatchedTags, - unmatchedReleases: releasesWithTags.unmatched, + unmatchedReleases: releaseStats.unmatchedReleases, + unmatchedTags: releaseStats.unmatchedTags, + unmappableTags: releaseStats.unmappableTags, }); } return ( - <> - + + - + @@ -102,26 +101,22 @@ export function DialogBody() { - {Object.entries(releasesWithTags.releases).map( - ([baseVersion, releaseWithTags], index) => { + {Object.entries(releaseStats.releases).map( + ([baseVersion, releaseStat], index) => { return ( ); }, )}
-
- - {shouldWarn && ( - - )} - - + {shouldWarn && } +
+
); } diff --git a/plugins/github-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx b/plugins/github-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx new file mode 100644 index 0000000000..659956ddb1 --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { getDecimalNumber } from '../../helpers/getDecimalNumber'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; + +export function AverageReleaseTime({ + averageReleaseTime, +}: { + averageReleaseTime: ReturnType< + typeof useGetReleaseTimes + >['averageReleaseTime']; +}) { + if (averageReleaseTime.length === 0) { + return <>-; + } + + const average = averageReleaseTime.reduce( + (acc, { daysWithHours }) => { + acc.daysWithHours += daysWithHours / averageReleaseTime.length; + return acc; + }, + { daysWithHours: 0 }, + ); + + const days = Math.floor(average.daysWithHours); + const hours = getDecimalNumber((average.daysWithHours - days) * 24, 1); + + return ( + <> + {days} days {hours} hours + + ); +} diff --git a/plugins/github-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx b/plugins/github-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx new file mode 100644 index 0000000000..9fbd2bc925 --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { + Box, + Button, + Tooltip as MaterialTooltip, + Typography, +} from '@material-ui/core'; +import { BarChart, Bar, XAxis, YAxis, Legend, Tooltip } from 'recharts'; + +import { AverageReleaseTime } from './AverageReleaseTime'; +import { LinearProgressWithLabel } from '../../../../components/ResponseStepDialog/LinearProgressWithLabel'; +import { LongestReleaseTime } from './LongestReleaseTime'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; +import { useReleaseStatsContext } from '../../contexts/ReleaseStatsContext'; + +export function InDepth() { + const { releaseStats } = useReleaseStatsContext(); + const { + averageReleaseTime, + progress, + releaseCommitPairs, + run, + } = useGetReleaseTimes(); + + const skipped = + Object.keys(releaseStats.releases).length - releaseCommitPairs.length; + + return ( + + + In-depth + + + + + + Release time + + + Release time is derived by comparing{' '} + createdAt of the commits belonging to the first and last + tag of each release. Releases without patches will have tags + pointing towards the same commit and will thus be omitted. This + project will omit {skipped} out of the total{' '} + {Object.keys(releaseStats.releases).length} releases. + + + + + + + In numbers + + + Average release time:{' '} + + + + + Longest release:{' '} + + + + + + {progress === 0 && ( + + + + )} + + + + + + 0 + ? averageReleaseTime + : [{ version: 'x.y.z', days: 0 }] + } + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + layout="vertical" + > + + + + + + + + {progress > 0 && progress < 100 && ( + + + + )} + + + ); +} diff --git a/plugins/github-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx b/plugins/github-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx new file mode 100644 index 0000000000..7fd5dbd572 --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { getDecimalNumber } from '../../helpers/getDecimalNumber'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; + +export function LongestReleaseTime({ + averageReleaseTime, +}: { + averageReleaseTime: ReturnType< + typeof useGetReleaseTimes + >['averageReleaseTime']; +}) { + if (averageReleaseTime.length === 0) { + return <>-; + } + + const longestRelease = [...averageReleaseTime].sort( + (a, b) => b.daysWithHours - a.daysWithHours, + )[0]; + + return ( + <> + {longestRelease.version} ({longestRelease.days} days{' '} + {getDecimalNumber(longestRelease.hours, 1)} hours ) + + ); +} diff --git a/plugins/github-release-manager/src/features/Stats/Info/Info.tsx b/plugins/github-release-manager/src/features/Stats/Info/Info.tsx new file mode 100644 index 0000000000..1c21f66e2c --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/Info/Info.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { Paper } from '@material-ui/core'; + +import { InDepth } from './InDepth/InDepth'; +import { Summary } from './Summary'; + +export function Info() { + return ( + + + + + + ); +} diff --git a/plugins/github-release-manager/src/features/Stats/Info/Summary.tsx b/plugins/github-release-manager/src/features/Stats/Info/Summary.tsx new file mode 100644 index 0000000000..ff872dfffc --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/Info/Summary.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { + Box, + makeStyles, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@material-ui/core'; + +import { getDecimalNumber } from '../helpers/getDecimalNumber'; +import { getSummary } from '../helpers/getSummary'; +import { useReleaseStatsContext } from '../contexts/ReleaseStatsContext'; + +const useStyles = makeStyles({ + table: { + minWidth: 650, + }, +}); + +export function Summary() { + const { releaseStats } = useReleaseStatsContext(); + const { summary } = getSummary({ releaseStats }); + const classes = useStyles(); + + return ( + + Summary + + + Total releases: {summary.totalReleases} + + + + + + + + Patches + Patches per release + + + + + + + Release Candidate + + {summary.totalCandidatePatches} + + {getDecimalNumber( + summary.totalCandidatePatches / summary.totalReleases, + )} + + + + + + Release Version + + {summary.totalVersionPatches} + + {getDecimalNumber( + summary.totalVersionPatches / summary.totalReleases, + )} + + + + + + Total + + + {summary.totalCandidatePatches + summary.totalVersionPatches} + + + {getDecimalNumber( + (summary.totalCandidatePatches + + summary.totalVersionPatches) / + summary.totalReleases, + )} + + + +
+
+
+ ); +} diff --git a/plugins/github-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx b/plugins/github-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx new file mode 100644 index 0000000000..3d9fb9449c --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { getReleaseCommitPairs } from './getReleaseCommitPairs'; + +describe('getReleaseCommitPairs', () => { + it('should work', () => { + const nonPublishedRelease = { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.0.0', + sha: 'sha-1.0.0', + }, + { + tagName: 'rc-1.0.1', + sha: 'sha-1.0.1', + }, + ], + versions: [], + }; + + const releaseWithoutPatches = { + baseVersion: '2.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-2.0.0', + sha: 'sha-2.0.0', + }, + ], + versions: [ + { + tagName: 'version-2.0.0', + sha: 'sha-2.0.0', + }, + ], + }; + + const releaseWithPatches = { + baseVersion: '3.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-3.0.1', + sha: 'sha-3.0.1', + }, + { + tagName: 'rc-3.0.0', + sha: 'sha-3.0.0', + }, + ], + versions: [ + { + tagName: 'version-3.0.1', + sha: 'sha-3.0.1', + }, + ], + }; + + const result = getReleaseCommitPairs({ + releaseStats: { + releases: { + nonPublishedRelease, // Should be omitted + releaseWithoutPatches, // Should be omitted + releaseWithPatches, + }, + unmatchedReleases: [], + unmappableTags: [], + unmatchedTags: [], + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "releaseCommitPairs": Array [ + Object { + "baseVersion": "3.0", + "endCommit": Object { + "sha": "sha-3.0.1", + "tagName": "version-3.0.1", + }, + "startCommit": Object { + "sha": "sha-3.0.0", + "tagName": "rc-3.0.0", + }, + }, + ], + } + `); + }); +}); diff --git a/plugins/github-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx b/plugins/github-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx new file mode 100644 index 0000000000..e55d97078e --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { ReleaseCommitPairs } from '../hooks/useGetReleaseTimes'; +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; + +export function getReleaseCommitPairs({ + releaseStats, +}: { + releaseStats: ReleaseStats; +}) { + const releaseCommitPairs = Object.values(releaseStats.releases).reduce( + (acc: ReleaseCommitPairs, release) => { + const startTag = [...release.candidates].reverse()[0]; + const endTag = release.versions[0]; + + // Missing Release Candidate for unknown reason + if (!startTag?.sha) { + return acc; + } + + // Missing Release Version (likely prerelease) + if (!endTag?.sha) { + return acc; + } + + // First RC tag is pointing towards the same commit as the most + // recent Version tag, meaning there haven't been any patches + // and we thus cannot determine and difference in time + if (startTag?.sha === endTag?.sha) { + return acc; + } + + return acc.concat({ + baseVersion: release.baseVersion, + startCommit: { + tagName: startTag.tagName, + sha: startTag.sha, + }, + endCommit: { + tagName: endTag.tagName, + sha: endTag.sha, + }, + }); + }, + [], + ); + + return { + releaseCommitPairs, + }; +} diff --git a/plugins/github-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx b/plugins/github-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx new file mode 100644 index 0000000000..2909763a8f --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { useEffect, useState } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { DateTime } from 'luxon'; + +import { getReleaseCommitPairs } from '../helpers/getReleaseCommitPairs'; +import { usePluginApiClientContext } from '../../../../contexts/PluginApiClientContext'; +import { useProjectContext } from '../../../../contexts/ProjectContext'; +import { useReleaseStatsContext } from '../../contexts/ReleaseStatsContext'; + +export type ReleaseCommitPairs = Array<{ + baseVersion: string; + startCommit: { + tagName: string; + sha: string; + }; + endCommit: { + tagName: string; + sha: string; + }; +}>; + +type ReleaseTime = { + version: string; + daysWithHours: number; + days: number; + hours: number; + startCommitCreatedAt?: string; + endCommitCreatedAt?: string; +}; + +export function useGetReleaseTimes() { + const { pluginApiClient } = usePluginApiClientContext(); + const { project } = useProjectContext(); + const { releaseStats } = useReleaseStatsContext(); + const [averageReleaseTime, setAverageReleaseTime] = useState( + [], + ); + const [progress, setProgress] = useState(0); + const { releaseCommitPairs } = getReleaseCommitPairs({ releaseStats }); + + const [fnRes, run] = useAsyncFn(() => { + setProgress(0); + return getAndSetReleaseTime(0); + }); + + useAsync(async () => { + if (averageReleaseTime.length === 0) return; + if (releaseCommitPairs.length === averageReleaseTime.length) return; + + await getAndSetReleaseTime(averageReleaseTime.length); + }, [fnRes.value, averageReleaseTime]); + + useEffect(() => { + const unboundedProgress = Math.round( + (averageReleaseTime.length / releaseCommitPairs.length) * 100, + ); + const boundedProgress = unboundedProgress > 100 ? 100 : unboundedProgress; + + setProgress(boundedProgress); + }, [averageReleaseTime.length, releaseCommitPairs.length]); + + async function getAndSetReleaseTime(index: number) { + const { baseVersion, startCommit, endCommit } = releaseCommitPairs[index]; + + const [ + { createdAt: startCommitCreatedAt }, + { createdAt: endCommitCreatedAt }, + ] = await Promise.all([ + pluginApiClient.stats.getCommit({ + owner: project.owner, + repo: project.repo, + ref: startCommit.sha, + }), + pluginApiClient.stats.getCommit({ + owner: project.owner, + repo: project.repo, + ref: endCommit.sha, + }), + ]); + + const releaseTime: ReleaseTime = { + version: baseVersion, + daysWithHours: 0, + days: 0, + hours: 0, + startCommitCreatedAt, + endCommitCreatedAt, + }; + + if (startCommitCreatedAt && endCommitCreatedAt) { + const { days: luxDays = 0, hours: luxHours = 0 } = DateTime.fromISO( + endCommitCreatedAt, + ) + .diff(DateTime.fromISO(startCommitCreatedAt), ['days', 'hours']) + .toObject(); + + releaseTime.daysWithHours = luxDays + luxHours / 24; + releaseTime.days = luxDays; + releaseTime.hours = luxHours; + } + + setAverageReleaseTime([...averageReleaseTime, releaseTime]); + } + + return { + releaseCommitPairs, + averageReleaseTime, + progress, + run, + }; +} diff --git a/plugins/github-release-manager/src/features/Stats/Row/Row.tsx b/plugins/github-release-manager/src/features/Stats/Row/Row.tsx index 199aa74fdc..95ec244e78 100644 --- a/plugins/github-release-manager/src/features/Stats/Row/Row.tsx +++ b/plugins/github-release-manager/src/features/Stats/Row/Row.tsx @@ -27,7 +27,7 @@ import { import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp'; -import { getReleasesWithTags } from '../helpers/getReleasesWithTags'; +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; import { RowCollapsed } from './RowCollapsed/RowCollapsed'; const useRowStyles = makeStyles({ @@ -40,17 +40,15 @@ const useRowStyles = makeStyles({ interface RowProps { baseVersion: string; - releaseWithTags: ReturnType< - typeof getReleasesWithTags - >['releasesWithTags']['releases']['0']; + releaseStat: ReleaseStats['releases']['0']; } -export function Row({ baseVersion, releaseWithTags }: RowProps) { +export function Row({ baseVersion, releaseStat }: RowProps) { const [open, setOpen] = useState(false); const classes = useRowStyles(); return ( - + <> - + {baseVersion} - {releaseWithTags.versions.length === 0 ? ' (prerelease)' : ''} + {releaseStat.versions.length === 0 ? ' (prerelease)' : ''} - {releaseWithTags.createdAt - ? DateTime.fromISO(releaseWithTags.createdAt) + {releaseStat.createdAt + ? DateTime.fromISO(releaseStat.createdAt) .setLocale('sv-SE') .toFormat('yyyy-MM-dd') : '-'} - {releaseWithTags.candidates.length} + {releaseStat.candidates.length} - - {Math.max(0, releaseWithTags.versions.length - 1)} - + {Math.max(0, releaseStat.versions.length - 1)} - + - + ); } diff --git a/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx b/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx index 3047c310f1..e07b74a171 100644 --- a/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx +++ b/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx @@ -17,14 +17,12 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; -import { getReleasesWithTags } from '../../helpers/getReleasesWithTags'; +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; export function ReleaseTagList({ - releaseWithTags, + releaseStat, }: { - releaseWithTags: ReturnType< - typeof getReleasesWithTags - >['releasesWithTags']['releases']['0']; + releaseStat: ReleaseStats['releases']['0']; }) { return ( - {releaseWithTags.versions.length > 0 && ( + {releaseStat.versions.length > 0 && ( - {releaseWithTags.versions.map(version => ( + {releaseStat.versions.map(version => ( {version.tagName} @@ -46,7 +44,7 @@ export function ReleaseTagList({ )} - {releaseWithTags.versions.length > 0 && ( + {releaseStat.versions.length > 0 && ( - {releaseWithTags.candidates.map(candidate => ( + {releaseStat.candidates.map(candidate => ( {candidate.tagName} diff --git a/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx b/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx index b8cc25fd12..7f19ec1df9 100644 --- a/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx +++ b/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx @@ -15,27 +15,23 @@ */ import React from 'react'; -import { Box, Typography } from '@material-ui/core'; import { DateTime } from 'luxon'; - -import { CenteredCircularProgress } from '../../../../components/CenteredCircularProgress'; -import { getReleasesWithTags } from '../../helpers/getReleasesWithTags'; -import { useGetCommit } from '../../hooks/useGetCommit'; +import { Box, Typography } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; +import { CenteredCircularProgress } from '../../../../components/CenteredCircularProgress'; +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; +import { useGetCommit } from '../../hooks/useGetCommit'; + interface ReleaseTimeProps { - releaseWithTags: ReturnType< - typeof getReleasesWithTags - >['releasesWithTags']['releases']['0']; + releaseStat: ReleaseStats['releases']['0']; } -export function ReleaseTime({ releaseWithTags }: ReleaseTimeProps) { - const reversedCandidates = [...releaseWithTags.candidates].reverse(); - - const firstCandidateSha = reversedCandidates[0]?.sha; +export function ReleaseTime({ releaseStat }: ReleaseTimeProps) { + const firstCandidateSha = [...releaseStat.candidates].reverse()[0]?.sha; const { commit: releaseCut } = useGetCommit({ ref: firstCandidateSha }); - const mostRecentVersionSha = releaseWithTags.versions[0]?.sha; + const mostRecentVersionSha = releaseStat.versions[0]?.sha; const { commit: releaseComplete } = useGetCommit({ ref: mostRecentVersionSha, }); @@ -57,15 +53,6 @@ export function ReleaseTime({ releaseWithTags }: ReleaseTimeProps) { ); } - if (releaseComplete.error) { - return ( - - Failed to fetch the final Release Version Commit ( - {releaseComplete.error.message}) - - ); - } - const diff = releaseCut.value?.createdAt && releaseComplete.value?.createdAt ? DateTime.fromISO(releaseComplete.value.createdAt) @@ -83,7 +70,7 @@ export function ReleaseTime({ releaseWithTags }: ReleaseTimeProps) { }} > - Release completed{' '} + {releaseStat.versions.length === 0 ? '-' : 'Release completed '} {releaseComplete.value?.createdAt && DateTime.fromISO(releaseComplete.value.createdAt) .setLocale('sv-SE') @@ -99,7 +86,11 @@ export function ReleaseTime({ releaseWithTags }: ReleaseTimeProps) { }} > - Release time: {diff.days} days + {diff.days === -1 ? ( + <>Ongoing: {diff.days} days + ) : ( + <>Completed in: {diff.days} days + )} diff --git a/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx b/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx index a17707a62c..101954e49d 100644 --- a/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx +++ b/plugins/github-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx @@ -16,17 +16,15 @@ import React from 'react'; import { Box } from '@material-ui/core'; -import { getReleasesWithTags } from '../../helpers/getReleasesWithTags'; -import { ReleaseTime } from './ReleaseTime'; +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; import { ReleaseTagList } from './ReleaseTagList'; +import { ReleaseTime } from './ReleaseTime'; interface RowCollapsedProps { - releaseWithTags: ReturnType< - typeof getReleasesWithTags - >['releasesWithTags']['releases']['0']; + releaseStat: ReleaseStats['releases']['0']; } -export function RowCollapsed({ releaseWithTags }: RowCollapsedProps) { +export function RowCollapsed({ releaseStat }: RowCollapsedProps) { return ( - + - + ); } diff --git a/plugins/github-release-manager/src/features/Stats/Summary.tsx b/plugins/github-release-manager/src/features/Stats/Summary.tsx deleted file mode 100644 index d5cedaeaae..0000000000 --- a/plugins/github-release-manager/src/features/Stats/Summary.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * 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 { Box, Paper, Typography } from '@material-ui/core'; - -import { getDecimalNumber } from './helpers/getDecimalNumber'; -import { getSummary } from './helpers/getSummary'; - -interface SummaryProps { - summary: ReturnType; -} - -export function Summary({ summary }: SummaryProps) { - return ( - - - Summary - - Total releases: {summary.totalReleases} - - - - - Release Candidate - - Release Candidate patches: {summary.totalCandidatePatches} - - - - Release Candidate patches per release:{' '} - {getDecimalNumber( - summary.totalCandidatePatches / summary.totalReleases, - )} - - - - - Release Version - - Release Version patches: {summary.totalVersionPatches} - - - - Release Version patches per release:{' '} - {getDecimalNumber( - summary.totalVersionPatches / summary.totalReleases, - )} - - - - - Total - - Patches: {summary.totalCandidatePatches + summary.totalVersionPatches} - - - - Patches per release:{' '} - {getDecimalNumber( - (summary.totalCandidatePatches + summary.totalVersionPatches) / - summary.totalReleases, - )} - - - - ); -} diff --git a/plugins/github-release-manager/src/features/Stats/Warn.tsx b/plugins/github-release-manager/src/features/Stats/Warn.tsx index a86ac2cf1b..93c2cc4154 100644 --- a/plugins/github-release-manager/src/features/Stats/Warn.tsx +++ b/plugins/github-release-manager/src/features/Stats/Warn.tsx @@ -17,39 +17,37 @@ import React from 'react'; import { Alert } from '@material-ui/lab'; -import { getReleasesWithTags } from './helpers/getReleasesWithTags'; -import { Project } from '../../contexts/ProjectContext'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useReleaseStatsContext } from './contexts/ReleaseStatsContext'; -interface WarnProps { - releasesWithTags: ReturnType['releasesWithTags']; - project: Project; -} +export const Warn = () => { + const { releaseStats } = useReleaseStatsContext(); + const { project } = useProjectContext(); -export const Warn = ({ releasesWithTags, project }: WarnProps) => { return ( - {releasesWithTags.unmappableTags.length > 0 && ( + {releaseStats.unmappableTags.length > 0 && (
- Failed to map{' '} - {releasesWithTags.unmappableTags.length} tags to - releases + Failed to map {releaseStats.unmappableTags.length}{' '} + tags to releases
)} - {releasesWithTags.unmatchedTags.length > 0 && ( + {releaseStats.unmatchedTags.length > 0 && ( +
+ Failed to match {releaseStats.unmatchedTags.length}{' '} + tags to {project.versioningStrategy} +
+ )} + + {releaseStats.unmatchedReleases.length > 0 && (
Failed to match{' '} - {releasesWithTags.unmatchedTags.length} tags to{' '} + {releaseStats.unmatchedReleases.length} releases to{' '} {project.versioningStrategy}
)} - {releasesWithTags.unmatched.length > 0 && ( -
- Failed to match {releasesWithTags.unmatched.length}{' '} - releases to {project.versioningStrategy} -
- )}
See full output in the console
); diff --git a/plugins/github-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx b/plugins/github-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx new file mode 100644 index 0000000000..be0d68e2e5 --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { createContext, useContext } from 'react'; + +import { GitHubReleaseManagerError } from '../../../errors/GitHubReleaseManagerError'; + +export interface ReleaseStats { + unmappableTags: string[]; + unmatchedTags: string[]; + unmatchedReleases: string[]; + releases: { + [baseVersion: string]: { + baseVersion: string; + createdAt: string | null; + htmlUrl: string; + + /** + * Ordered from new to old + */ + candidates: { + tagName: string; + sha: string; + }[]; + + /** + * Ordered from new to old + */ + versions: { + tagName: string; + sha: string; + }[]; + }; + }; +} + +export const ReleaseStatsContext = createContext< + { releaseStats: ReleaseStats } | undefined +>(undefined); + +export const useReleaseStatsContext = () => { + const { releaseStats } = useContext(ReleaseStatsContext) ?? {}; + + if (!releaseStats) { + throw new GitHubReleaseManagerError('releaseStats not found'); + } + + return { + releaseStats, + }; +}; diff --git a/plugins/github-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx new file mode 100644 index 0000000000..b4b1a1df4c --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { getDecimalNumber } from './getDecimalNumber'; + +describe('getDecimalNumber', () => { + it('should handle NaN', () => { + const result = getDecimalNumber(NaN); + + expect(result).toEqual(0); + }); + + it('should only handle decimals', () => { + const result = getDecimalNumber(1); + + expect(result).toEqual(1); + }); + + it('should get decimal number with default decimals = 2', () => { + const result = getDecimalNumber(1 / 3); + + expect(result).toMatchInlineSnapshot(`0.33`); + }); + + it('should get decimal number for decimals = 1', () => { + const result = getDecimalNumber(1 / 3, 1); + + expect(result).toMatchInlineSnapshot(`0.3`); + }); +}); diff --git a/plugins/github-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx index 590d95a757..ab4a83fa79 100644 --- a/plugins/github-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx +++ b/plugins/github-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx @@ -14,13 +14,13 @@ * limitations under the License. */ -export function getDecimalNumber(n: number) { +export function getDecimalNumber(n: number, decimals = 2) { if (isNaN(n)) { return 0; } if (n.toString().includes('.')) { - return n.toFixed(2); + return parseFloat(n.toFixed(decimals)); } return n; diff --git a/plugins/github-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx new file mode 100644 index 0000000000..5bea396d5c --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { getMappedReleases } from './getMappedReleases'; +import { mockSemverProject } from '../../../test-helpers/test-helpers'; + +describe('getMappedReleases', () => { + it('should get mapped releases', () => { + const createRelease = (tagName: string) => ({ + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + id: 1, + name: 'name', + tagName, + }); + + const result = getMappedReleases({ + project: mockSemverProject, + allReleases: [createRelease('rc-1.0.0'), createRelease('rc-1.1.0')], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "mappedReleases": Object { + "releases": Object { + "1.0": Object { + "baseVersion": "1.0", + "candidates": Array [ + Object { + "sha": "", + "tagName": "rc-1.0.0", + }, + ], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + "1.1": Object { + "baseVersion": "1.1", + "candidates": Array [ + Object { + "sha": "", + "tagName": "rc-1.1.0", + }, + ], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + }, + "unmappableTags": Array [], + "unmatchedReleases": Array [], + "unmatchedTags": Array [], + }, + } + `); + }); +}); diff --git a/plugins/github-release-manager/src/features/Stats/helpers/mapReleases.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getMappedReleases.tsx similarity index 68% rename from plugins/github-release-manager/src/features/Stats/helpers/mapReleases.tsx rename to plugins/github-release-manager/src/features/Stats/helpers/getMappedReleases.tsx index d1103d5e71..55382100ac 100644 --- a/plugins/github-release-manager/src/features/Stats/helpers/mapReleases.tsx +++ b/plugins/github-release-manager/src/features/Stats/helpers/getMappedReleases.tsx @@ -17,6 +17,7 @@ import { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts'; import { GetAllReleasesResult } from '../../../api/PluginApiClient'; import { Project } from '../../../contexts/ProjectContext'; +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts'; export function getMappedReleases({ @@ -28,61 +29,47 @@ export function getMappedReleases({ }) { return { mappedReleases: allReleases.reduce( - ( - acc: { - unmatched: string[]; - releases: { - [baseVersion: string]: { - createdAt: string | null; - candidates: { - tagName: string; - sha: string; - }[]; - versions: { - tagName: string; - sha: string; - }[]; - htmlUrl: string; - }; - }; - }, - release, - ) => { + (acc: ReleaseStats, release) => { const match = project.versioningStrategy === 'semver' ? release.tagName.match(semverRegexp) : release.tagName.match(calverRegexp); if (!match) { - acc.unmatched.push(release.tagName); + acc.unmatchedReleases.push(release.tagName); return acc; } - const prefix = match[1]; + const prefix = match[1] as 'rc' | 'version'; const baseVersion = project.versioningStrategy === 'semver' ? `${match[2]}.${match[3]}` : match[2]; if (!acc.releases[baseVersion]) { - acc.releases[baseVersion] = { - createdAt: release.createdAt, - candidates: - prefix === 'rc' ? [{ tagName: release.tagName, sha: '' }] : [], - versions: - prefix === 'version' - ? [{ tagName: release.tagName, sha: '' }] - : [], - htmlUrl: release.htmlUrl, + const releaseEntry = { + tagName: release.tagName, + sha: '', }; + + acc.releases[baseVersion] = { + baseVersion, + createdAt: release.createdAt, + htmlUrl: release.htmlUrl, + candidates: prefix === 'rc' ? [releaseEntry] : [], + versions: prefix === 'version' ? [releaseEntry] : [], + }; + return acc; } return acc; }, { - unmatched: [], releases: {}, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], }, ), }; diff --git a/plugins/github-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx new file mode 100644 index 0000000000..3b59cc4c46 --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { getReleaseStats } from './getReleaseStats'; +import { mockSemverProject } from '../../../test-helpers/test-helpers'; + +describe('getReleaseStats', () => { + it('should get releases with tags', () => { + const result = getReleaseStats({ + project: mockSemverProject, + mappedReleases: { + releases: { + '1.0': { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.0.0', + sha: '', + }, + ], + versions: [], + }, + '1.1': { + baseVersion: '1.1', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.1.0', + sha: '', + }, + ], + versions: [], + }, + }, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], + }, + allTags: [ + { sha: 'sha', tagName: 'rc-1.0.0' }, + { sha: 'sha', tagName: 'rc-1.0.1' }, + { sha: 'sha', tagName: 'rc-1.0.2' }, + { sha: 'sha', tagName: 'version-1.0.2' }, + { sha: 'sha', tagName: 'rc-1.1.1' }, + + { sha: 'unmatchable', tagName: 'rc-1/2/3' }, + { sha: 'unmappable', tagName: 'rc-123.123.123' }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "releaseStats": Object { + "releases": Object { + "1.0": Object { + "baseVersion": "1.0", + "candidates": Array [ + Object { + "sha": "sha", + "tagName": "rc-1.0.0", + }, + Object { + "sha": "sha", + "tagName": "rc-1.0.1", + }, + Object { + "sha": "sha", + "tagName": "rc-1.0.2", + }, + ], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [ + Object { + "sha": "sha", + "tagName": "version-1.0.2", + }, + ], + }, + "1.1": Object { + "baseVersion": "1.1", + "candidates": Array [ + Object { + "sha": "", + "tagName": "rc-1.1.0", + }, + Object { + "sha": "sha", + "tagName": "rc-1.1.1", + }, + ], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + }, + "unmappableTags": Array [ + "rc-123.123.123", + ], + "unmatchedReleases": Array [], + "unmatchedTags": Array [ + "rc-1/2/3", + ], + }, + } + `); + }); +}); diff --git a/plugins/github-release-manager/src/features/Stats/helpers/getReleaseStats.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getReleaseStats.tsx new file mode 100644 index 0000000000..9b77cc1223 --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/helpers/getReleaseStats.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts'; +import { GetAllTagsResult } from '../../../api/PluginApiClient'; +import { Project } from '../../../contexts/ProjectContext'; +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; +import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts'; + +export function getReleaseStats({ + allTags, + project, + mappedReleases, +}: { + allTags: GetAllTagsResult; + project: Project; + mappedReleases: ReleaseStats; +}) { + const releaseStats = allTags.reduce( + (acc: ReleaseStats, tag) => { + const match = + project.versioningStrategy === 'semver' + ? tag.tagName.match(semverRegexp) + : tag.tagName.match(calverRegexp); + + if (!match) { + acc.unmatchedTags.push(tag.tagName); + return acc; + } + + const prefix = match[1] as 'rc' | 'version'; + const baseVersion = + project.versioningStrategy === 'semver' + ? `${match[2]}.${match[3]}` // major.minor + : match[2]; // yyyy.MM.dd + + const release = acc.releases[baseVersion]; + + if (!release) { + acc.unmappableTags.push(tag.tagName); + return acc; + } + + const dest = release[prefix === 'rc' ? 'candidates' : 'versions']; + const releaseToEnrich = dest.find( + ({ tagName }) => tagName === tag.tagName, + ); + + if (releaseToEnrich) { + releaseToEnrich.sha = tag.sha; + } else { + dest.push({ + tagName: tag.tagName, + sha: tag.sha, + }); + } + + return acc; + }, + { + ...mappedReleases, + }, + ); + + return { + releaseStats, + }; +} diff --git a/plugins/github-release-manager/src/features/Stats/helpers/getReleasesWithTags.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getReleasesWithTags.tsx deleted file mode 100644 index 1c41f78288..0000000000 --- a/plugins/github-release-manager/src/features/Stats/helpers/getReleasesWithTags.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * 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 { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts'; -import { GetAllTagsResult } from '../../../api/PluginApiClient'; -import { getMappedReleases } from './mapReleases'; -import { Project } from '../../../contexts/ProjectContext'; -import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts'; - -export function getReleasesWithTags({ - allTags, - project, - mappedReleases, -}: { - allTags: GetAllTagsResult; - project: Project; - mappedReleases: ReturnType['mappedReleases']; -}) { - return { - releasesWithTags: allTags.reduce( - ( - acc: ReturnType['mappedReleases'] & { - unmatchedTags: string[]; - unmappableTags: string[]; - }, - tag, - ) => { - const match = - project.versioningStrategy === 'semver' - ? tag.tagName.match(semverRegexp) - : tag.tagName.match(calverRegexp); - - if (!match) { - acc.unmatchedTags.push(tag.tagName); - return acc; - } - - const prefix = match[1]; - const baseVersion = - project.versioningStrategy === 'semver' - ? `${match[2]}.${match[3]}` - : match[2]; - - if (!acc.releases[baseVersion]) { - acc.unmappableTags.push(tag.tagName); - return acc; - } - - if (prefix === 'rc') { - const existingEntry = acc.releases[baseVersion].candidates.find( - ({ tagName }) => tagName === tag.tagName, - ); - - if (existingEntry) { - existingEntry.sha = tag.sha; - } else { - acc.releases[baseVersion].candidates.push({ - tagName: tag.tagName, - sha: tag.sha, - }); - } - } - - if (prefix === 'version') { - const existingEntry = acc.releases[baseVersion].versions.find( - ({ tagName }) => tagName === tag.tagName, - ); - - if (existingEntry) { - existingEntry.sha = tag.sha; - } else { - acc.releases[baseVersion].versions.push({ - tagName: tag.tagName, - sha: tag.sha, - }); - } - } - - return acc; - }, - { - ...mappedReleases, - unmatchedTags: [], - unmappableTags: [], - }, - ), - }; -} diff --git a/plugins/github-release-manager/src/features/Stats/helpers/getSummary.test.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getSummary.test.tsx new file mode 100644 index 0000000000..be6052c2a0 --- /dev/null +++ b/plugins/github-release-manager/src/features/Stats/helpers/getSummary.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { getSummary } from './getSummary'; +import { mockReleaseStats } from '../../../test-helpers/stats'; + +describe('getSummary', () => { + it('should get summary', () => { + const result = getSummary({ releaseStats: mockReleaseStats }); + + expect(result).toMatchInlineSnapshot(` + Object { + "summary": Object { + "totalCandidatePatches": 3, + "totalReleases": 2, + "totalVersionPatches": 1, + }, + } + `); + }); +}); diff --git a/plugins/github-release-manager/src/features/Stats/helpers/getSummary.tsx b/plugins/github-release-manager/src/features/Stats/helpers/getSummary.tsx index b898469c8a..e42c5c7379 100644 --- a/plugins/github-release-manager/src/features/Stats/helpers/getSummary.tsx +++ b/plugins/github-release-manager/src/features/Stats/helpers/getSummary.tsx @@ -14,32 +14,35 @@ * limitations under the License. */ -import { getReleasesWithTags } from './getReleasesWithTags'; +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; -export function getSummary({ - releasesWithTags, -}: { - releasesWithTags: ReturnType['releasesWithTags']; -}) { - return Object.entries(releasesWithTags.releases).reduce( - ( - acc: { - totalReleases: number; - totalCandidatePatches: number; - totalVersionPatches: number; +export function getSummary({ releaseStats }: { releaseStats: ReleaseStats }) { + return { + summary: Object.entries(releaseStats.releases).reduce( + ( + acc: { + totalReleases: number; + totalCandidatePatches: number; + totalVersionPatches: number; + }, + [_baseVersion, mappedRelease], + ) => { + const candidatePatches = + Object.keys(mappedRelease.candidates).length - 1; + const versionPatches = Object.keys(mappedRelease.versions).length - 1; + + acc.totalReleases += 1; + acc.totalCandidatePatches += + candidatePatches >= 0 ? candidatePatches : 0; + acc.totalVersionPatches += versionPatches >= 0 ? versionPatches : 0; + + return acc; }, - [_baseVersion, mappedRelease], - ) => { - acc.totalReleases += 1; - acc.totalCandidatePatches += mappedRelease.candidates.length - 1; - acc.totalVersionPatches += mappedRelease.versions.length - 1; - - return acc; - }, - { - totalReleases: 0, - totalCandidatePatches: 0, - totalVersionPatches: 0, - }, - ); + { + totalReleases: 0, + totalCandidatePatches: 0, + totalVersionPatches: 0, + }, + ), + }; } diff --git a/plugins/github-release-manager/src/features/Stats/hooks/useGetCommit.ts b/plugins/github-release-manager/src/features/Stats/hooks/useGetCommit.ts index d19aa30331..95db493774 100644 --- a/plugins/github-release-manager/src/features/Stats/hooks/useGetCommit.ts +++ b/plugins/github-release-manager/src/features/Stats/hooks/useGetCommit.ts @@ -16,20 +16,25 @@ import { useAsync } from 'react-use'; +import { GitHubReleaseManagerError } from '../../../errors/GitHubReleaseManagerError'; import { usePluginApiClientContext } from '../../../contexts/PluginApiClientContext'; import { useProjectContext } from '../../../contexts/ProjectContext'; -export const useGetCommit = ({ ref }: { ref: string }) => { +export const useGetCommit = ({ ref }: { ref?: string }) => { const { pluginApiClient } = usePluginApiClientContext(); const { project } = useProjectContext(); - const commit = useAsync(() => - pluginApiClient.stats.getCommit({ + const commit = useAsync(async () => { + if (!ref) { + throw new GitHubReleaseManagerError('Missing ref to get commit'); + } + + return pluginApiClient.stats.getCommit({ owner: project.owner, repo: project.repo, ref, - }), - ); + }); + }); return { commit, diff --git a/plugins/github-release-manager/src/test-helpers/stats.ts b/plugins/github-release-manager/src/test-helpers/stats.ts new file mode 100644 index 0000000000..22b3b83d1e --- /dev/null +++ b/plugins/github-release-manager/src/test-helpers/stats.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { ReleaseStats } from '../features/Stats/contexts/ReleaseStatsContext'; + +export const mockReleaseStats: ReleaseStats = { + releases: { + '1.0': { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.0.1', + sha: 'sha-1.0.1', + }, + { + tagName: 'rc-1.0.0', + sha: 'sha-1.0.0', + }, + ], + versions: [], + }, + '1.1': { + baseVersion: '1.1', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.1.2', + sha: 'sha-1.1.2', + }, + { + tagName: 'rc-1.1.1', + sha: 'sha-1.1.1', + }, + { + tagName: 'rc-1.1.0', + sha: 'sha-1.1.0', + }, + ], + versions: [ + { + tagName: 'version-1.1.3', + sha: 'sha-1.1.3', + }, + { + tagName: 'version-1.1.2', + sha: 'sha-1.1.2', + }, + ], + }, + }, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], +}; diff --git a/plugins/github-release-manager/src/test-helpers/test-helpers.test.ts b/plugins/github-release-manager/src/test-helpers/test-helpers.test.ts deleted file mode 100644 index 89553b3e24..0000000000 --- a/plugins/github-release-manager/src/test-helpers/test-helpers.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * 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 * as testHelpers from './test-helpers'; - -describe('testHelpers', () => { - it('should export the correct things', () => { - expect(testHelpers).toMatchInlineSnapshot(` - Object { - "mockApiClient": Object { - "createRc": Object { - "createRef": [MockFunction], - "createRelease": [MockFunction], - "getComparison": [MockFunction], - }, - "getBranch": [MockFunction], - "getHost": [MockFunction], - "getLatestCommit": [MockFunction], - "getLatestRelease": [MockFunction], - "getOwners": [MockFunction], - "getRecentCommits": [MockFunction], - "getRepoPath": [MockFunction], - "getRepositories": [MockFunction], - "getRepository": [MockFunction], - "getUsername": [MockFunction], - "patch": Object { - "createCherryPickCommit": [MockFunction], - "createReference": [MockFunction], - "createTagObject": [MockFunction], - "createTempCommit": [MockFunction], - "forceBranchHeadToTempCommit": [MockFunction], - "merge": [MockFunction], - "replaceTempCommit": [MockFunction], - "updateRelease": [MockFunction], - }, - "promoteRc": Object { - "promoteRelease": [MockFunction], - }, - "stats": Object { - "getAllReleases": [MockFunction], - "getAllTags": [MockFunction], - "getCommit": [MockFunction], - }, - }, - "mockBumpedTag": "rc-2020.01.01_1337", - "mockCalverProject": Object { - "isProvidedViaProps": false, - "owner": "mock_owner", - "repo": "mock_repo", - "versioningStrategy": "calver", - }, - "mockDefaultBranch": "mock_defaultBranch", - "mockNextGitHubInfoCalver": Object { - "rcBranch": "rc/2020.01.01_1", - "rcReleaseTag": "rc-2020.01.01_1", - "releaseName": "Version 2020.01.01_1", - }, - "mockNextGitHubInfoSemver": Object { - "rcBranch": "rc/1.2.3", - "rcReleaseTag": "rc-1.2.3", - "releaseName": "Version 1.2.3", - }, - "mockReleaseBranch": Object { - "commit": Object { - "commit": Object { - "tree": Object { - "sha": "mock_branch_commit_commit_tree_sha", - }, - }, - "sha": "mock_branch_commit_sha", - }, - "links": Object { - "html": "mock_branch_links_html", - }, - "name": "rc/1.2.3", - }, - "mockReleaseCandidateCalver": Object { - "htmlUrl": "mock_release_html_url", - "id": 1, - "prerelease": true, - "tagName": "rc-2020.01.01_1", - "targetCommitish": "rc/2020.01.01_1", - }, - "mockReleaseCandidateSemver": Object { - "htmlUrl": "mock_release_html_url", - "id": 1, - "prerelease": true, - "tagName": "rc-1.2.3", - "targetCommitish": "rc/1.2.3", - }, - "mockReleaseVersionCalver": Object { - "htmlUrl": "mock_release_html_url", - "id": 1, - "prerelease": false, - "tagName": "version-2020.01.01_1", - "targetCommitish": "rc/2020.01.01_1", - }, - "mockReleaseVersionSemver": Object { - "htmlUrl": "mock_release_html_url", - "id": 1, - "prerelease": false, - "tagName": "version-1.2.3", - "targetCommitish": "rc/1.2.3", - }, - "mockSearchCalver": "?versioningStrategy=calver&owner=mock_owner&repo=mock_repo", - "mockSearchSemver": "?versioningStrategy=semver&owner=mock_owner&repo=mock_repo", - "mockSelectedPatchCommit": Object { - "author": Object { - "htmlUrl": "author_html_url", - "login": "author_login", - }, - "commit": Object { - "message": "commit_message", - }, - "firstParentSha": "mock_first_parent_sha", - "htmlUrl": "mock_htmlUrl", - "sha": "mock_sha_selected_patch_commit", - }, - "mockSemverProject": Object { - "isProvidedViaProps": false, - "owner": "mock_owner", - "repo": "mock_repo", - "versioningStrategy": "semver", - }, - "mockTagParts": Object { - "calver": "2020.01.01", - "patch": 1, - "prefix": "rc", - }, - } - `); - }); -}); diff --git a/plugins/github-release-manager/src/test-helpers/test-helpers.ts b/plugins/github-release-manager/src/test-helpers/test-helpers.ts index 629bdb4031..68bd9c7e3b 100644 --- a/plugins/github-release-manager/src/test-helpers/test-helpers.ts +++ b/plugins/github-release-manager/src/test-helpers/test-helpers.ts @@ -280,16 +280,25 @@ export const mockApiClient: IPluginApiClient = { }, stats: { - getAllTags: jest.fn(async () => { - throw new Error('Not implemented'); - }), + getAllTags: jest.fn(async () => [ + { + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + sha: 'mock_sha', + }, + ]), - getAllReleases: jest.fn(async () => { - throw new Error('Not implemented'); - }), + getAllReleases: jest.fn(async () => [ + { + id: 1, + name: 'mock_release_name', + tagName: 'mock_release_tag_name', + createdAt: 'mock_release_published_at', + htmlUrl: 'mock_release_html_url', + }, + ]), - getCommit: jest.fn(async () => { - throw new Error('Not implemented'); - }), + getCommit: jest.fn(async () => ({ + createdAt: '2021-01-01T10:11:12Z', + })), }, }; diff --git a/plugins/github-release-manager/src/test-helpers/test-ids.test.ts b/plugins/github-release-manager/src/test-helpers/test-ids.test.ts deleted file mode 100644 index 199a7a5745..0000000000 --- a/plugins/github-release-manager/src/test-helpers/test-ids.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * 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 * as testIds from './test-ids'; - -describe('test-ids', () => { - it('should export the correct things', () => { - expect(testIds).toMatchInlineSnapshot(` - Object { - "TEST_IDS": Object { - "components": Object { - "circularProgress": "grm--circular-progress", - "differ": Object { - "current": "grm--differ-current", - "icons": Object { - "branch": "grm--differ--icons--branch", - "github": "grm--differ--icons--github", - "slack": "grm--differ--icons--slack", - "tag": "grm--differ--icons--tag", - "versioning": "grm--differ--icons--versioning", - }, - "next": "grm--differ-next", - }, - "divider": "grm--divider", - "linearProgressWithLabel": "grm--linear-progress-with-label", - "noLatestRelease": "grm--no-latest-release", - "responseStepListDialogContent": "grm--response-step-list--dialog-content", - "responseStepListItem": "grm--response-step-list-item", - "responseStepListItemIconDefault": "grm--response-step-list-item--item-icon--default", - "responseStepListItemIconFailure": "grm--response-step-list-item--item-icon--failure", - "responseStepListItemIconLink": "grm--response-step-list-item--item-icon--link", - "responseStepListItemIconSuccess": "grm--response-step-list-item--item-icon--success", - }, - "createRc": Object { - "cta": "grm--create-rc--cta", - "semverSelect": "grm--create-rc--semver-select", - }, - "form": Object { - "owner": Object { - "empty": "grm--form--owner--empty", - "error": "grm--form--owner--error", - "loading": "grm--form--owner--loading", - "select": "grm--form--owner--select", - }, - "repo": Object { - "empty": "grm--form--repo--empty", - "error": "grm--form--repo--error", - "loading": "grm--form--repo--loading", - "select": "grm--form--repo--select", - }, - "versioningStrategy": Object { - "radioGroup": "grm--form--versioning-strategy--radio-group", - }, - }, - "info": Object { - "info": "grm--info", - "infoFeaturePlus": "grm--info-feature-plus", - }, - "patch": Object { - "body": "grm--patch-body", - "error": "grm--patch-body--error", - "loading": "grm--patch-body--loading", - "notPrerelease": "grm--patch-body--not-prerelease--info", - }, - "promoteRc": Object { - "cta": "grm--promote-rc-body--cta", - "mockedPromoteRcBody": "grm-mocked-promote-rc-body", - "notRcWarning": "grm--promote-rc--not-rc-warning", - "promoteRc": "grm--promote-rc", - }, - }, - } - `); - }); -});