Introduce Stats Feature

Create two new API calls, getAllTags & getAllReleases

Rename "components" prop to more suitable "features"

Signed-off-by: Erik Engervall <erik.engervall@gmail.com>
This commit is contained in:
Erik Engervall
2021-04-23 00:28:53 +02:00
parent 5f8bae8779
commit c33462b440
18 changed files with 880 additions and 47 deletions
+1 -1
View File
@@ -54,6 +54,6 @@ The plugin exports a single full-page extension `GitHubReleaseManagerPage`, whic
The plugin is configurable either via props or the select elements on the page.
If project configuration is provided via props, the select elements are disabled. It is also possible to omit components from the page via props, as well as attaching callbacks for successful executions.
If project configuration is provided via props, the select elements are disabled. It is also possible to omit features from the page via props, as well as attaching callbacks for successful executions.
See the plugin's dev folder (`dev/index.tsx`) to see some examples.
@@ -38,8 +38,9 @@ import { useStyles } from './styles/styles';
export interface GitHubReleaseManagerProps {
project?: Omit<Project, 'isProvidedViaProps'>;
components?: {
features?: {
info?: Pick<ComponentConfig<void>, 'omit'>;
stats?: Pick<ComponentConfig<void>, 'omit'>;
createRc?: ComponentConfigCreateRc;
promoteRc?: ComponentConfigPromoteRc;
patch?: ComponentConfigPatch;
@@ -90,9 +91,7 @@ export function GitHubReleaseManager(props: GitHubReleaseManagerProps) {
<RepoDetailsForm username={usernameResponse.value.username} />
</InfoCardPlus>
{isProjectValid(project) && (
<Features components={props.components} />
)}
{isProjectValid(project) && <Features features={props.features} />}
</div>
</ProjectContext.Provider>
</PluginApiClientContext.Provider>
@@ -57,6 +57,10 @@ describe('PluginApiClient', () => {
"promoteRc": Object {
"promoteRelease": [Function],
},
"stats": Object {
"getAllReleases": [Function],
"getAllTags": [Function],
},
}
`);
});
@@ -562,6 +562,43 @@ ${selectedPatchCommit.commit.message}`,
};
},
};
stats = {
getAllTags: async ({ owner, repo }: OwnerRepo) => {
const { octokit } = await this.getOctokit();
const tags = await octokit.paginate(octokit.repos.listTags, {
owner,
repo,
per_page: 100,
...DISABLE_CACHE,
});
return tags.map(tag => ({
tagName: tag.name,
}));
},
getAllReleases: async ({ owner, repo }: OwnerRepo) => {
const { octokit } = await this.getOctokit();
const releases = await octokit.paginate(octokit.repos.listReleases, {
owner,
repo,
per_page: 100,
...DISABLE_CACHE,
});
return releases.map(release => ({
release,
id: release.id,
name: release.name,
tagName: release.tag_name,
createdAt: release.published_at,
htmlUrl: release.html_url,
}));
},
};
}
type UnboxPromise<T extends Promise<any>> = T extends Promise<infer U>
@@ -623,6 +660,28 @@ type GetRecentCommits = (
export type GetRecentCommitsResult = UnboxReturnedPromise<GetRecentCommits>;
export type GetRecentCommitsResultSingle = UnboxArray<GetRecentCommitsResult>;
type GetAllTags = (
args: OwnerRepo,
) => Promise<
Array<{
tagName: string;
}>
>;
export type GetAllTagsResult = UnboxReturnedPromise<GetAllTags>;
type GetAllReleases = (
args: OwnerRepo,
) => Promise<
Array<{
id: number;
name: string | null;
tagName: string;
createdAt: string | null;
htmlUrl: string;
}>
>;
export type GetAllReleasesResult = UnboxReturnedPromise<GetAllReleases>;
type GetLatestRelease = (
args: OwnerRepo,
) => Promise<{
@@ -858,4 +917,8 @@ export interface IPluginApiClient {
promoteRc: {
promoteRelease: PromoteRelease;
};
stats: {
getAllTags: GetAllTags;
getAllReleases: GetAllReleases;
};
}
@@ -52,8 +52,8 @@ describe('Features', () => {
expect(getByTestId(TEST_IDS.info.info)).toMatchInlineSnapshot(`
<div
class="MuiBox-root MuiBox-root-11"
data-testid="grm--info"
style="margin-bottom: 1em;"
>
<h6
class="MuiTypography-root MuiTypography-h6"
@@ -98,20 +98,6 @@ describe('Features', () => {
</strong>
: A GitHub release intended for end users
</p>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedSecondary"
tabindex="0"
type="button"
>
<span
class="MuiButton-label"
>
Show stats
</span>
<span
class="MuiTouchRipple-root"
/>
</button>
</div>
`);
});
@@ -32,9 +32,9 @@ import { useVersioningStrategyMatchesRepoTags } from '../hooks/useVersioningStra
import { validateTagName } from '../helpers/tagParts/validateTagName';
export function Features({
components,
features,
}: {
components: GitHubReleaseManagerProps['components'];
features: GitHubReleaseManagerProps['features'];
}) {
const { pluginApiClient } = usePluginApiClientContext();
const { project } = useProjectContext();
@@ -114,34 +114,35 @@ export function Features({
</Alert>
)}
{!components?.info?.omit && (
{!features?.info?.omit && (
<Info
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
statsEnabled={features?.stats?.omit !== true}
/>
)}
{!components?.createRc?.omit && (
{!features?.createRc?.omit && (
<CreateRc
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
defaultBranch={gitHubBatchInfo.value.repository.defaultBranch}
successCb={components?.createRc?.successCb}
successCb={features?.createRc?.successCb}
/>
)}
{!components?.promoteRc?.omit && (
{!features?.promoteRc?.omit && (
<PromoteRc
latestRelease={gitHubBatchInfo.value.latestRelease}
successCb={components?.promoteRc?.successCb}
successCb={features?.promoteRc?.successCb}
/>
)}
{!components?.patch?.omit && (
{!features?.patch?.omit && (
<Patch
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
successCb={components?.patch?.successCb}
successCb={features?.patch?.successCb}
/>
)}
</ErrorBoundary>
@@ -15,7 +15,8 @@
*/
import React, { useState } from 'react';
import { Link, Typography, Button } from '@material-ui/core';
import { Link, Typography, Button, Box } from '@material-ui/core';
import BarChartIcon from '@material-ui/icons/BarChart';
import {
GetBranchResult,
@@ -23,7 +24,7 @@ import {
} from '../../api/PluginApiClient';
import { Differ } from '../../components/Differ';
import { InfoCardPlus } from '../../components/InfoCardPlus';
import { Stats } from '../../components/Stats/Stats';
import { Stats } from '../Stats/Stats';
import { TEST_IDS } from '../../test-helpers/test-ids';
import { useProjectContext } from '../../contexts/ProjectContext';
import { useStyles } from '../../styles/styles';
@@ -32,16 +33,21 @@ import flowImage from './flow.png';
interface InfoCardProps {
releaseBranch: GetBranchResult | null;
latestRelease: GetLatestReleaseResult;
statsEnabled: boolean;
}
export const Info = ({ releaseBranch, latestRelease }: InfoCardProps) => {
export const Info = ({
releaseBranch,
latestRelease,
statsEnabled,
}: InfoCardProps) => {
const { project } = useProjectContext();
const classes = useStyles();
const [showStats, setShowStats] = useState(false);
return (
<InfoCardPlus>
<div style={{ marginBottom: '1em' }} data-testid={TEST_IDS.info.info}>
<Box marginBottom={1} data-testid={TEST_IDS.info.info}>
<Typography variant="h6">Terminology</Typography>
<Typography>
@@ -65,18 +71,9 @@ export const Info = ({ releaseBranch, latestRelease }: InfoCardProps) => {
<strong>Release Version</strong>: A GitHub release intended for end
users
</Typography>
</Box>
<Button
variant="contained"
color="secondary"
onClick={() => setShowStats(true)}
>
Show stats
</Button>
{showStats && <Stats setShowStats={setShowStats} />}
</div>
<div style={{ marginBottom: '1em' }}>
<Box marginBottom={1}>
<Typography variant="h6">Flow</Typography>
<Typography className={classes.paragraph}>
@@ -90,9 +87,9 @@ export const Info = ({ releaseBranch, latestRelease }: InfoCardProps) => {
</Typography>
<img alt="flow" src={flowImage} style={{ width: '100%' }} />
</div>
</Box>
<div style={{ marginBottom: '1em' }}>
<Box marginBottom={1}>
<Typography variant="h6">Details</Typography>
<Typography>
@@ -113,7 +110,23 @@ export const Info = ({ releaseBranch, latestRelease }: InfoCardProps) => {
<Typography>
Latest release: <Differ icon="tag" next={latestRelease?.tagName} />
</Typography>
</div>
</Box>
{statsEnabled && (
<Box>
<Button
variant="contained"
color="secondary"
onClick={() => setShowStats(true)}
startIcon={<BarChartIcon />}
size="small"
>
Show stats
</Button>
{showStats && <Stats setShowStats={setShowStats} />}
</Box>
)}
</InfoCardPlus>
);
};
@@ -0,0 +1,194 @@
/*
* 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 { Alert } from '@material-ui/lab';
import {
Box,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@material-ui/core';
import { CenteredCircularProgress } from '../../components/CenteredCircularProgress';
import { getMappedReleases } from './getMappedReleases';
import { getSummary } from './getSummary';
import { getTags } from './getTags';
import { Row } from './Row';
import { useGetStats } from './hooks/useGetStats';
import { useProjectContext } from '../../contexts/ProjectContext';
import { Warn } from './Warn';
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
export function DialogBody() {
const classes = useStyles();
const { stats } = useGetStats();
const { project } = useProjectContext();
if (stats.error) {
return (
<Alert severity="error">Unexpected error: {stats.error.message}</Alert>
);
}
if (stats.loading) {
return <CenteredCircularProgress />;
}
if (!stats.value) {
return <Alert severity="error">Couldn't find any stats :(</Alert>;
}
const { allReleases, allTags } = stats.value;
const mappedReleases = getMappedReleases({ allReleases, project });
const tags = getTags({ allTags, project, mappedReleases });
const summary = getSummary({ mappedReleases });
const shouldWarn =
tags.unmappable.length > 0 ||
tags.unmatched.length > 0 ||
mappedReleases.unmatched.length > 0;
if (shouldWarn) {
// eslint-disable-next-line no-console
console.log("⚠️ Here's a summary of unmapped/unmatched tags/releases", {
unmappableTags: tags.unmappable,
unmatchableTags: tags.unmatched,
unmatchableReleases: mappedReleases.unmatched,
});
}
const getDecimalNumber = (n: number) => {
if (isNaN(n)) {
return 0;
}
if (n.toString().includes('.')) {
return n.toFixed(2);
}
return n;
};
return (
<>
<Paper
variant="outlined"
style={{
padding: 20,
marginLeft: 25,
marginRight: 25,
marginBottom: 25,
}}
>
<Box margin={1}>
<Typography variant="h4">Summary</Typography>
<Typography variant="body2">
Total releases: {summary.totalReleases}
</Typography>
</Box>
<Box margin={1}>
<Typography variant="h6">Release Candidate</Typography>
<Typography variant="body2">
Release Candidate patches: {summary.totalCandidatePatches}
</Typography>
<Typography variant="body2">
Release Candidate patches per release:{' '}
{getDecimalNumber(
summary.totalCandidatePatches / summary.totalReleases,
)}
</Typography>
</Box>
<Box margin={1}>
<Typography variant="h6">Release Version</Typography>
<Typography variant="body2">
Release Version patches: {summary.totalVersionPatches}
</Typography>
<Typography variant="body2">
Release Version patches per release:{' '}
{getDecimalNumber(
summary.totalVersionPatches / summary.totalReleases,
)}
</Typography>
</Box>
<Box margin={1}>
<Typography variant="h6">Total</Typography>
<Typography variant="body2">
Patches:{' '}
{summary.totalCandidatePatches + summary.totalVersionPatches}
</Typography>
<Typography variant="body2">
Patches per release:{' '}
{getDecimalNumber(
(summary.totalCandidatePatches + summary.totalVersionPatches) /
summary.totalReleases,
)}
</Typography>
</Box>
</Paper>
<TableContainer component={Paper}>
<Table className={classes.table} size="small">
<TableHead>
<TableRow>
<TableCell />
<TableCell>Release</TableCell>
<TableCell>Created at</TableCell>
<TableCell># candidate patches</TableCell>
<TableCell># release patches</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(mappedReleases.releases).map(
([baseVersion, mappedRelease], index) => {
return (
<Row
key={`row-${index}`}
baseVersion={baseVersion}
mappedRelease={mappedRelease}
/>
);
},
)}
</TableBody>
</Table>
</TableContainer>
<Box marginTop={2}>
{shouldWarn && (
<Warn tags={tags} mappedReleases={mappedReleases} project={project} />
)}
</Box>
</>
);
}
@@ -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 React from 'react';
import {
createStyles,
IconButton,
Theme,
Typography,
withStyles,
WithStyles,
} from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import { Stats } from './Stats';
interface DialogTitleProps extends WithStyles<typeof styles> {
children: React.ReactNode;
setShowStats: React.ComponentProps<typeof Stats>['setShowStats'];
}
const styles = (theme: Theme) =>
createStyles({
root: {
margin: 0,
padding: theme.spacing(2),
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
});
export const DialogTitle = withStyles(styles)((props: DialogTitleProps) => {
const { children, classes, setShowStats, ...other } = props;
return (
<MuiDialogTitle disableTypography className={classes.root} {...other}>
<Typography variant="h6">{children}</Typography>
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={() => setShowStats(false)}
>
<CloseIcon />
</IconButton>
</MuiDialogTitle>
);
});
@@ -0,0 +1,131 @@
/*
* 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, { useState } from 'react';
import { DateTime } from 'luxon';
import {
Box,
Collapse,
IconButton,
Link,
makeStyles,
TableCell,
TableRow,
Typography,
} from '@material-ui/core';
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
import { getMappedReleases } from './getMappedReleases';
const useRowStyles = makeStyles({
root: {
'& > *': {
borderBottom: 'unset',
},
},
});
interface RowProps {
baseVersion: string;
mappedRelease: ReturnType<typeof getMappedReleases>['releases']['0'];
}
export function Row({ baseVersion, mappedRelease }: RowProps) {
const [open, setOpen] = useState(false);
const classes = useRowStyles();
const versions = mappedRelease.versions.reverse();
const candidates = mappedRelease.candidates.reverse();
const isPrerelease = versions.length === 0;
return (
<React.Fragment>
<TableRow className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
<Link href={mappedRelease.htmlUrl} target="_blank">
{baseVersion}
{isPrerelease ? ' (prerelease)' : ''}
</Link>
</TableCell>
<TableCell>
{mappedRelease.createdAt
? DateTime.fromISO(mappedRelease.createdAt)
.setLocale('sv-SE')
.toFormat('yyyy-MM-dd')
: '-'}
</TableCell>
<TableCell>{candidates.length}</TableCell>
<TableCell>{Math.max(0, versions.length - 1)}</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<div
style={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
}}
>
{!isPrerelease && (
<Box margin={1}>
{versions.map(version => (
<Typography key={version} variant="body1">
{version}
</Typography>
))}
</Box>
)}
{!isPrerelease && (
<Box
margin={1}
style={{ transform: 'rotate(-45deg)', fontSize: 30 }}
>
{' 🚀 '}
</Box>
)}
<Box margin={1}>
{candidates.map(candidate => (
<Typography key={candidate} variant="body1">
{candidate}
</Typography>
))}
</Box>
</div>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
@@ -0,0 +1,66 @@
/*
* 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 { Button, Dialog, Theme, withStyles } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import MuiDialogActions from '@material-ui/core/DialogActions';
import MuiDialogContent from '@material-ui/core/DialogContent';
import { DialogBody } from './DialogBody';
import { DialogTitle } from './DialogTitle';
import { Transition } from '../../components/Transition';
const DialogContent = withStyles((theme: Theme) => ({
root: {
padding: theme.spacing(2),
},
}))(MuiDialogContent);
const DialogActions = withStyles((theme: Theme) => ({
root: {
margin: 0,
padding: theme.spacing(1),
},
}))(MuiDialogActions);
interface StatsProps {
setShowStats: React.Dispatch<React.SetStateAction<boolean>>;
}
export function Stats({ setShowStats }: StatsProps) {
return (
<Dialog open maxWidth="md" fullWidth TransitionComponent={Transition}>
<DialogTitle setShowStats={setShowStats}>Stats</DialogTitle>
<DialogContent>
<DialogBody />
</DialogContent>
<DialogActions>
<Button
onClick={() => setShowStats(false)}
variant="contained"
size="large"
color="primary"
startIcon={<CloseIcon />}
>
Close
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,56 @@
/*
* 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 { Alert } from '@material-ui/lab';
import { getMappedReleases } from './getMappedReleases';
import { getTags } from './getTags';
import { Project } from '../../contexts/ProjectContext';
interface WarnProps {
tags: ReturnType<typeof getTags>;
mappedReleases: ReturnType<typeof getMappedReleases>;
project: Project;
}
export const Warn = ({ tags, mappedReleases, project }: WarnProps) => {
return (
<Alert severity="warning" style={{ marginBottom: 10 }}>
{tags.unmappable.length > 0 && (
<div>
Failed to map <strong>{tags.unmappable.length}</strong> tags to
releases
</div>
)}
{tags.unmatched.length > 0 && (
<div>
Failed to match <strong>{tags.unmatched.length}</strong> tags to{' '}
{project.versioningStrategy}
</div>
)}
{mappedReleases.unmatched.length > 0 && (
<div>
Failed to match <strong>{mappedReleases.unmatched.length}</strong>{' '}
releases to {project.versioningStrategy}
</div>
)}
<div>See full output in the console</div>
</Alert>
);
};
@@ -0,0 +1,74 @@
/*
* 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 { GetAllReleasesResult } from '../../api/PluginApiClient';
import { Project } from '../../contexts/ProjectContext';
import { semverRegexp } from '../../helpers/tagParts/getSemverTagParts';
export function getMappedReleases({
allReleases,
project,
}: {
allReleases: GetAllReleasesResult;
project: Project;
}) {
return allReleases.reduce(
(
acc: {
unmatched: string[];
releases: {
[baseVersion: string]: {
createdAt: string | null;
candidates: string[];
versions: string[];
htmlUrl: string;
};
};
},
release,
) => {
const match =
project.versioningStrategy === 'semver'
? release.tagName.match(semverRegexp)
: release.tagName.match(calverRegexp);
if (!match) {
acc.unmatched.push(release.tagName);
return acc;
}
const prefix = match[1];
const baseVersion =
project.versioningStrategy === 'semver'
? `${match[2]}.${match[3]}`
: match[2];
if (!acc.releases[baseVersion]) {
acc.releases[baseVersion] = {
createdAt: release.createdAt,
candidates: prefix === 'rc' ? [release.tagName] : [],
versions: prefix === 'version' ? [release.tagName] : [],
htmlUrl: release.htmlUrl,
};
return acc;
}
return acc;
},
{ unmatched: [], releases: {} },
);
}
@@ -0,0 +1,45 @@
/*
* 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';
export function getSummary({
mappedReleases,
}: {
mappedReleases: ReturnType<typeof getMappedReleases>;
}) {
return Object.entries(mappedReleases.releases).reduce(
(
acc: {
totalReleases: number;
totalCandidatePatches: number;
totalVersionPatches: number;
},
[_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,
},
);
}
@@ -0,0 +1,75 @@
/*
* 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 './getMappedReleases';
import { Project } from '../../contexts/ProjectContext';
import { semverRegexp } from '../../helpers/tagParts/getSemverTagParts';
export function getTags({
allTags,
project,
mappedReleases,
}: {
allTags: GetAllTagsResult;
project: Project;
mappedReleases: ReturnType<typeof getMappedReleases>;
}) {
return allTags.reduce(
(acc: { unmatched: string[]; unmappable: string[] }, tag) => {
const match =
project.versioningStrategy === 'semver'
? tag.tagName.match(semverRegexp)
: tag.tagName.match(calverRegexp);
if (!match) {
acc.unmatched.push(tag.tagName);
return acc;
}
const prefix = match[1];
const baseVersion =
project.versioningStrategy === 'semver'
? `${match[2]}.${match[3]}`
: match[2];
if (!mappedReleases.releases[baseVersion]) {
acc.unmappable.push(tag.tagName);
return acc;
}
if (
prefix === 'rc' &&
!mappedReleases.releases[baseVersion].candidates.includes(tag.tagName)
) {
mappedReleases.releases[baseVersion].candidates.push(tag.tagName);
return acc;
}
if (
prefix === 'version' &&
!mappedReleases.releases[baseVersion].versions.includes(tag.tagName)
) {
mappedReleases.releases[baseVersion].versions.push(tag.tagName);
return acc;
}
return acc;
},
{ unmatched: [], unmappable: [] },
);
}
@@ -0,0 +1,47 @@
/*
* 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 { useAsync } from 'react-use';
import { usePluginApiClientContext } from '../../../contexts/PluginApiClientContext';
import { useProjectContext } from '../../../contexts/ProjectContext';
export const useGetStats = () => {
const { pluginApiClient } = usePluginApiClientContext();
const { project } = useProjectContext();
const stats = useAsync(async () => {
const [allReleases, allTags] = await Promise.all([
pluginApiClient.stats.getAllReleases({
owner: project.owner,
repo: project.repo,
}),
pluginApiClient.stats.getAllTags({
owner: project.owner,
repo: project.repo,
}),
]);
return {
allReleases,
allTags,
};
}, [project]);
return {
stats,
};
};
@@ -49,6 +49,10 @@ describe('testHelpers', () => {
"promoteRc": Object {
"promoteRelease": [MockFunction],
},
"stats": Object {
"getAllReleases": [MockFunction],
"getAllTags": [MockFunction],
},
},
"mockBumpedTag": "rc-2020.01.01_1337",
"mockCalverProject": Object {
@@ -278,4 +278,14 @@ export const mockApiClient: IPluginApiClient = {
htmlUrl: 'mock_release_html_url',
})),
},
stats: {
getAllTags: jest.fn(async () => {
throw new Error('Not implemented');
}),
getAllReleases: jest.fn(async () => {
throw new Error('Not implemented');
}),
},
};