Merge pull request #5299 from erikengervall/erikengervall/plugin-release-manager-as-a-service
plugins: add GitHub Release Manager
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: GitHub Release Manager
|
||||
author: '@Spotify'
|
||||
authorUrl: https://github.com/spotify
|
||||
category: Release management
|
||||
description: Manage releases without having to juggle git commands
|
||||
documentation: https://github.com/backstage/backstage/tree/master/plugins/git-release-manager
|
||||
iconUrl: img/git-release-manager-logo.svg
|
||||
npmPackageName: '@backstage/plugin-git-release-manager'
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Export</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ship" transform="translate(249.680798, 211.000000) scale(-1, 1) translate(-249.680798, -211.000000) translate(35.680798, -39.000000)" fill-rule="nonzero">
|
||||
<g id="Flamez" transform="translate(279.925187, 455.280081) rotate(-90.000000) translate(-279.925187, -455.280081) translate(235.660848, 411.639183)">
|
||||
<path d="M77.6240648,9.79348504 C64.5660848,-3.26449501 42.8017456,-3.26449501 29.7437656,9.79348504 C11.6072319,27.9300187 0.725685786,80.8882481 8.85963511e-15,83.064682 C8.85963511e-15,84.5154302 8.85963511e-15,85.241116 0.725685786,85.9668017 C2.17643392,86.6924875 3.62780549,86.6924875 5.07855362,86.6924875 C7.25498753,85.9668017 60.213217,75.810318 78.3497506,56.9487219 C91.4077307,43.8901185 91.4077307,22.8514651 77.6240648,9.79348504 Z" id="Path" fill="#F79548"></path>
|
||||
<path d="M75.4476309,10.5185474 C68.9183292,3.98924564 58.036783,3.98924564 51.5074813,10.5185474 C42.0766833,19.9493454 36.9981297,46.0659289 36.9981297,46.7916147 C36.9981297,47.5173005 36.9981297,48.2423628 37.7238155,48.2423628 C38.4495012,48.9680486 38.4495012,48.9680486 39.1745636,48.9680486 C40.6253117,48.9680486 66.7418953,43.1644327 75.4476309,34.458697 C81.9769327,27.9300187 81.9769327,17.0478491 75.4476309,10.5185474 Z" id="Path" fill="#FCBB76"></path>
|
||||
</g>
|
||||
<path d="M216.186409,55.2394015 C218.49257,55.2394015 220.58047,56.1742348 222.091833,57.6855981 C223.534498,59.128263 224.451854,61.0962436 224.532269,63.2779325 L224.53803,63.5910224 L224.53803,91.005836 L353.369587,261.704121 C354.323776,262.968115 354.87911,264.447677 355.019478,265.965658 C355.159883,267.484039 354.88511,269.040862 354.179061,270.458806 C353.472845,271.876805 352.395871,273.033852 351.099552,273.836436 C349.895645,274.581806 348.502551,275.021453 347.041547,275.080457 L346.703242,275.087282 L224.53803,275.087282 L224.53803,303.904613 L346.684539,303.904613 C348.308638,303.904613 349.858968,304.375354 351.174692,305.21426 C352.490519,306.053232 353.571696,307.260417 354.25731,308.732969 C354.942424,310.205367 355.16974,311.809443 354.964363,313.356104 C354.77365,314.792333 354.20983,316.179047 353.29311,317.364769 L353.075304,317.634761 L309.451382,369.468132 C307.930114,371.274871 305.719044,372.349921 303.368443,372.436371 L303.061097,372.44202 L124.602244,372.44202 C122.240117,372.44202 119.994057,371.442358 118.41433,369.699238 L118.212103,369.467679 L74.5886595,317.634759 C73.5428392,316.392134 72.9047814,314.903039 72.6994228,313.356514 C72.494029,311.809724 72.7214803,310.205497 73.4066556,308.732969 C74.0917295,307.260659 75.1726391,306.053772 76.4882065,305.214939 C77.7099169,304.435952 79.1339993,303.974371 80.6313862,303.912406 L80.9781796,303.905237 L207.834788,303.905237 L207.834788,274.720698 L106.28616,274.720698 C103.979999,274.720698 101.892098,273.785865 100.380735,272.274502 C98.8693719,270.763138 97.9345387,268.675238 97.9345387,266.369077 C97.9345387,233.249109 111.496611,180.759139 130.891816,138.720038 C142.0216,114.595575 154.255888,95.1994886 167.266567,81.0803824 C183.02715,63.976526 199.471354,55.2394015 216.186409,55.2394015 Z M328.739655,320.607855 L98.9224399,320.607855 L128.488453,355.738155 L299.173642,355.738155 L328.739655,320.607855 Z M224.53803,118.732589 L224.53803,258.384663 L329.936488,258.384663 L224.53803,118.732589 Z M207.834165,73.0839035 C187.31365,78.5957312 165.169959,104.292863 146.058313,145.71761 C129.135086,182.399574 116.887708,227.020442 114.917014,258.016833 L114.917014,258.016833 L207.834165,258.016833 Z" id="Shape" stroke="#DDDDDD" stroke-width="2" fill="#E8E8E8" transform="translate(213.840829, 213.840711) rotate(45.000000) translate(-213.840829, -213.840711) "></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
# Git Release Manager (GRM)
|
||||
|
||||
## Overview
|
||||
|
||||
`GRM` enables developers to manage their releases without having to juggle git commands.
|
||||
|
||||
Does it build and ship your code? **No**.
|
||||
|
||||
What `GRM` does is manage your Git **[releases](https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository)**, building and shipping is entirely up to you as a developer to handle in your CI.
|
||||
|
||||
`GRM` is built with industry standards in mind and the flow is as follows:
|
||||
|
||||

|
||||
|
||||
> **Git**: The source control system where releases reside in a practical sense. Read more about [Git releases](https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository). (Note that this plugin works just as well with any system implementing `Git`.)
|
||||
>
|
||||
> **Release Candidate (RC)**: A Git pre-release intended primarily for internal testing
|
||||
>
|
||||
> **Release Version**: A Git release intended for end users
|
||||
|
||||
Looking at the flow above, a common release lifecycle could be:
|
||||
|
||||
- User presses **Create Release Candidate**
|
||||
- `GRM`
|
||||
1. Creates a release branch `rc/<version>`
|
||||
1. Creates Release Candidate tag `rc-<version>`
|
||||
1. Creates a Git prerelease with Release Candidate tag
|
||||
- Your CI
|
||||
1. Detects the new tag by matching the git reference `refs/tags/rc-.*`
|
||||
1. Builds and deploys to staging environment for testing
|
||||
- User presses **Patch**
|
||||
- `GRM`
|
||||
1. The selected commit is cherry-picked to the release branch
|
||||
1. The release tag is bumped
|
||||
1. Updates Git release's tag and description with the patch's details
|
||||
- Your CI
|
||||
1. Detects the new tag by matching the git reference `refs/tags/(rc|version)-.*` (Release Versions are patchable as well)
|
||||
1. Builds and deploys to staging (or production if Release Version) for testing
|
||||
- User presses **Promote Release Candidate to Release Version**
|
||||
- `GRM`
|
||||
1. Creates Release Version tag `version-<version>`
|
||||
1. Promotes the Git release by removing the prerelease flag
|
||||
- Your CI
|
||||
1. Detects the new tag by matching the git reference `refs/tags/version-.*`
|
||||
1. Builds and deploys to production for testing
|
||||
|
||||
## Usage
|
||||
|
||||
### Importing
|
||||
|
||||
The plugin exports a single full-page extension `GitReleaseManagerPage`, which one can add to an app like a usual top-level tool on a dedicated route.
|
||||
|
||||
### Configuration
|
||||
|
||||
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 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.
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 { createDevApp } from '@backstage/dev-utils';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
|
||||
import { gitReleaseManagerPlugin, GitReleaseManagerPage } from '../src/plugin';
|
||||
import { InfoCardPlus } from '../src/components/InfoCardPlus';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(gitReleaseManagerPlugin)
|
||||
.addPage({
|
||||
title: 'Dynamic',
|
||||
path: '/dynamic',
|
||||
element: (
|
||||
<Box padding={4}>
|
||||
<InfoCardPlus>
|
||||
<Typography variant="h4">Dev notes</Typography>
|
||||
<Typography>Configure plugin via select inputs</Typography>
|
||||
</InfoCardPlus>
|
||||
|
||||
<GitReleaseManagerPage />
|
||||
</Box>
|
||||
),
|
||||
})
|
||||
.addPage({
|
||||
title: 'Static',
|
||||
path: '/static',
|
||||
element: (
|
||||
<Box padding={4}>
|
||||
<InfoCardPlus>
|
||||
<Typography variant="h4">Dev notes</Typography>
|
||||
<Typography>
|
||||
Configure plugin statically by passing props to the
|
||||
`GitHubReleaseManagerPage` component
|
||||
</Typography>
|
||||
</InfoCardPlus>
|
||||
|
||||
<GitReleaseManagerPage
|
||||
project={{
|
||||
owner: 'eengervall-playground',
|
||||
repo: 'RMaaS-semver',
|
||||
versioningStrategy: 'semver',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
})
|
||||
.addPage({
|
||||
title: 'Omit',
|
||||
path: '/omit',
|
||||
element: (
|
||||
<Box padding={4}>
|
||||
<InfoCardPlus>
|
||||
<Typography variant="h4">Dev notes</Typography>
|
||||
<Typography>Each feature can be omitted</Typography>
|
||||
<Typography>Success callbacks can also be added</Typography>
|
||||
</InfoCardPlus>
|
||||
|
||||
<GitReleaseManagerPage
|
||||
project={{
|
||||
owner: 'eengervall-playground',
|
||||
repo: 'playground-semver',
|
||||
versioningStrategy: 'semver',
|
||||
}}
|
||||
features={{
|
||||
createRc: {
|
||||
onSuccess: ({
|
||||
comparisonUrl,
|
||||
createdTag,
|
||||
gitReleaseName,
|
||||
gitReleaseUrl,
|
||||
previousTag,
|
||||
}) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'Custom success callback for Create RC',
|
||||
comparisonUrl,
|
||||
createdTag,
|
||||
gitReleaseName,
|
||||
gitReleaseUrl,
|
||||
previousTag,
|
||||
);
|
||||
},
|
||||
},
|
||||
promoteRc: {
|
||||
omit: true,
|
||||
},
|
||||
patch: {
|
||||
omit: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
})
|
||||
.render();
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@backstage/plugin-git-release-manager",
|
||||
"version": "0.1.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"start": "backstage-cli plugin:serve",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"diff": "backstage-cli plugin:diff",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.7.8",
|
||||
"@backstage/integration": "^0.5.1",
|
||||
"@backstage/theme": "^0.2.7",
|
||||
"recharts": "^1.8.5",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@octokit/rest": "^18.5.3",
|
||||
"luxon": "^1.26.0",
|
||||
"qs": "^6.10.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4",
|
||||
"react": "^16.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.6.10",
|
||||
"@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",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.21.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 { useAsync } from 'react-use';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { useApi, ContentHeader, Progress } from '@backstage/core';
|
||||
import { Box } from '@material-ui/core';
|
||||
|
||||
import {
|
||||
ComponentConfig,
|
||||
ComponentConfigCreateRc,
|
||||
ComponentConfigPatch,
|
||||
ComponentConfigPromoteRc,
|
||||
} from './types/types';
|
||||
import { Features } from './features/Features';
|
||||
import { gitReleaseManagerApiRef } from './api/serviceApiRef';
|
||||
import { InfoCardPlus } from './components/InfoCardPlus';
|
||||
import { isProjectValid } from './helpers/isProjectValid';
|
||||
import { ProjectContext, Project } from './contexts/ProjectContext';
|
||||
import { RepoDetailsForm } from './features/RepoDetailsForm/RepoDetailsForm';
|
||||
import { useQueryHandler } from './hooks/useQueryHandler';
|
||||
import { UserContext } from './contexts/UserContext';
|
||||
|
||||
interface GitReleaseManagerProps {
|
||||
project?: Omit<Project, 'isProvidedViaProps'>;
|
||||
features?: {
|
||||
info?: Pick<ComponentConfig<void>, 'omit'>;
|
||||
stats?: Pick<ComponentConfig<void>, 'omit'>;
|
||||
createRc?: ComponentConfigCreateRc;
|
||||
promoteRc?: ComponentConfigPromoteRc;
|
||||
patch?: ComponentConfigPatch;
|
||||
};
|
||||
}
|
||||
|
||||
export function GitReleaseManager(props: GitReleaseManagerProps) {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
|
||||
const { getParsedQuery } = useQueryHandler();
|
||||
const { parsedQuery } = getParsedQuery();
|
||||
const project: Project = isProjectValid(props.project)
|
||||
? {
|
||||
...props.project,
|
||||
isProvidedViaProps: true,
|
||||
}
|
||||
: {
|
||||
owner: parsedQuery.owner ?? '',
|
||||
repo: parsedQuery.repo ?? '',
|
||||
versioningStrategy: parsedQuery.versioningStrategy ?? 'semver',
|
||||
isProvidedViaProps: false,
|
||||
};
|
||||
|
||||
const userResponse = useAsync(() =>
|
||||
pluginApiClient.getUser({ owner: project.owner, repo: project.repo }),
|
||||
);
|
||||
|
||||
if (userResponse.error) {
|
||||
return <Alert severity="error">{userResponse.error.message}</Alert>;
|
||||
}
|
||||
|
||||
if (userResponse.loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (!userResponse.value?.user.username) {
|
||||
return <Alert severity="error">Unable to retrieve username</Alert>;
|
||||
}
|
||||
|
||||
const user = userResponse.value.user;
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider value={{ project }}>
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<Box maxWidth={999}>
|
||||
<ContentHeader title="Git Release Manager" />
|
||||
|
||||
<InfoCardPlus>
|
||||
<RepoDetailsForm />
|
||||
</InfoCardPlus>
|
||||
|
||||
{isProjectValid(project) && <Features features={props.features} />}
|
||||
</Box>
|
||||
</UserContext.Provider>
|
||||
</ProjectContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 { ConfigReader, OAuthApi } from '@backstage/core';
|
||||
|
||||
import { GitReleaseClient } from './GitReleaseClient';
|
||||
|
||||
describe('GitReleaseClient', () => {
|
||||
it('should return the default plugin api client', () => {
|
||||
const configApi = new ConfigReader({});
|
||||
const githubAuthApi: OAuthApi = {
|
||||
getAccessToken: jest.fn(),
|
||||
};
|
||||
const gitReleaseClient = new GitReleaseClient({
|
||||
configApi,
|
||||
githubAuthApi,
|
||||
});
|
||||
|
||||
expect(gitReleaseClient).toMatchInlineSnapshot(`
|
||||
GitReleaseClient {
|
||||
"apiBaseUrl": "https://api.github.com",
|
||||
"createCommit": [Function],
|
||||
"createRef": [Function],
|
||||
"createRelease": [Function],
|
||||
"createTagObject": [Function],
|
||||
"getAllReleases": [Function],
|
||||
"getAllTags": [Function],
|
||||
"getBranch": [Function],
|
||||
"getCommit": [Function],
|
||||
"getComparison": [Function],
|
||||
"getHost": [Function],
|
||||
"getLatestRelease": [Function],
|
||||
"getOwners": [Function],
|
||||
"getRecentCommits": [Function],
|
||||
"getRepoPath": [Function],
|
||||
"getRepositories": [Function],
|
||||
"getRepository": [Function],
|
||||
"getTag": [Function],
|
||||
"getUser": [Function],
|
||||
"githubAuthApi": Object {
|
||||
"getAccessToken": [MockFunction],
|
||||
},
|
||||
"host": "github.com",
|
||||
"merge": [Function],
|
||||
"updateRef": [Function],
|
||||
"updateRelease": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,843 @@
|
||||
/*
|
||||
* 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 { ConfigApi, OAuthApi } from '@backstage/core';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { GitHubIntegration, ScmIntegrations } from '@backstage/integration';
|
||||
|
||||
import { DISABLE_CACHE } from '../constants/constants';
|
||||
import { Project } from '../contexts/ProjectContext';
|
||||
import { UnboxArray, UnboxReturnedPromise } from '../types/helpers';
|
||||
import { GitReleaseManagerError } from '../errors/GitReleaseManagerError';
|
||||
|
||||
export class GitReleaseClient implements GitReleaseApi {
|
||||
private readonly githubAuthApi: OAuthApi;
|
||||
private readonly apiBaseUrl: string;
|
||||
readonly host: string;
|
||||
|
||||
constructor({
|
||||
configApi,
|
||||
githubAuthApi,
|
||||
}: {
|
||||
configApi: ConfigApi;
|
||||
githubAuthApi: OAuthApi;
|
||||
}) {
|
||||
this.githubAuthApi = githubAuthApi;
|
||||
|
||||
const gitHubIntegrations = ScmIntegrations.fromConfig(
|
||||
configApi,
|
||||
).github.list();
|
||||
const { host, apiBaseUrl } = this.getGithubIntegrationConfig({
|
||||
gitHubIntegrations,
|
||||
});
|
||||
|
||||
this.host = host;
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
private getGithubIntegrationConfig({
|
||||
gitHubIntegrations,
|
||||
}: {
|
||||
gitHubIntegrations: GitHubIntegration[];
|
||||
}) {
|
||||
const defaultIntegration = gitHubIntegrations.find(
|
||||
({ config: { host } }) => host === 'github.com',
|
||||
);
|
||||
const enterpriseIntegration = gitHubIntegrations.find(
|
||||
({ config: { host } }) => host !== 'github.com',
|
||||
);
|
||||
|
||||
const host =
|
||||
enterpriseIntegration?.config.host ?? defaultIntegration?.config.host;
|
||||
const apiBaseUrl =
|
||||
enterpriseIntegration?.config.apiBaseUrl ??
|
||||
defaultIntegration?.config.apiBaseUrl;
|
||||
|
||||
if (!host) {
|
||||
throw new GitReleaseManagerError(
|
||||
'Invalid API configuration: missing host',
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
throw new GitReleaseManagerError(
|
||||
'Invalid API configuration: missing apiBaseUrl',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
apiBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private async getOctokit() {
|
||||
const token = await this.githubAuthApi.getAccessToken(['repo']);
|
||||
|
||||
return {
|
||||
octokit: new Octokit({
|
||||
auth: token,
|
||||
baseUrl: this.apiBaseUrl,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public getHost: GitReleaseApi['getHost'] = () => {
|
||||
return this.host;
|
||||
};
|
||||
|
||||
public getRepoPath: GitReleaseApi['getRepoPath'] = ({ owner, repo }) => {
|
||||
return `${owner}/${repo}`;
|
||||
};
|
||||
|
||||
getOwners: GitReleaseApi['getOwners'] = async () => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const orgListResponse = await octokit.paginate(
|
||||
octokit.orgs.listForAuthenticatedUser,
|
||||
{ per_page: 100 },
|
||||
);
|
||||
|
||||
return {
|
||||
owners: orgListResponse.map(organization => organization.login),
|
||||
};
|
||||
};
|
||||
|
||||
getRepositories: GitReleaseApi['getRepositories'] = async ({ owner }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
|
||||
const repositoryResponse = await octokit
|
||||
.paginate(octokit.repos.listForOrg, { org: owner, per_page: 100 })
|
||||
.catch(async error => {
|
||||
// `owner` is not an org, try listing a user's repositories instead
|
||||
if (error.status === 404) {
|
||||
const userRepositoryResponse = await octokit.paginate(
|
||||
octokit.repos.listForUser,
|
||||
{ username: owner, per_page: 100 },
|
||||
);
|
||||
return userRepositoryResponse;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return {
|
||||
repositories: repositoryResponse.map(repository => repository.name),
|
||||
};
|
||||
};
|
||||
|
||||
getUser: GitReleaseApi['getUser'] = async () => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const userResponse = await octokit.users.getAuthenticated();
|
||||
|
||||
return {
|
||||
user: {
|
||||
username: userResponse.data.login,
|
||||
email: userResponse.data.email ?? undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getRecentCommits: GitReleaseApi['getRecentCommits'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
releaseBranchName,
|
||||
}) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const recentCommitsResponse = await octokit.repos.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
...(releaseBranchName ? { sha: releaseBranchName } : {}),
|
||||
...DISABLE_CACHE,
|
||||
});
|
||||
|
||||
return {
|
||||
recentCommits: recentCommitsResponse.data.map(commit => ({
|
||||
htmlUrl: commit.html_url,
|
||||
sha: commit.sha,
|
||||
author: {
|
||||
htmlUrl: commit.author?.html_url,
|
||||
login: commit.author?.login,
|
||||
},
|
||||
commit: {
|
||||
message: commit.commit.message,
|
||||
},
|
||||
firstParentSha: commit.parents?.[0]?.sha,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
getLatestRelease: GitReleaseApi['getLatestRelease'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
}) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const { data: latestReleases } = await octokit.repos.listReleases({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 1,
|
||||
...DISABLE_CACHE,
|
||||
});
|
||||
|
||||
if (latestReleases.length === 0) {
|
||||
return {
|
||||
latestRelease: null,
|
||||
};
|
||||
}
|
||||
|
||||
const latestRelease = latestReleases[0];
|
||||
|
||||
return {
|
||||
latestRelease: {
|
||||
targetCommitish: latestRelease.target_commitish,
|
||||
tagName: latestRelease.tag_name,
|
||||
prerelease: latestRelease.prerelease,
|
||||
id: latestRelease.id,
|
||||
htmlUrl: latestRelease.html_url,
|
||||
body: latestRelease.body,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getRepository: GitReleaseApi['getRepository'] = async ({ owner, repo }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const { data: repository } = await octokit.repos.get({
|
||||
owner,
|
||||
repo,
|
||||
...DISABLE_CACHE,
|
||||
});
|
||||
|
||||
return {
|
||||
repository: {
|
||||
pushPermissions: repository.permissions?.push,
|
||||
defaultBranch: repository.default_branch,
|
||||
name: repository.name,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getCommit: GitReleaseApi['getCommit'] = async ({ owner, repo, ref }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const { data: commit } = await octokit.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
...DISABLE_CACHE,
|
||||
});
|
||||
|
||||
return {
|
||||
commit: {
|
||||
sha: commit.sha,
|
||||
htmlUrl: commit.html_url,
|
||||
commit: {
|
||||
message: commit.commit.message,
|
||||
},
|
||||
createdAt: commit.commit.committer?.date,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getBranch: GitReleaseApi['getBranch'] = async ({ owner, repo, branch }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
|
||||
const { data: branchData } = await octokit.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
...DISABLE_CACHE,
|
||||
});
|
||||
|
||||
return {
|
||||
branch: {
|
||||
name: branchData.name,
|
||||
links: {
|
||||
html: branchData._links.html,
|
||||
},
|
||||
commit: {
|
||||
sha: branchData.commit.sha,
|
||||
commit: {
|
||||
tree: {
|
||||
sha: branchData.commit.commit.tree.sha,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
createRef: GitReleaseApi['createRef'] = async ({ owner, repo, sha, ref }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const createRefResponse = await octokit.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
sha,
|
||||
});
|
||||
|
||||
return {
|
||||
reference: {
|
||||
ref: createRefResponse.data.ref,
|
||||
objectSha: createRefResponse.data.object.sha,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getComparison: GitReleaseApi['getComparison'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
base,
|
||||
head,
|
||||
}) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const compareCommitsResponse = await octokit.repos.compareCommits({
|
||||
owner,
|
||||
repo,
|
||||
base,
|
||||
head,
|
||||
});
|
||||
|
||||
return {
|
||||
comparison: {
|
||||
htmlUrl: compareCommitsResponse.data.html_url,
|
||||
aheadBy: compareCommitsResponse.data.ahead_by,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
createRelease: GitReleaseApi['createRelease'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
tagName,
|
||||
name,
|
||||
targetCommitish,
|
||||
body,
|
||||
}) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const createReleaseResponse = await octokit.repos.createRelease({
|
||||
owner,
|
||||
repo,
|
||||
tag_name: tagName,
|
||||
name: name,
|
||||
target_commitish: targetCommitish,
|
||||
body,
|
||||
prerelease: true,
|
||||
});
|
||||
|
||||
return {
|
||||
release: {
|
||||
name: createReleaseResponse.data.name,
|
||||
htmlUrl: createReleaseResponse.data.html_url,
|
||||
tagName: createReleaseResponse.data.tag_name,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
createTagObject: GitReleaseApi['createTagObject'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
object,
|
||||
taggerName,
|
||||
taggerEmail,
|
||||
message,
|
||||
}) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const { data: createdTagObject } = await octokit.git.createTag({
|
||||
owner,
|
||||
repo,
|
||||
message,
|
||||
tag,
|
||||
object,
|
||||
type: 'commit',
|
||||
...(taggerEmail
|
||||
? {
|
||||
tagger: {
|
||||
date: new Date().toISOString(),
|
||||
email: taggerEmail,
|
||||
name: taggerName,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
return {
|
||||
tagObject: {
|
||||
tagName: createdTagObject.tag,
|
||||
tagSha: createdTagObject.sha,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
createCommit: GitReleaseApi['createCommit'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
message,
|
||||
tree,
|
||||
parents,
|
||||
}) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const { data: commit } = await octokit.git.createCommit({
|
||||
owner,
|
||||
repo,
|
||||
message,
|
||||
tree,
|
||||
parents,
|
||||
});
|
||||
|
||||
return {
|
||||
commit: {
|
||||
message: commit.message,
|
||||
sha: commit.sha,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
updateRef: GitReleaseApi['updateRef'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
sha,
|
||||
force,
|
||||
}) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const { data: updatedRef } = await octokit.git.updateRef({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
sha,
|
||||
force,
|
||||
});
|
||||
|
||||
return {
|
||||
reference: {
|
||||
ref: updatedRef.ref,
|
||||
object: {
|
||||
sha: updatedRef.object.sha,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
merge: GitReleaseApi['merge'] = async ({ owner, repo, base, head }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const { data: merge } = await octokit.repos.merge({
|
||||
owner,
|
||||
repo,
|
||||
base,
|
||||
head,
|
||||
});
|
||||
|
||||
return {
|
||||
merge: {
|
||||
htmlUrl: merge.html_url,
|
||||
commit: {
|
||||
message: merge.commit.message,
|
||||
tree: {
|
||||
sha: merge.commit.tree.sha,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
updateRelease: GitReleaseApi['updateRelease'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
releaseId,
|
||||
tagName,
|
||||
body,
|
||||
prerelease,
|
||||
}) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const { data: updatedRelease } = await octokit.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
tag_name: tagName,
|
||||
body,
|
||||
prerelease,
|
||||
});
|
||||
|
||||
return {
|
||||
release: {
|
||||
name: updatedRelease.name,
|
||||
tagName: updatedRelease.tag_name,
|
||||
htmlUrl: updatedRelease.html_url,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getAllTags: GitReleaseApi['getAllTags'] = async ({ owner, repo }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
|
||||
const tags = await octokit.paginate(octokit.git.listMatchingRefs, {
|
||||
owner,
|
||||
repo,
|
||||
ref: 'tags',
|
||||
per_page: 100,
|
||||
...DISABLE_CACHE,
|
||||
});
|
||||
|
||||
return {
|
||||
tags: tags
|
||||
.map(tag => ({
|
||||
tagName: tag.ref.replace('refs/tags/', ''),
|
||||
tagSha: tag.object.sha,
|
||||
tagType: tag.object.type as 'tag' | 'commit',
|
||||
}))
|
||||
.reverse(),
|
||||
};
|
||||
};
|
||||
|
||||
getAllReleases: GitReleaseApi['getAllReleases'] = async ({ owner, repo }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
|
||||
const releases = await octokit.paginate(octokit.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
...DISABLE_CACHE,
|
||||
});
|
||||
|
||||
return {
|
||||
releases: releases.map(release => ({
|
||||
id: release.id,
|
||||
name: release.name,
|
||||
tagName: release.tag_name,
|
||||
createdAt: release.published_at,
|
||||
htmlUrl: release.html_url,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
getTag: GitReleaseApi['getTag'] = async ({ owner, repo, tagSha }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const singleTag = await octokit.git.getTag({
|
||||
owner,
|
||||
repo,
|
||||
tag_sha: tagSha,
|
||||
});
|
||||
|
||||
return {
|
||||
tag: {
|
||||
date: singleTag.data.tagger.date,
|
||||
username: singleTag.data.tagger.name,
|
||||
userEmail: singleTag.data.tagger.email,
|
||||
objectSha: singleTag.data.object.sha,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type OwnerRepo = {
|
||||
owner: Project['owner'];
|
||||
repo: Project['repo'];
|
||||
};
|
||||
|
||||
export interface GitReleaseApi {
|
||||
getHost: () => string;
|
||||
|
||||
getRepoPath: (args: OwnerRepo) => string;
|
||||
|
||||
getOwners: () => Promise<{
|
||||
owners: string[];
|
||||
}>;
|
||||
|
||||
getRepositories: (args: {
|
||||
owner: OwnerRepo['owner'];
|
||||
}) => Promise<{
|
||||
repositories: string[];
|
||||
}>;
|
||||
|
||||
getUser: (
|
||||
args: OwnerRepo,
|
||||
) => Promise<{
|
||||
user: {
|
||||
username: string;
|
||||
email?: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
getRecentCommits: (
|
||||
args: {
|
||||
releaseBranchName?: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
recentCommits: {
|
||||
htmlUrl: string;
|
||||
sha: string;
|
||||
author: {
|
||||
htmlUrl?: string;
|
||||
login?: string;
|
||||
};
|
||||
commit: {
|
||||
message: string;
|
||||
};
|
||||
firstParentSha?: string;
|
||||
}[];
|
||||
}>;
|
||||
|
||||
getLatestRelease: (
|
||||
args: OwnerRepo,
|
||||
) => Promise<{
|
||||
latestRelease: {
|
||||
targetCommitish: string;
|
||||
tagName: string;
|
||||
prerelease: boolean;
|
||||
id: number;
|
||||
htmlUrl: string;
|
||||
body?: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
|
||||
getRepository: (
|
||||
args: OwnerRepo,
|
||||
) => Promise<{
|
||||
repository: {
|
||||
pushPermissions: boolean | undefined;
|
||||
defaultBranch: string;
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
getCommit: (
|
||||
args: {
|
||||
ref: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
commit: {
|
||||
sha: string;
|
||||
htmlUrl: string;
|
||||
commit: {
|
||||
message: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
getBranch: (
|
||||
args: {
|
||||
branch: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
branch: {
|
||||
name: string;
|
||||
links: {
|
||||
html: string;
|
||||
};
|
||||
commit: {
|
||||
sha: string;
|
||||
commit: {
|
||||
tree: {
|
||||
sha: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
createRef: (
|
||||
args: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
reference: {
|
||||
ref: string;
|
||||
objectSha: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
getComparison: (
|
||||
args: {
|
||||
base: string;
|
||||
head: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
comparison: {
|
||||
htmlUrl: string;
|
||||
aheadBy: number;
|
||||
};
|
||||
}>;
|
||||
|
||||
createRelease: (
|
||||
args: {
|
||||
tagName: string;
|
||||
name: string;
|
||||
targetCommitish: string;
|
||||
body: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
release: {
|
||||
name: string | null;
|
||||
htmlUrl: string;
|
||||
tagName: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
createTagObject: (
|
||||
args: {
|
||||
tag: string;
|
||||
taggerEmail?: string;
|
||||
message: string;
|
||||
object: string;
|
||||
taggerName: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
tagObject: {
|
||||
tagName: string;
|
||||
tagSha: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
createCommit: (
|
||||
args: {
|
||||
message: string;
|
||||
tree: string;
|
||||
parents: string[];
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
commit: {
|
||||
message: string;
|
||||
sha: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
updateRef: (
|
||||
args: {
|
||||
sha: string;
|
||||
ref: string;
|
||||
force: boolean;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
reference: {
|
||||
ref: string;
|
||||
object: {
|
||||
sha: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
merge: (
|
||||
args: {
|
||||
base: string;
|
||||
head: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
merge: {
|
||||
htmlUrl: string;
|
||||
commit: {
|
||||
message: string;
|
||||
tree: {
|
||||
sha: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
updateRelease: (
|
||||
args: {
|
||||
releaseId: number;
|
||||
tagName: string;
|
||||
body?: string;
|
||||
prerelease?: boolean;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
release: {
|
||||
name: string | null;
|
||||
tagName: string;
|
||||
htmlUrl: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Get all tags in descending order
|
||||
*/
|
||||
getAllTags: (
|
||||
args: OwnerRepo,
|
||||
) => Promise<{
|
||||
tags: Array<{
|
||||
tagName: string;
|
||||
tagSha: string;
|
||||
tagType: 'tag' | 'commit';
|
||||
}>;
|
||||
}>;
|
||||
|
||||
getAllReleases: (
|
||||
args: OwnerRepo,
|
||||
) => Promise<{
|
||||
releases: Array<{
|
||||
id: number;
|
||||
name: string | null;
|
||||
tagName: string;
|
||||
createdAt: string | null;
|
||||
htmlUrl: string;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
getTag: (
|
||||
args: {
|
||||
tagSha: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
tag: {
|
||||
date: string;
|
||||
username: string;
|
||||
userEmail: string;
|
||||
objectSha: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export type GetOwnersResult = UnboxReturnedPromise<GitReleaseApi['getOwners']>;
|
||||
export type GetRepositoriesResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['getRepositories']
|
||||
>;
|
||||
export type GetUserResult = UnboxReturnedPromise<GitReleaseApi['getUser']>;
|
||||
export type GetRecentCommitsResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['getRecentCommits']
|
||||
>;
|
||||
export type GetRecentCommitsResultSingle = UnboxArray<
|
||||
GetRecentCommitsResult['recentCommits']
|
||||
>;
|
||||
export type GetLatestReleaseResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['getLatestRelease']
|
||||
>;
|
||||
export type GetRepositoryResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['getRepository']
|
||||
>;
|
||||
export type GetCommitResult = UnboxReturnedPromise<GitReleaseApi['getCommit']>;
|
||||
export type GetBranchResult = UnboxReturnedPromise<GitReleaseApi['getBranch']>;
|
||||
export type CreateRefResult = UnboxReturnedPromise<GitReleaseApi['createRef']>;
|
||||
export type GetComparisonResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['getComparison']
|
||||
>;
|
||||
export type CreateReleaseResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['createRelease']
|
||||
>;
|
||||
export type MergeResult = UnboxReturnedPromise<GitReleaseApi['merge']>;
|
||||
export type CreateTagObjectResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['createTagObject']
|
||||
>;
|
||||
export type UpdateReleaseResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['updateRelease']
|
||||
>;
|
||||
export type GetAllTagsResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['getAllTags']
|
||||
>;
|
||||
export type GetAllReleasesResult = UnboxReturnedPromise<
|
||||
GitReleaseApi['getAllReleases']
|
||||
>;
|
||||
export type GetTagResult = UnboxReturnedPromise<GitReleaseApi['getTag']>;
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 { gitReleaseManagerApiRef } from './serviceApiRef';
|
||||
|
||||
describe('gitReleaseManagerApiRef', () => {
|
||||
it('should work', () => {
|
||||
const result = gitReleaseManagerApiRef;
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
ApiRefImpl {
|
||||
"config": Object {
|
||||
"description": "Used by the Git Release Manager plugin to make requests",
|
||||
"id": "plugin.git-release-manager.service",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 { createApiRef } from '@backstage/core';
|
||||
|
||||
import { GitReleaseApi } from './GitReleaseClient';
|
||||
|
||||
export const gitReleaseManagerApiRef = createApiRef<GitReleaseApi>({
|
||||
id: 'plugin.git-release-manager.service',
|
||||
description: 'Used by the Git Release Manager plugin to make requests',
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { Differ } from './Differ';
|
||||
import { TEST_IDS } from '../test-helpers/test-ids';
|
||||
import {
|
||||
mockReleaseCandidateCalver,
|
||||
mockReleaseVersionCalver,
|
||||
mockReleaseVersionSemver,
|
||||
} from '../test-helpers/test-helpers';
|
||||
|
||||
describe('Differ', () => {
|
||||
it('should render icon and `none` for missing current & next', () => {
|
||||
const { getByTestId, queryByTestId } = render(<Differ icon="branch" />);
|
||||
|
||||
const icon = getByTestId(TEST_IDS.components.differ.icons.branch);
|
||||
const current = queryByTestId(TEST_IDS.components.differ.current);
|
||||
const next = queryByTestId(TEST_IDS.components.differ.next);
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(current).toMatchInlineSnapshot(`null`);
|
||||
expect(next).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render icon & current for missing next', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<Differ icon="branch" current={mockReleaseVersionSemver.tagName} />,
|
||||
);
|
||||
|
||||
const icon = getByTestId(TEST_IDS.components.differ.icons.branch);
|
||||
const current = getByTestId(TEST_IDS.components.differ.current);
|
||||
const next = queryByTestId(TEST_IDS.components.differ.next);
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(current.innerHTML).toMatchInlineSnapshot(`"version-1.2.3"`);
|
||||
expect(next).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render icon & current & next (with seperator)', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<Differ
|
||||
icon="branch"
|
||||
current={mockReleaseCandidateCalver.tagName}
|
||||
next={mockReleaseVersionCalver.tagName}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = getByTestId(TEST_IDS.components.differ.icons.branch);
|
||||
const current = getByTestId(TEST_IDS.components.differ.current);
|
||||
const next = queryByTestId(TEST_IDS.components.differ.next);
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(current.innerHTML).toMatchInlineSnapshot(`"rc-2020.01.01_1"`);
|
||||
expect(next?.innerHTML).toMatchInlineSnapshot(`"version-2020.01.01_1"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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, { ReactNode } from 'react';
|
||||
import { grey } from '@material-ui/core/colors';
|
||||
import CallSplitIcon from '@material-ui/icons/CallSplit';
|
||||
import ChatIcon from '@material-ui/icons/Chat';
|
||||
import DynamicFeedIcon from '@material-ui/icons/DynamicFeed';
|
||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
||||
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
|
||||
|
||||
import { GitReleaseManagerError } from '../errors/GitReleaseManagerError';
|
||||
import { TEST_IDS } from '../test-helpers/test-ids';
|
||||
|
||||
interface DifferProps {
|
||||
icon: 'tag' | 'branch' | 'github' | 'slack' | 'versioning';
|
||||
current?: string;
|
||||
next?: string | ReactNode;
|
||||
}
|
||||
|
||||
export const Differ = ({ current, next, icon }: DifferProps) => {
|
||||
return (
|
||||
<>
|
||||
{icon && (
|
||||
<span>
|
||||
<Icon icon={icon} />{' '}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!!current && (
|
||||
<span
|
||||
data-testid={TEST_IDS.components.differ.current}
|
||||
style={{ color: grey[700] }}
|
||||
>
|
||||
{current ?? 'None'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{current && next && <span>{' → '}</span>}
|
||||
|
||||
{next && (
|
||||
<span
|
||||
data-testid={TEST_IDS.components.differ.next}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{next}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IconProps {
|
||||
icon: DifferProps['icon'];
|
||||
}
|
||||
|
||||
function Icon({ icon }: IconProps) {
|
||||
switch (icon) {
|
||||
case 'tag':
|
||||
return (
|
||||
<LocalOfferIcon
|
||||
data-testid={TEST_IDS.components.differ.icons.tag}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
fontSize="small"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'branch':
|
||||
return (
|
||||
<CallSplitIcon
|
||||
data-testid={TEST_IDS.components.differ.icons.branch}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
fontSize="small"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'github':
|
||||
return (
|
||||
<GitHubIcon
|
||||
data-testid={TEST_IDS.components.differ.icons.github}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
fontSize="small"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'slack':
|
||||
return (
|
||||
<ChatIcon
|
||||
data-testid={TEST_IDS.components.differ.icons.slack}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
fontSize="small"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'versioning':
|
||||
return (
|
||||
<DynamicFeedIcon
|
||||
data-testid={TEST_IDS.components.differ.icons.versioning}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
fontSize="small"
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
throw new GitReleaseManagerError('Invalid Differ icon');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { TEST_IDS } from '../test-helpers/test-ids';
|
||||
import { Divider } from './Divider';
|
||||
|
||||
describe('Divider', () => {
|
||||
it('render Divider', () => {
|
||||
const { getByTestId } = render(<Divider />);
|
||||
|
||||
expect(getByTestId(TEST_IDS.components.divider)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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, Divider as MaterialDivider } from '@material-ui/core';
|
||||
|
||||
import { TEST_IDS } from '../test-helpers/test-ids';
|
||||
|
||||
export const Divider = () => {
|
||||
return (
|
||||
<Box
|
||||
marginTop={2}
|
||||
marginBottom={2}
|
||||
data-testid={TEST_IDS.components.divider}
|
||||
>
|
||||
<MaterialDivider />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { TEST_IDS } from '../test-helpers/test-ids';
|
||||
import { InfoCardPlus } from './InfoCardPlus';
|
||||
|
||||
describe('InfoCardPlus', () => {
|
||||
it('render InfoCardPlus', () => {
|
||||
const { getByTestId } = render(<InfoCardPlus />);
|
||||
|
||||
expect(getByTestId(TEST_IDS.info.infoFeaturePlus)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { InfoCard } from '@backstage/core';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
|
||||
import { TEST_IDS } from '../test-helpers/test-ids';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
feature: {
|
||||
marginBottom: '3em',
|
||||
},
|
||||
}));
|
||||
|
||||
export const InfoCardPlus = ({ children }: { children?: React.ReactNode }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
data-testid={TEST_IDS.info.infoFeaturePlus}
|
||||
>
|
||||
<InfoCard className={classes.feature}>{children}</InfoCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { NoLatestRelease } from './NoLatestRelease';
|
||||
|
||||
describe('NoLatestRelease', () => {
|
||||
it('render NoLatestRelease', () => {
|
||||
const { container } = render(<NoLatestRelease />);
|
||||
|
||||
expect(container).toMatchInlineSnapshot(`
|
||||
<div>
|
||||
<div
|
||||
class="MuiBox-root MuiBox-root-1"
|
||||
>
|
||||
<div
|
||||
class="MuiPaper-root MuiAlert-root MuiAlert-standardWarning MuiPaper-elevation0"
|
||||
data-testid="grm--no-latest-release"
|
||||
role="alert"
|
||||
>
|
||||
<div
|
||||
class="MuiAlert-icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeInherit"
|
||||
focusable="false"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 5.99L19.53 19H4.47L12 5.99M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-6h-2v4h2v-4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="MuiAlert-message"
|
||||
>
|
||||
Unable to find any Release
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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 React from 'react';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { Box } from '@material-ui/core';
|
||||
|
||||
import { TEST_IDS } from '../test-helpers/test-ids';
|
||||
|
||||
export const NoLatestRelease = () => {
|
||||
return (
|
||||
<Box marginBottom={2}>
|
||||
<Alert
|
||||
data-testid={TEST_IDS.components.noLatestRelease}
|
||||
severity="warning"
|
||||
>
|
||||
Unable to find any Release
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { LinearProgressWithLabel, testables } from './LinearProgressWithLabel';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
|
||||
const { ICONS } = testables;
|
||||
|
||||
describe('LinearProgressWithLabel', () => {
|
||||
it('should render 50% progress without CompletionEmoji', () => {
|
||||
const progress = 50;
|
||||
|
||||
const { container, getByTestId } = render(
|
||||
<LinearProgressWithLabel
|
||||
progress={progress}
|
||||
responseSteps={[
|
||||
{ message: 'mock_response_step_message', icon: 'success' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.linearProgressWithLabel).getAttribute(
|
||||
'style',
|
||||
),
|
||||
).toContain(`font-size: 141%`);
|
||||
expect(container.innerHTML).toContain(`${progress}%`);
|
||||
expect(container.innerHTML).not.toContain(ICONS.SUCCESS);
|
||||
expect(container.innerHTML).not.toContain(ICONS.FAILURE);
|
||||
});
|
||||
|
||||
it('should render 100% progress with CompletionEmoji for success', () => {
|
||||
const progress = 100;
|
||||
|
||||
const { container, getByTestId } = render(
|
||||
<LinearProgressWithLabel
|
||||
progress={progress}
|
||||
responseSteps={[
|
||||
{ message: 'mock_response_step_message', icon: 'success' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.linearProgressWithLabel).getAttribute(
|
||||
'style',
|
||||
),
|
||||
).toContain(`font-size: 157%`);
|
||||
expect(container.innerHTML).toContain(`${progress}%`);
|
||||
expect(container.innerHTML).toContain(ICONS.SUCCESS);
|
||||
expect(container.innerHTML).not.toContain(ICONS.FAILURE);
|
||||
});
|
||||
|
||||
it('should render 100% progress with CompletionEmoji for failure if at least one failed response step is present', () => {
|
||||
const progress = 100;
|
||||
|
||||
const { container } = render(
|
||||
<LinearProgressWithLabel
|
||||
progress={progress}
|
||||
responseSteps={[
|
||||
{ message: 'does not matter', icon: 'success' },
|
||||
{ message: 'mock_response_step_message', icon: 'failure' },
|
||||
{ message: 'does not matter', icon: 'success' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toContain(`${progress}%`);
|
||||
expect(container.innerHTML).toContain(ICONS.FAILURE);
|
||||
expect(container.innerHTML).not.toContain(ICONS.SUCCESS);
|
||||
});
|
||||
|
||||
it('should round > 100 progress to 100', () => {
|
||||
const progress = 101;
|
||||
|
||||
const { container } = render(
|
||||
<LinearProgressWithLabel
|
||||
progress={progress}
|
||||
responseSteps={[{ message: 'mock_response_step_message' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toContain('100%');
|
||||
expect(container.innerHTML).toContain(ICONS.SUCCESS);
|
||||
expect(container.innerHTML).not.toContain(ICONS.FAILURE);
|
||||
expect(container.innerHTML).not.toContain(`${progress}%`);
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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, LinearProgress, Typography } from '@material-ui/core';
|
||||
|
||||
import { ResponseStep } from '../../types/types';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
|
||||
const STATUSES = {
|
||||
FAILURE: 'FAILURE',
|
||||
ONGOING: 'ONGOING',
|
||||
SUCCESS: 'SUCCESS',
|
||||
} as const;
|
||||
|
||||
const ICONS = {
|
||||
SUCCESS: '🚀',
|
||||
FAILURE: '🔥',
|
||||
};
|
||||
|
||||
const getFontSize = (progress: number) => 125 + Math.ceil(progress / Math.PI);
|
||||
|
||||
export function LinearProgressWithLabel(props: {
|
||||
progress: number;
|
||||
responseSteps: ResponseStep[];
|
||||
}) {
|
||||
const roundedValue = Math.ceil(props.progress);
|
||||
const progress = roundedValue < 100 ? roundedValue : 100;
|
||||
|
||||
const failure = props.responseSteps.some(
|
||||
responseStep => responseStep.icon === 'failure',
|
||||
);
|
||||
|
||||
let status: keyof typeof STATUSES = STATUSES.ONGOING;
|
||||
if (!failure && progress === 100) status = STATUSES.SUCCESS;
|
||||
if (failure) status = STATUSES.FAILURE;
|
||||
|
||||
const CompletionEmoji = () => {
|
||||
if (status === STATUSES.ONGOING) return null;
|
||||
if (status === STATUSES.FAILURE) return <span>{` ${ICONS.FAILURE} `}</span>;
|
||||
return <span>{` ${ICONS.SUCCESS} `}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
width="90%"
|
||||
alignSelf="center"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box width="100%">
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
data-testid={TEST_IDS.components.linearProgressWithLabel}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
minWidth: 35,
|
||||
color: failure ? '#ff0033' : '#1DB954',
|
||||
fontWeight: 'bold',
|
||||
fontSize: `${getFontSize(progress)}%`,
|
||||
transition: 'font-size 250ms ease',
|
||||
}}
|
||||
>
|
||||
<CompletionEmoji />
|
||||
{`${progress}%`}
|
||||
<CompletionEmoji />
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const testables = {
|
||||
ICONS,
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { ResponseStepDialog } from './ResponseStepDialog';
|
||||
|
||||
jest.mock('../../contexts/RefetchContext', () => ({
|
||||
useRefetchContext: () => jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ResponseStepDialog', () => {
|
||||
it('should render ResponseStepDialog', () => {
|
||||
const mockTitle = 'mock_dialog_title';
|
||||
const mockResponseStepMessage = 'banana';
|
||||
|
||||
const { baseElement } = render(
|
||||
<ResponseStepDialog
|
||||
progress={1}
|
||||
responseSteps={[{ message: mockResponseStepMessage }]}
|
||||
title={mockTitle}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(baseElement.innerHTML).toMatch(mockTitle);
|
||||
expect(baseElement.innerHTML).toMatch(mockResponseStepMessage);
|
||||
});
|
||||
});
|
||||
@@ -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 React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dialog as MaterialDialog,
|
||||
DialogActions,
|
||||
DialogTitle,
|
||||
} from '@material-ui/core';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
|
||||
import { LinearProgressWithLabel } from './LinearProgressWithLabel';
|
||||
import { ResponseStep } from '../../types/types';
|
||||
import { ResponseStepList } from './ResponseStepList';
|
||||
import { Transition } from '../Transition';
|
||||
import { useRefetchContext } from '../../contexts/RefetchContext';
|
||||
|
||||
interface DialogProps {
|
||||
progress: number;
|
||||
responseSteps: ResponseStep[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const ResponseStepDialog = ({
|
||||
progress,
|
||||
responseSteps,
|
||||
title,
|
||||
}: DialogProps) => {
|
||||
const { fetchGitBatchInfo } = useRefetchContext();
|
||||
|
||||
return (
|
||||
<MaterialDialog
|
||||
open
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
|
||||
<ResponseStepList responseSteps={responseSteps} />
|
||||
|
||||
<LinearProgressWithLabel
|
||||
progress={progress}
|
||||
responseSteps={responseSteps}
|
||||
/>
|
||||
|
||||
<DialogActions style={{ padding: 20 }}>
|
||||
<Button
|
||||
onClick={() => fetchGitBatchInfo()}
|
||||
disabled={progress < 100}
|
||||
variant="contained"
|
||||
size="large"
|
||||
color="primary"
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MaterialDialog>
|
||||
);
|
||||
};
|
||||
+56
@@ -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 { render } from '@testing-library/react';
|
||||
|
||||
import { ResponseStepList } from './ResponseStepList';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
|
||||
describe('ResponseStepList', () => {
|
||||
it('should render loading state when loading', () => {
|
||||
const { getByTestId } = render(
|
||||
<ResponseStepList loading responseSteps={[]} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.circularProgress),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state when no responseSteps', () => {
|
||||
const { getByTestId } = render(
|
||||
<ResponseStepList loading={false} responseSteps={[]} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.circularProgress),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render dialog content when loading is completed', () => {
|
||||
const { getByTestId } = render(
|
||||
<ResponseStepList
|
||||
loading={false}
|
||||
responseSteps={[{ message: 'banana' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.responseStepListDialogContent),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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, { PropsWithChildren } from 'react';
|
||||
import { DialogContent, List } from '@material-ui/core';
|
||||
import { Progress } from '@backstage/core';
|
||||
|
||||
import { ResponseStep } from '../../types/types';
|
||||
import { ResponseStepListItem } from './ResponseStepListItem';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
|
||||
interface ResponseStepListProps {
|
||||
responseSteps: (ResponseStep | undefined)[];
|
||||
animationDelay?: number;
|
||||
loading?: boolean;
|
||||
closeable?: boolean;
|
||||
denseList?: boolean;
|
||||
}
|
||||
|
||||
export const ResponseStepList = ({
|
||||
responseSteps,
|
||||
animationDelay,
|
||||
loading = false,
|
||||
denseList = false,
|
||||
children,
|
||||
}: PropsWithChildren<ResponseStepListProps>) => {
|
||||
return (
|
||||
<>
|
||||
{loading || responseSteps.length === 0 ? (
|
||||
<div
|
||||
data-testid={TEST_IDS.components.circularProgress}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
margin: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Progress />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogContent
|
||||
data-testid={TEST_IDS.components.responseStepListDialogContent}
|
||||
>
|
||||
<List dense={denseList}>
|
||||
{responseSteps.map((responseStep, index) => {
|
||||
if (!responseStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponseStepListItem
|
||||
key={`ResponseStepListItem-${index}`}
|
||||
responseStep={responseStep}
|
||||
index={index}
|
||||
animationDelay={animationDelay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
{children}
|
||||
</DialogContent>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { ResponseStepListItem } from './ResponseStepListItem';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
|
||||
describe('ResponseStepListItem', () => {
|
||||
it('should render', () => {
|
||||
const { getByTestId } = render(
|
||||
<ResponseStepListItem
|
||||
index={0}
|
||||
responseStep={{ message: 'mock_response_step_message' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.responseStepListItem),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render success icon', () => {
|
||||
const { getByTestId } = render(
|
||||
<ResponseStepListItem
|
||||
index={0}
|
||||
responseStep={{
|
||||
message: 'mock_response_step_message',
|
||||
icon: 'success',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.responseStepListItemIconSuccess),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render failure icon', () => {
|
||||
const { getByTestId } = render(
|
||||
<ResponseStepListItem
|
||||
index={0}
|
||||
responseStep={{
|
||||
message: 'mock_response_step_message',
|
||||
icon: 'failure',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.responseStepListItemIconFailure),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render link icon', () => {
|
||||
const { getByTestId } = render(
|
||||
<ResponseStepListItem
|
||||
index={0}
|
||||
responseStep={{
|
||||
message: 'mock_response_step_message',
|
||||
link: 'http://example.com',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.responseStepListItemIconLink),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render default icon', () => {
|
||||
const { getByTestId } = render(
|
||||
<ResponseStepListItem
|
||||
index={0}
|
||||
responseStep={{ message: 'mock_response_step_message' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.responseStepListItemIconDefault),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 {
|
||||
colors,
|
||||
IconButton,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
|
||||
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline';
|
||||
import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord';
|
||||
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
|
||||
|
||||
import { ResponseStep } from '../../types/types';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
|
||||
interface ResponseStepListItemProps {
|
||||
responseStep: ResponseStep;
|
||||
index: number;
|
||||
animationDelay?: number;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
item: {
|
||||
transition: `opacity ${(props: any) =>
|
||||
props.animationDelay <= 0
|
||||
? 0
|
||||
: Math.ceil(props.animationDelay / 2)}ms ease-in`,
|
||||
overflow: 'hidden',
|
||||
'&:before': {
|
||||
flex: 'none',
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
shown: {
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const ResponseStepListItem = ({
|
||||
responseStep,
|
||||
animationDelay = 300,
|
||||
}: ResponseStepListItemProps) => {
|
||||
const classes = useStyles({ animationDelay });
|
||||
|
||||
function ItemIcon() {
|
||||
if (responseStep.icon === 'success') {
|
||||
return (
|
||||
<CheckCircleOutline
|
||||
data-testid={TEST_IDS.components.responseStepListItemIconSuccess}
|
||||
style={{ color: colors.green[500] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (responseStep.icon === 'failure') {
|
||||
return (
|
||||
<ErrorOutlineIcon
|
||||
data-testid={TEST_IDS.components.responseStepListItemIconFailure}
|
||||
style={{ color: colors.red[500] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (responseStep.link) {
|
||||
return (
|
||||
<IconButton
|
||||
data-testid={TEST_IDS.components.responseStepListItemIconLink}
|
||||
style={{ padding: 0 }}
|
||||
aria-label="link"
|
||||
onClick={() => {
|
||||
const newTab = window.open(responseStep.link, '_blank');
|
||||
newTab?.focus();
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon color="primary" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FiberManualRecordIcon
|
||||
data-testid={TEST_IDS.components.responseStepListItemIconDefault}
|
||||
fontSize="small"
|
||||
style={{ opacity: 0.85 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
className={`${classes.item}`}
|
||||
data-testid={TEST_IDS.components.responseStepListItem}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ItemIcon />
|
||||
</ListItemIcon>
|
||||
|
||||
<ListItemText
|
||||
primary={responseStep.message}
|
||||
secondary={responseStep.secondaryMessage}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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, { forwardRef, Ref } from 'react';
|
||||
import { Slide } from '@material-ui/core';
|
||||
import { TransitionProps } from '@material-ui/core/transitions';
|
||||
|
||||
export const Transition = forwardRef(function Transition(
|
||||
props: { children?: React.ReactElement<any, any> } & TransitionProps,
|
||||
ref: Ref<unknown>,
|
||||
) {
|
||||
return <Slide direction="up" ref={ref} {...props} />;
|
||||
});
|
||||
@@ -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 * as constants from './constants';
|
||||
|
||||
describe('constants', () => {
|
||||
it('should match snapshot', () => {
|
||||
expect(constants).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"DISABLE_CACHE": Object {
|
||||
"headers": Object {
|
||||
"If-None-Match": "",
|
||||
},
|
||||
},
|
||||
"SEMVER_PARTS": Object {
|
||||
"major": "major",
|
||||
"minor": "minor",
|
||||
"patch": "patch",
|
||||
},
|
||||
"TAG_OBJECT_MESSAGE": "Tag generated by your friendly neighborhood Backstage Release Manager",
|
||||
"VERSIONING_STRATEGIES": Object {
|
||||
"calver": "calver",
|
||||
"semver": "semver",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const SEMVER_PARTS: {
|
||||
major: 'major';
|
||||
minor: 'minor';
|
||||
patch: 'patch';
|
||||
} = {
|
||||
major: 'major',
|
||||
minor: 'minor',
|
||||
patch: 'patch',
|
||||
} as const;
|
||||
|
||||
export const DISABLE_CACHE = {
|
||||
headers: {
|
||||
'If-None-Match': '',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const VERSIONING_STRATEGIES: {
|
||||
semver: 'semver';
|
||||
calver: 'calver';
|
||||
} = {
|
||||
semver: 'semver',
|
||||
calver: 'calver',
|
||||
} as const;
|
||||
|
||||
export const TAG_OBJECT_MESSAGE =
|
||||
'Tag generated by your friendly neighborhood Backstage Release Manager';
|
||||
@@ -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 { createContext, useContext } from 'react';
|
||||
|
||||
import { VERSIONING_STRATEGIES } from '../constants/constants';
|
||||
import { GitReleaseManagerError } from '../errors/GitReleaseManagerError';
|
||||
|
||||
export interface Project {
|
||||
/**
|
||||
* Repository's owner (user or organisation)
|
||||
*
|
||||
* @example erikengervall
|
||||
*/
|
||||
owner: string;
|
||||
/**
|
||||
* Repository's name
|
||||
*
|
||||
* @example dockest
|
||||
*/
|
||||
repo: string;
|
||||
/**
|
||||
* Declares the versioning strategy of the project
|
||||
*
|
||||
* semver: `1.2.3` (major.minor.patch)
|
||||
* calver: `2020.01.01_0` (YYYY.0M.0D_patch)
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
versioningStrategy: keyof typeof VERSIONING_STRATEGIES;
|
||||
/**
|
||||
* Project props was provided via props
|
||||
*
|
||||
* If true, this means select inputs will be disabled
|
||||
*/
|
||||
isProvidedViaProps: boolean;
|
||||
}
|
||||
|
||||
export const ProjectContext = createContext<{ project: Project } | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useProjectContext = () => {
|
||||
const { project } = useContext(ProjectContext) ?? {};
|
||||
|
||||
if (!project) {
|
||||
throw new GitReleaseManagerError('project not found');
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 { GitReleaseManagerError } from '../errors/GitReleaseManagerError';
|
||||
|
||||
export const RefetchContext = createContext<
|
||||
{ fetchGitBatchInfo: () => any } | undefined
|
||||
>(undefined);
|
||||
|
||||
export const useRefetchContext = () => {
|
||||
const refetch = useContext(RefetchContext);
|
||||
|
||||
if (!refetch) {
|
||||
throw new GitReleaseManagerError('refetch not found');
|
||||
}
|
||||
|
||||
return {
|
||||
fetchGitBatchInfo: refetch.fetchGitBatchInfo,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 { GitReleaseManagerError } from '../errors/GitReleaseManagerError';
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export const UserContext = createContext<{ user: User } | undefined>(undefined);
|
||||
|
||||
export const useUserContext = () => {
|
||||
const { user } = useContext(UserContext) ?? {};
|
||||
|
||||
if (!user) {
|
||||
throw new GitReleaseManagerError('user not found');
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export class GitReleaseManagerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
|
||||
this.name = 'GitReleaseManagerError';
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockCalverProject,
|
||||
mockNextGitInfoSemver,
|
||||
mockReleaseBranch,
|
||||
mockReleaseCandidateCalver,
|
||||
mockReleaseVersionCalver,
|
||||
mockSemverProject,
|
||||
} from '../../test-helpers/test-helpers';
|
||||
import { CreateReleaseCandidate } from './CreateReleaseCandidate';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { useCreateReleaseCandidate } from './hooks/useCreateReleaseCandidate';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
|
||||
jest.mock('../../contexts/ProjectContext', () => ({
|
||||
useProjectContext: jest.fn(() => ({
|
||||
project: mockCalverProject,
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../helpers/getReleaseCandidateGitInfo', () => ({
|
||||
getReleaseCandidateGitInfo: () => mockNextGitInfoSemver,
|
||||
}));
|
||||
jest.mock('./hooks/useCreateReleaseCandidate', () => ({
|
||||
useCreateReleaseCandidate: () =>
|
||||
({
|
||||
run: jest.fn(),
|
||||
responseSteps: [],
|
||||
progress: 0,
|
||||
runInvoked: false,
|
||||
} as ReturnType<typeof useCreateReleaseCandidate>),
|
||||
}));
|
||||
|
||||
describe('CreateReleaseCandidate', () => {
|
||||
it('should display CTA', () => {
|
||||
const { getByTestId } = render(
|
||||
<CreateReleaseCandidate
|
||||
defaultBranch="mockDefaultBranch"
|
||||
latestRelease={mockReleaseCandidateCalver}
|
||||
releaseBranch={mockReleaseBranch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_IDS.createRc.cta)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display select element for semver', () => {
|
||||
(useProjectContext as jest.Mock).mockReturnValue({
|
||||
project: mockSemverProject,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<CreateReleaseCandidate
|
||||
defaultBranch="mockDefaultBranch"
|
||||
latestRelease={mockReleaseVersionCalver}
|
||||
releaseBranch={mockReleaseBranch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_IDS.createRc.semverSelect)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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, useEffect } from 'react';
|
||||
import { Alert, AlertTitle } from '@material-ui/lab';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
|
||||
import {
|
||||
GetBranchResult,
|
||||
GetLatestReleaseResult,
|
||||
GetRepositoryResult,
|
||||
} from '../../api/GitReleaseClient';
|
||||
import { ComponentConfigCreateRc } from '../../types/types';
|
||||
import { Differ } from '../../components/Differ';
|
||||
import { getReleaseCandidateGitInfo } from '../../helpers/getReleaseCandidateGitInfo';
|
||||
import { InfoCardPlus } from '../../components/InfoCardPlus';
|
||||
import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog';
|
||||
import { SEMVER_PARTS } from '../../constants/constants';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { useCreateReleaseCandidate } from './hooks/useCreateReleaseCandidate';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
|
||||
interface CreateReleaseCandidateProps {
|
||||
defaultBranch: GetRepositoryResult['repository']['defaultBranch'];
|
||||
latestRelease: GetLatestReleaseResult['latestRelease'];
|
||||
releaseBranch: GetBranchResult['branch'] | null;
|
||||
onSuccess?: ComponentConfigCreateRc['onSuccess'];
|
||||
}
|
||||
|
||||
const InfoCardPlusWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<InfoCardPlus>
|
||||
<Box marginBottom={2}>
|
||||
<Typography variant="h4">Create Release Candidate</Typography>
|
||||
</Box>
|
||||
{children}
|
||||
</InfoCardPlus>
|
||||
);
|
||||
};
|
||||
|
||||
export const CreateReleaseCandidate = ({
|
||||
defaultBranch,
|
||||
latestRelease,
|
||||
releaseBranch,
|
||||
onSuccess,
|
||||
}: CreateReleaseCandidateProps) => {
|
||||
const { project } = useProjectContext();
|
||||
|
||||
const [semverBumpLevel, setSemverBumpLevel] = useState<'major' | 'minor'>(
|
||||
SEMVER_PARTS.minor,
|
||||
);
|
||||
const [releaseCandidateGitInfo, setReleaseCandidateGitInfo] = useState(
|
||||
getReleaseCandidateGitInfo({ latestRelease, project, semverBumpLevel }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setReleaseCandidateGitInfo(
|
||||
getReleaseCandidateGitInfo({ latestRelease, project, semverBumpLevel }),
|
||||
);
|
||||
}, [semverBumpLevel, setReleaseCandidateGitInfo, latestRelease, project]);
|
||||
|
||||
const {
|
||||
progress,
|
||||
responseSteps,
|
||||
run,
|
||||
runInvoked,
|
||||
} = useCreateReleaseCandidate({
|
||||
defaultBranch,
|
||||
latestRelease,
|
||||
releaseCandidateGitInfo,
|
||||
project,
|
||||
onSuccess,
|
||||
});
|
||||
if (responseSteps.length > 0) {
|
||||
return (
|
||||
<ResponseStepDialog
|
||||
progress={progress}
|
||||
responseSteps={responseSteps}
|
||||
title="Create Release Candidate"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (releaseCandidateGitInfo.error !== undefined) {
|
||||
return (
|
||||
<InfoCardPlusWrapper>
|
||||
<Alert severity="error">
|
||||
{releaseCandidateGitInfo.error.title && (
|
||||
<AlertTitle>{releaseCandidateGitInfo.error.title}</AlertTitle>
|
||||
)}
|
||||
|
||||
{releaseCandidateGitInfo.error.subtitle}
|
||||
</Alert>
|
||||
</InfoCardPlusWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const tagAlreadyExists =
|
||||
latestRelease !== null &&
|
||||
latestRelease.tagName === releaseCandidateGitInfo.rcReleaseTag;
|
||||
const conflictingPreRelease =
|
||||
latestRelease !== null && latestRelease.prerelease;
|
||||
|
||||
return (
|
||||
<InfoCardPlusWrapper>
|
||||
{project.versioningStrategy === 'semver' &&
|
||||
latestRelease &&
|
||||
!conflictingPreRelease && (
|
||||
<Box marginBottom={2} data-testid={TEST_IDS.createRc.semverSelect}>
|
||||
<FormControl style={{ margin: 5, minWidth: 250 }}>
|
||||
<InputLabel>Select bump severity</InputLabel>
|
||||
|
||||
<Select
|
||||
value={semverBumpLevel}
|
||||
onChange={({ target: { value: semverSeverity } }: any) => {
|
||||
setSemverBumpLevel(semverSeverity);
|
||||
}}
|
||||
>
|
||||
<MenuItem value={SEMVER_PARTS.minor}>
|
||||
{SEMVER_PARTS.minor}
|
||||
</MenuItem>
|
||||
<MenuItem value={SEMVER_PARTS.major}>
|
||||
{SEMVER_PARTS.major}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{conflictingPreRelease || tagAlreadyExists ? (
|
||||
<>
|
||||
{conflictingPreRelease && (
|
||||
<Box marginBottom={2}>
|
||||
<Alert severity="warning">
|
||||
The most recent release is already a Release Candidate
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tagAlreadyExists && (
|
||||
<Box marginBottom={2}>
|
||||
<Alert severity="warning">
|
||||
There's already a tag named{' '}
|
||||
<strong>{releaseCandidateGitInfo.rcReleaseTag}</strong>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box marginBottom={2}>
|
||||
<Typography>
|
||||
<Differ
|
||||
icon="branch"
|
||||
current={releaseBranch?.name}
|
||||
next={releaseCandidateGitInfo.rcBranch}
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
<Typography>
|
||||
<Differ
|
||||
icon="tag"
|
||||
current={latestRelease?.tagName}
|
||||
next={releaseCandidateGitInfo.rcReleaseTag}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
data-testid={TEST_IDS.createRc.cta}
|
||||
disabled={conflictingPreRelease || tagAlreadyExists || runInvoked}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => run()}
|
||||
>
|
||||
Create Release Candidate
|
||||
</Button>
|
||||
</InfoCardPlusWrapper>
|
||||
);
|
||||
};
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockApiClient,
|
||||
mockCalverProject,
|
||||
mockDefaultBranch,
|
||||
mockNextGitInfoCalver,
|
||||
mockReleaseVersionCalver,
|
||||
mockUser,
|
||||
} from '../../../test-helpers/test-helpers';
|
||||
import { useCreateReleaseCandidate } from './useCreateReleaseCandidate';
|
||||
|
||||
jest.mock('@backstage/core', () => ({
|
||||
...jest.requireActual('@backstage/core'),
|
||||
useApi: () => mockApiClient,
|
||||
}));
|
||||
jest.mock('../../../contexts/UserContext', () => ({
|
||||
useUserContext: () => ({ user: mockUser }),
|
||||
}));
|
||||
|
||||
describe('useCreateReleaseCandidate', () => {
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
it('should return the expected responseSteps and progress', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCreateReleaseCandidate({
|
||||
defaultBranch: mockDefaultBranch,
|
||||
latestRelease: mockReleaseVersionCalver,
|
||||
releaseCandidateGitInfo: mockNextGitInfoCalver,
|
||||
project: mockCalverProject,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await waitFor(() => result.current.run());
|
||||
});
|
||||
|
||||
expect(result.error).toEqual(undefined);
|
||||
expect(result.current.responseSteps).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should return the expected responseSteps and progress (with onSuccess)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCreateReleaseCandidate({
|
||||
defaultBranch: mockDefaultBranch,
|
||||
latestRelease: mockReleaseVersionCalver,
|
||||
releaseCandidateGitInfo: mockNextGitInfoCalver,
|
||||
project: mockCalverProject,
|
||||
onSuccess: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await waitFor(() => result.current.run());
|
||||
});
|
||||
|
||||
expect(result.current.responseSteps).toHaveLength(7);
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"progress": 100,
|
||||
"responseSteps": Array [
|
||||
Object {
|
||||
"link": "https://latestCommit.html_url",
|
||||
"message": "Fetched latest commit from \\"mock_defaultBranch\\"",
|
||||
"secondaryMessage": "with message \\"latestCommit.commit.message\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Created Release Branch",
|
||||
"secondaryMessage": "with ref \\"mock_createRef_ref\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Created Tag Object",
|
||||
"secondaryMessage": "with sha \\"mock_tag_object_sha\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Cut Tag Reference",
|
||||
"secondaryMessage": "with ref \\"mock_createRef_ref\\"",
|
||||
},
|
||||
Object {
|
||||
"link": "https://mock_compareCommits_html_url",
|
||||
"message": "Fetched commit comparison",
|
||||
"secondaryMessage": "rc/2020.01.01_1...rc/2020.01.01_1",
|
||||
},
|
||||
Object {
|
||||
"link": "https://mock_createRelease_html_url",
|
||||
"message": "Created Release Candidate \\"mock_createRelease_name\\"",
|
||||
"secondaryMessage": "with tag \\"rc-2020.01.01_1\\"",
|
||||
},
|
||||
Object {
|
||||
"icon": "success",
|
||||
"message": "Success callback successfully called 🚀",
|
||||
},
|
||||
],
|
||||
"run": [Function],
|
||||
"runInvoked": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core';
|
||||
|
||||
import {
|
||||
GetLatestReleaseResult,
|
||||
GetRepositoryResult,
|
||||
} from '../../../api/GitReleaseClient';
|
||||
import { CardHook, ComponentConfigCreateRc } from '../../../types/types';
|
||||
import { getReleaseCandidateGitInfo } from '../../../helpers/getReleaseCandidateGitInfo';
|
||||
import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef';
|
||||
import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError';
|
||||
import { Project } from '../../../contexts/ProjectContext';
|
||||
import { TAG_OBJECT_MESSAGE } from '../../../constants/constants';
|
||||
import { useResponseSteps } from '../../../hooks/useResponseSteps';
|
||||
import { useUserContext } from '../../../contexts/UserContext';
|
||||
|
||||
interface UseCreateReleaseCandidate {
|
||||
defaultBranch: GetRepositoryResult['repository']['defaultBranch'];
|
||||
latestRelease: GetLatestReleaseResult['latestRelease'];
|
||||
releaseCandidateGitInfo: ReturnType<typeof getReleaseCandidateGitInfo>;
|
||||
project: Project;
|
||||
onSuccess?: ComponentConfigCreateRc['onSuccess'];
|
||||
}
|
||||
|
||||
export function useCreateReleaseCandidate({
|
||||
defaultBranch,
|
||||
latestRelease,
|
||||
releaseCandidateGitInfo,
|
||||
project,
|
||||
onSuccess,
|
||||
}: UseCreateReleaseCandidate): CardHook<void> {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { user } = useUserContext();
|
||||
|
||||
if (releaseCandidateGitInfo.error) {
|
||||
throw new GitReleaseManagerError(
|
||||
`Unexpected error: ${
|
||||
releaseCandidateGitInfo.error.title
|
||||
? `${releaseCandidateGitInfo.error.title} (${releaseCandidateGitInfo.error.subtitle})`
|
||||
: releaseCandidateGitInfo.error.subtitle
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
responseSteps,
|
||||
addStepToResponseSteps,
|
||||
asyncCatcher,
|
||||
abortIfError,
|
||||
} = useResponseSteps();
|
||||
|
||||
/**
|
||||
* (1) Get the default branch's most recent commit
|
||||
*/
|
||||
const [latestCommitRes, run] = useAsyncFn(async () => {
|
||||
const { commit: latestCommit } = await pluginApiClient
|
||||
.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: defaultBranch,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Fetched latest commit from "${defaultBranch}"`,
|
||||
secondaryMessage: `with message "${latestCommit.commit.message}"`,
|
||||
link: latestCommit.htmlUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
latestCommit,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* (2) Create release branch based on default branch's most recent sha
|
||||
*/
|
||||
const releaseBranchRes = useAsync(async () => {
|
||||
abortIfError(latestCommitRes.error);
|
||||
if (!latestCommitRes.value) return undefined;
|
||||
|
||||
const { reference: createdReleaseBranch } = await pluginApiClient
|
||||
.createRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
sha: latestCommitRes.value.latestCommit.sha,
|
||||
ref: `refs/heads/${releaseCandidateGitInfo.rcBranch}`,
|
||||
})
|
||||
.catch(error => {
|
||||
if (error?.body?.message === 'Reference already exists') {
|
||||
throw new GitReleaseManagerError(
|
||||
`Branch "${releaseCandidateGitInfo.rcBranch}" already exists: .../tree/${releaseCandidateGitInfo.rcBranch}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Created Release Branch',
|
||||
secondaryMessage: `with ref "${createdReleaseBranch.ref}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...createdReleaseBranch,
|
||||
};
|
||||
}, [latestCommitRes.value, latestCommitRes.error]);
|
||||
|
||||
/**
|
||||
* (3) Create tag object for our soon-to-be-created annotated tag
|
||||
*/
|
||||
const tagObjectRes = useAsync(async () => {
|
||||
abortIfError(releaseBranchRes.error);
|
||||
if (!releaseBranchRes.value) return undefined;
|
||||
|
||||
const { tagObject } = await pluginApiClient
|
||||
.createTagObject({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
tag: releaseCandidateGitInfo.rcReleaseTag,
|
||||
object: releaseBranchRes.value.objectSha,
|
||||
taggerName: user.username,
|
||||
taggerEmail: user.email,
|
||||
message: TAG_OBJECT_MESSAGE,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Created Tag Object',
|
||||
secondaryMessage: `with sha "${tagObject.tagSha}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...tagObject,
|
||||
};
|
||||
}, [releaseBranchRes.value, releaseBranchRes.error]);
|
||||
|
||||
/**
|
||||
* (4) Create reference for tag object
|
||||
*/
|
||||
const createRcRes = useAsync(async () => {
|
||||
abortIfError(tagObjectRes.error);
|
||||
if (!tagObjectRes.value) return undefined;
|
||||
|
||||
const { reference: createdRef } = await pluginApiClient
|
||||
.createRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: `refs/tags/${releaseCandidateGitInfo.rcReleaseTag}`,
|
||||
sha: tagObjectRes.value.tagSha,
|
||||
})
|
||||
.catch(error => {
|
||||
if (error?.body?.message === 'Reference already exists') {
|
||||
throw new GitReleaseManagerError(
|
||||
`Tag reference "${releaseCandidateGitInfo.rcReleaseTag}" already exists`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Cut Tag Reference',
|
||||
secondaryMessage: `with ref "${createdRef.ref}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...createdRef,
|
||||
};
|
||||
}, [tagObjectRes.value, tagObjectRes.error]);
|
||||
|
||||
/**
|
||||
* (5) Compose a body for the release
|
||||
*/
|
||||
const getComparisonRes = useAsync(async () => {
|
||||
abortIfError(createRcRes.error);
|
||||
if (!createRcRes.value) return undefined;
|
||||
|
||||
const previousReleaseBranch = latestRelease
|
||||
? latestRelease.targetCommitish
|
||||
: defaultBranch;
|
||||
const nextReleaseBranch = releaseCandidateGitInfo.rcBranch;
|
||||
const { comparison } = await pluginApiClient
|
||||
.getComparison({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
base: previousReleaseBranch,
|
||||
head: nextReleaseBranch,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
const releaseBody = `**Compare** ${comparison.htmlUrl}
|
||||
|
||||
**Ahead by** ${comparison.aheadBy} commits
|
||||
|
||||
**Release branch** ${createRcRes.value.ref}
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Fetched commit comparison',
|
||||
secondaryMessage: `${previousReleaseBranch}...${nextReleaseBranch}`,
|
||||
link: comparison.htmlUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
...comparison,
|
||||
releaseBody,
|
||||
};
|
||||
}, [createRcRes.value, createRcRes.error]);
|
||||
|
||||
/**
|
||||
* (6) Creates the Git Release itself
|
||||
*/
|
||||
const createReleaseRes = useAsync(async () => {
|
||||
abortIfError(getComparisonRes.error);
|
||||
if (!getComparisonRes.value) return undefined;
|
||||
|
||||
const { release: createReleaseResult } = await pluginApiClient
|
||||
.createRelease({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
tagName: releaseCandidateGitInfo.rcReleaseTag,
|
||||
name: releaseCandidateGitInfo.releaseName,
|
||||
targetCommitish: releaseCandidateGitInfo.rcBranch,
|
||||
body: getComparisonRes.value.releaseBody,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Created Release Candidate "${createReleaseResult.name}"`,
|
||||
secondaryMessage: `with tag "${releaseCandidateGitInfo.rcReleaseTag}"`,
|
||||
link: createReleaseResult.htmlUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
...createReleaseResult,
|
||||
};
|
||||
}, [getComparisonRes.value, getComparisonRes.error]);
|
||||
|
||||
/**
|
||||
* (7) Run onSuccess if defined
|
||||
*/
|
||||
useAsync(async () => {
|
||||
if (onSuccess && !!createReleaseRes.value && !!getComparisonRes.value) {
|
||||
abortIfError(createReleaseRes.error);
|
||||
|
||||
try {
|
||||
await onSuccess({
|
||||
comparisonUrl: getComparisonRes.value.htmlUrl,
|
||||
createdTag: createReleaseRes.value.tagName,
|
||||
gitReleaseName: createReleaseRes.value.name,
|
||||
gitReleaseUrl: createReleaseRes.value.htmlUrl,
|
||||
previousTag: latestRelease?.tagName,
|
||||
});
|
||||
} catch (error) {
|
||||
asyncCatcher(error);
|
||||
}
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Success callback successfully called 🚀',
|
||||
icon: 'success',
|
||||
});
|
||||
}
|
||||
}, [createReleaseRes.value, createReleaseRes.error]);
|
||||
|
||||
const TOTAL_STEPS = 6 + (!!onSuccess ? 1 : 0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
useEffect(() => {
|
||||
setProgress((responseSteps.length / TOTAL_STEPS) * 100);
|
||||
}, [TOTAL_STEPS, responseSteps.length]);
|
||||
|
||||
return {
|
||||
progress,
|
||||
responseSteps,
|
||||
run,
|
||||
runInvoked: Boolean(
|
||||
latestCommitRes.loading || latestCommitRes.value || latestCommitRes.error,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 { render, act, waitFor } from '@testing-library/react';
|
||||
|
||||
import { Features } from './Features';
|
||||
import { mockApiClient, mockCalverProject } from '../test-helpers/test-helpers';
|
||||
import { TEST_IDS } from '../test-helpers/test-ids';
|
||||
|
||||
jest.mock('@backstage/core', () => ({
|
||||
...jest.requireActual('@backstage/core'),
|
||||
useApi: () => mockApiClient,
|
||||
}));
|
||||
jest.mock('../contexts/ProjectContext', () => ({
|
||||
useProjectContext: () => ({
|
||||
project: mockCalverProject,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Features', () => {
|
||||
it('should omit features omitted via configuration', async () => {
|
||||
const { getByTestId } = render(
|
||||
<Features
|
||||
features={{
|
||||
info: { omit: false },
|
||||
createRc: { omit: true },
|
||||
promoteRc: { omit: true },
|
||||
patch: { omit: true },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await waitFor(() => getByTestId(TEST_IDS.info.info));
|
||||
});
|
||||
|
||||
expect(getByTestId(TEST_IDS.info.info)).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="MuiBox-root MuiBox-root-9"
|
||||
data-testid="grm--info"
|
||||
>
|
||||
<h6
|
||||
class="MuiTypography-root MuiTypography-h6"
|
||||
>
|
||||
Terminology
|
||||
</h6>
|
||||
<p
|
||||
class="MuiTypography-root MuiTypography-body1"
|
||||
>
|
||||
<strong>
|
||||
Git
|
||||
</strong>
|
||||
: The source control system where releases reside in a practical sense. Read more about
|
||||
|
||||
<a
|
||||
class="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiTypography-colorPrimary"
|
||||
href="https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository"
|
||||
target="_blank"
|
||||
to="https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository"
|
||||
>
|
||||
Git releases
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p
|
||||
class="MuiTypography-root MuiTypography-body1"
|
||||
>
|
||||
<strong>
|
||||
Release Candidate
|
||||
</strong>
|
||||
: A Git
|
||||
<i>
|
||||
prerelease
|
||||
</i>
|
||||
intended primarily for internal testing
|
||||
</p>
|
||||
<p
|
||||
class="MuiTypography-root MuiTypography-body1"
|
||||
>
|
||||
<strong>
|
||||
Release Version
|
||||
</strong>
|
||||
: A Git release intended for end users
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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, { ComponentProps } from 'react';
|
||||
import { Alert, AlertTitle } from '@material-ui/lab';
|
||||
import { ErrorBoundary, Progress, useApi } from '@backstage/core';
|
||||
|
||||
import { CreateReleaseCandidate } from './CreateReleaseCandidate/CreateReleaseCandidate';
|
||||
import { GitReleaseManager } from '../GitReleaseManager';
|
||||
import { gitReleaseManagerApiRef } from '../api/serviceApiRef';
|
||||
import { Info } from './Info/Info';
|
||||
import { Patch } from './Patch/Patch';
|
||||
import { PromoteRc } from './PromoteRc/PromoteRc';
|
||||
import { RefetchContext } from '../contexts/RefetchContext';
|
||||
import { useGetGitBatchInfo } from '../hooks/useGetGitBatchInfo';
|
||||
import { useProjectContext } from '../contexts/ProjectContext';
|
||||
import { useVersioningStrategyMatchesRepoTags } from '../hooks/useVersioningStrategyMatchesRepoTags';
|
||||
import { validateTagName } from '../helpers/tagParts/validateTagName';
|
||||
|
||||
export function Features({
|
||||
features,
|
||||
}: {
|
||||
features: ComponentProps<typeof GitReleaseManager>['features'];
|
||||
}) {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { project } = useProjectContext();
|
||||
const { gitBatchInfo, fetchGitBatchInfo } = useGetGitBatchInfo({
|
||||
pluginApiClient,
|
||||
project,
|
||||
});
|
||||
|
||||
const { versioningStrategyMatches } = useVersioningStrategyMatchesRepoTags({
|
||||
latestReleaseTagName: gitBatchInfo.value?.latestRelease?.tagName,
|
||||
project,
|
||||
repositoryName: gitBatchInfo.value?.repository.name,
|
||||
});
|
||||
|
||||
if (gitBatchInfo.error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error occured while fetching information for "{project.owner}/
|
||||
{project.repo}" ({gitBatchInfo.error.message})
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (gitBatchInfo.loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (gitBatchInfo.value === undefined) {
|
||||
return <Alert severity="error">Failed to fetch latest Git release</Alert>;
|
||||
}
|
||||
|
||||
if (!gitBatchInfo.value.repository.pushPermissions) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
You lack push permissions for repository "{project.owner}/{project.repo}
|
||||
"
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { tagNameError } = validateTagName({
|
||||
project,
|
||||
tagName: gitBatchInfo.value.latestRelease?.tagName,
|
||||
});
|
||||
if (tagNameError) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
{tagNameError.title && <AlertTitle>{tagNameError.title}</AlertTitle>}
|
||||
{tagNameError.subtitle}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RefetchContext.Provider value={{ fetchGitBatchInfo }}>
|
||||
<ErrorBoundary>
|
||||
{gitBatchInfo.value.latestRelease && !versioningStrategyMatches && (
|
||||
<Alert severity="warning" style={{ marginBottom: 20 }}>
|
||||
Versioning mismatch, expected {project.versioningStrategy} version,
|
||||
got "{gitBatchInfo.value.latestRelease.tagName}"
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!gitBatchInfo.value.latestRelease && (
|
||||
<Alert severity="info" style={{ marginBottom: 20 }}>
|
||||
This repository doesn't have any releases yet
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!gitBatchInfo.value.releaseBranch && (
|
||||
<Alert severity="info" style={{ marginBottom: 20 }}>
|
||||
This repository doesn't have any release branches
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!features?.info?.omit && (
|
||||
<Info
|
||||
latestRelease={gitBatchInfo.value.latestRelease}
|
||||
releaseBranch={gitBatchInfo.value.releaseBranch}
|
||||
statsEnabled={features?.stats?.omit !== true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!features?.createRc?.omit && (
|
||||
<CreateReleaseCandidate
|
||||
latestRelease={gitBatchInfo.value.latestRelease}
|
||||
releaseBranch={gitBatchInfo.value.releaseBranch}
|
||||
defaultBranch={gitBatchInfo.value.repository.defaultBranch}
|
||||
onSuccess={features?.createRc?.onSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!features?.promoteRc?.omit && (
|
||||
<PromoteRc
|
||||
latestRelease={gitBatchInfo.value.latestRelease}
|
||||
onSuccess={features?.promoteRc?.onSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!features?.patch?.omit && (
|
||||
<Patch
|
||||
latestRelease={gitBatchInfo.value.latestRelease}
|
||||
releaseBranch={gitBatchInfo.value.releaseBranch}
|
||||
onSuccess={features?.patch?.onSuccess}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</RefetchContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockCalverProject,
|
||||
mockReleaseBranch,
|
||||
mockReleaseCandidateCalver,
|
||||
} from '../../test-helpers/test-helpers';
|
||||
import { Info } from './Info';
|
||||
|
||||
jest.mock('../../contexts/ProjectContext', () => ({
|
||||
useProjectContext: () => ({
|
||||
project: mockCalverProject,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Info', () => {
|
||||
it('should return early if no latestRelease exists', async () => {
|
||||
const { findByText } = render(
|
||||
<Info
|
||||
latestRelease={mockReleaseCandidateCalver}
|
||||
releaseBranch={mockReleaseBranch}
|
||||
statsEnabled
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText(mockReleaseBranch.name)).toMatchInlineSnapshot(`
|
||||
<span
|
||||
data-testid="grm--differ-next"
|
||||
style="font-weight: bold;"
|
||||
>
|
||||
rc/1.2.3
|
||||
</span>
|
||||
`);
|
||||
|
||||
expect(
|
||||
await findByText(`${mockCalverProject.owner}/${mockCalverProject.repo}`),
|
||||
).toMatchInlineSnapshot(`
|
||||
<span
|
||||
data-testid="grm--differ-next"
|
||||
style="font-weight: bold;"
|
||||
>
|
||||
mock_owner/mock_repo
|
||||
</span>
|
||||
`);
|
||||
|
||||
expect(await findByText(mockCalverProject.versioningStrategy))
|
||||
.toMatchInlineSnapshot(`
|
||||
<span
|
||||
data-testid="grm--differ-next"
|
||||
style="font-weight: bold;"
|
||||
>
|
||||
calver
|
||||
</span>
|
||||
`);
|
||||
|
||||
expect(await findByText(mockReleaseCandidateCalver.tagName))
|
||||
.toMatchInlineSnapshot(`
|
||||
<span
|
||||
data-testid="grm--differ-next"
|
||||
style="font-weight: bold;"
|
||||
>
|
||||
rc-2020.01.01_1
|
||||
</span>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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, { useState } from 'react';
|
||||
import { Typography, Button, Box } from '@material-ui/core';
|
||||
import BarChartIcon from '@material-ui/icons/BarChart';
|
||||
|
||||
import {
|
||||
GetBranchResult,
|
||||
GetLatestReleaseResult,
|
||||
} from '../../api/GitReleaseClient';
|
||||
import { Differ } from '../../components/Differ';
|
||||
import { InfoCardPlus } from '../../components/InfoCardPlus';
|
||||
import { Stats } from '../Stats/Stats';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
import flowImage from './flow.png';
|
||||
import { Link } from '@backstage/core';
|
||||
|
||||
interface InfoCardProps {
|
||||
releaseBranch: GetBranchResult['branch'] | null;
|
||||
latestRelease: GetLatestReleaseResult['latestRelease'];
|
||||
statsEnabled: boolean;
|
||||
}
|
||||
|
||||
export const Info = ({
|
||||
releaseBranch,
|
||||
latestRelease,
|
||||
statsEnabled,
|
||||
}: InfoCardProps) => {
|
||||
const { project } = useProjectContext();
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
|
||||
return (
|
||||
<InfoCardPlus>
|
||||
<Box marginBottom={1} data-testid={TEST_IDS.info.info}>
|
||||
<Typography variant="h6">Terminology</Typography>
|
||||
|
||||
<Typography>
|
||||
<strong>Git</strong>: The source control system where releases reside
|
||||
in a practical sense. Read more about{' '}
|
||||
<Link
|
||||
to="https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository"
|
||||
target="_blank"
|
||||
>
|
||||
Git releases
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
|
||||
<Typography>
|
||||
<strong>Release Candidate</strong>: A Git <i>prerelease</i> intended
|
||||
primarily for internal testing
|
||||
</Typography>
|
||||
|
||||
<Typography>
|
||||
<strong>Release Version</strong>: A Git release intended for end users
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Typography variant="h6">Flow</Typography>
|
||||
|
||||
<Box marginBottom={2}>
|
||||
<Typography>
|
||||
Git Release Manager is built with a specific flow in mind. For
|
||||
example, it assumes your project is configured to react to tags
|
||||
prefixed with <b>rc</b> or <b>version</b>.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={2}>
|
||||
<Typography>Here's an overview of the flow:</Typography>
|
||||
</Box>
|
||||
|
||||
<img alt="flow" src={flowImage} style={{ width: '100%' }} />
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Typography variant="h6">Details</Typography>
|
||||
|
||||
<Typography>
|
||||
Repository:{' '}
|
||||
<Differ icon="github" next={`${project.owner}/${project.repo}`} />
|
||||
</Typography>
|
||||
|
||||
<Typography>
|
||||
Versioning strategy:{' '}
|
||||
<Differ icon="versioning" next={project.versioningStrategy} />
|
||||
</Typography>
|
||||
|
||||
<Typography>
|
||||
Latest release branch:{' '}
|
||||
<Differ icon="branch" next={releaseBranch?.name} />
|
||||
</Typography>
|
||||
|
||||
<Typography>
|
||||
Latest release: <Differ icon="tag" next={latestRelease?.tagName} />
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{statsEnabled && (
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => setShowStats(true)}
|
||||
startIcon={<BarChartIcon />}
|
||||
size="small"
|
||||
>
|
||||
Show stats
|
||||
</Button>
|
||||
|
||||
{showStats && <Stats setShowStats={setShowStats} />}
|
||||
</Box>
|
||||
)}
|
||||
</InfoCardPlus>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -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 { render } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockReleaseBranch,
|
||||
mockCalverProject,
|
||||
} from '../../test-helpers/test-helpers';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { Patch } from './Patch';
|
||||
|
||||
jest.mock('../../contexts/ProjectContext', () => ({
|
||||
useProjectContext: () => ({
|
||||
project: mockCalverProject,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Patch', () => {
|
||||
it('should return early if no latestRelease exists', () => {
|
||||
const { getByTestId } = render(
|
||||
<Patch latestRelease={null} releaseBranch={mockReleaseBranch} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.noLatestRelease),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 { Typography, Box } from '@material-ui/core';
|
||||
import { Alert, AlertTitle } from '@material-ui/lab';
|
||||
|
||||
import {
|
||||
GetBranchResult,
|
||||
GetLatestReleaseResult,
|
||||
} from '../../api/GitReleaseClient';
|
||||
import { ComponentConfigPatch } from '../../types/types';
|
||||
import { getBumpedTag } from '../../helpers/getBumpedTag';
|
||||
import { InfoCardPlus } from '../../components/InfoCardPlus';
|
||||
import { NoLatestRelease } from '../../components/NoLatestRelease';
|
||||
import { PatchBody } from './PatchBody';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
|
||||
interface PatchProps {
|
||||
latestRelease: GetLatestReleaseResult['latestRelease'];
|
||||
releaseBranch: GetBranchResult['branch'] | null;
|
||||
onSuccess?: ComponentConfigPatch['onSuccess'];
|
||||
}
|
||||
|
||||
export const Patch = ({
|
||||
latestRelease,
|
||||
releaseBranch,
|
||||
onSuccess,
|
||||
}: PatchProps) => {
|
||||
return (
|
||||
<InfoCardPlus>
|
||||
<Box marginBottom={2}>
|
||||
<Typography variant="h4">
|
||||
Patch Release {latestRelease?.prerelease ? 'Candidate' : 'Version'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<BodyWrapper
|
||||
latestRelease={latestRelease}
|
||||
releaseBranch={releaseBranch}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</InfoCardPlus>
|
||||
);
|
||||
};
|
||||
|
||||
function BodyWrapper({ latestRelease, releaseBranch, onSuccess }: PatchProps) {
|
||||
const { project } = useProjectContext();
|
||||
|
||||
if (latestRelease === null) {
|
||||
return <NoLatestRelease />;
|
||||
}
|
||||
|
||||
if (releaseBranch === null) {
|
||||
return <NoLatestRelease />;
|
||||
}
|
||||
|
||||
const bumpedTag = getBumpedTag({
|
||||
project,
|
||||
tag: latestRelease.tagName,
|
||||
bumpLevel: 'patch',
|
||||
});
|
||||
|
||||
if (bumpedTag.error !== undefined) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
{bumpedTag.error.title && (
|
||||
<AlertTitle>{bumpedTag.error.title}</AlertTitle>
|
||||
)}
|
||||
|
||||
{bumpedTag.error.subtitle}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PatchBody
|
||||
bumpedTag={bumpedTag.bumpedTag}
|
||||
latestRelease={latestRelease}
|
||||
releaseBranch={releaseBranch}
|
||||
onSuccess={onSuccess}
|
||||
tagParts={bumpedTag.tagParts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 { render, waitFor, screen } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockApiClient,
|
||||
mockBumpedTag,
|
||||
mockCalverProject,
|
||||
mockReleaseBranch,
|
||||
mockReleaseCandidateCalver,
|
||||
mockReleaseVersionCalver,
|
||||
mockTagParts,
|
||||
} from '../../test-helpers/test-helpers';
|
||||
import { PatchBody } from './PatchBody';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
|
||||
jest.mock('@backstage/core', () => ({
|
||||
...jest.requireActual('@backstage/core'),
|
||||
useApi: () => mockApiClient,
|
||||
}));
|
||||
jest.mock('../../contexts/ProjectContext', () => ({
|
||||
useProjectContext: () => ({
|
||||
project: mockCalverProject,
|
||||
}),
|
||||
}));
|
||||
jest.mock('./hooks/usePatch', () => ({
|
||||
usePatch: () => ({
|
||||
run: jest.fn(),
|
||||
responseSteps: [],
|
||||
progress: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PatchBody', () => {
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
it('should render error', async () => {
|
||||
(mockApiClient.getRecentCommits as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new Error('banana');
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<PatchBody
|
||||
bumpedTag={mockBumpedTag}
|
||||
latestRelease={mockReleaseCandidateCalver}
|
||||
releaseBranch={mockReleaseBranch}
|
||||
tagParts={mockTagParts}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_IDS.patch.loading)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => screen.getByTestId(TEST_IDS.patch.error));
|
||||
|
||||
expect(getByTestId(TEST_IDS.patch.error)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render not-prerelease description', async () => {
|
||||
const { getByTestId } = render(
|
||||
<PatchBody
|
||||
latestRelease={mockReleaseVersionCalver}
|
||||
releaseBranch={mockReleaseBranch}
|
||||
bumpedTag={mockBumpedTag}
|
||||
tagParts={mockTagParts}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_IDS.patch.loading)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => screen.getByTestId(TEST_IDS.patch.notPrerelease));
|
||||
|
||||
expect(getByTestId(TEST_IDS.patch.notPrerelease)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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 { useAsync } from 'react-use';
|
||||
import { Alert, AlertTitle } from '@material-ui/lab';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import FileCopyIcon from '@material-ui/icons/FileCopy';
|
||||
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
|
||||
import { Link, Progress, useApi } from '@backstage/core';
|
||||
|
||||
import {
|
||||
GetBranchResult,
|
||||
GetLatestReleaseResult,
|
||||
} from '../../api/GitReleaseClient';
|
||||
import { CalverTagParts } from '../../helpers/tagParts/getCalverTagParts';
|
||||
import { ComponentConfigPatch } from '../../types/types';
|
||||
import { Differ } from '../../components/Differ';
|
||||
import { getPatchCommitSuffix } from './helpers/getPatchCommitSuffix';
|
||||
import { gitReleaseManagerApiRef } from '../../api/serviceApiRef';
|
||||
import { GitReleaseManagerError } from '../../errors/GitReleaseManagerError';
|
||||
import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog';
|
||||
import { SemverTagParts } from '../../helpers/tagParts/getSemverTagParts';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { usePatch } from './hooks/usePatch';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
|
||||
interface PatchBodyProps {
|
||||
bumpedTag: string;
|
||||
latestRelease: NonNullable<GetLatestReleaseResult['latestRelease']>;
|
||||
releaseBranch: GetBranchResult['branch'];
|
||||
onSuccess?: ComponentConfigPatch['onSuccess'];
|
||||
tagParts: NonNullable<CalverTagParts | SemverTagParts>;
|
||||
}
|
||||
|
||||
export const PatchBody = ({
|
||||
bumpedTag,
|
||||
latestRelease,
|
||||
releaseBranch,
|
||||
onSuccess,
|
||||
tagParts,
|
||||
}: PatchBodyProps) => {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { project } = useProjectContext();
|
||||
const [checkedCommitIndex, setCheckedCommitIndex] = useState(-1);
|
||||
|
||||
const gitDataResponse = useAsync(async () => {
|
||||
const [
|
||||
{ recentCommits: recentCommitsOnDefaultBranch },
|
||||
{ recentCommits: recentCommitsOnReleaseBranch },
|
||||
] = await Promise.all([
|
||||
pluginApiClient.getRecentCommits({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
}),
|
||||
pluginApiClient.getRecentCommits({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
releaseBranchName: releaseBranch.name,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
recentCommitsOnDefaultBranch,
|
||||
recentCommitsOnReleaseBranch,
|
||||
};
|
||||
});
|
||||
|
||||
const { progress, responseSteps, run, runInvoked } = usePatch({
|
||||
bumpedTag,
|
||||
latestRelease,
|
||||
project,
|
||||
tagParts,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
if (responseSteps.length > 0) {
|
||||
return (
|
||||
<ResponseStepDialog
|
||||
progress={progress}
|
||||
responseSteps={responseSteps}
|
||||
title="Patch Release Candidate"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (gitDataResponse.error) {
|
||||
return (
|
||||
<Alert data-testid={TEST_IDS.patch.error} severity="error">
|
||||
Unexpected error: {gitDataResponse.error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (gitDataResponse.loading) {
|
||||
return (
|
||||
<Box data-testid={TEST_IDS.patch.loading}>
|
||||
<Progress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Description() {
|
||||
return (
|
||||
<>
|
||||
{!latestRelease.prerelease && (
|
||||
<Box marginBottom={2}>
|
||||
<Alert data-testid={TEST_IDS.patch.notPrerelease} severity="info">
|
||||
<AlertTitle>
|
||||
The current Git release is a <b>Release Version</b>
|
||||
</AlertTitle>
|
||||
It's still possible to patch it, but be extra mindful of changes
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginBottom={2}>
|
||||
<Typography>
|
||||
<Differ
|
||||
icon="tag"
|
||||
current={latestRelease.tagName}
|
||||
next={bumpedTag}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CommitList() {
|
||||
if (!gitDataResponse.value?.recentCommitsOnDefaultBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<List>
|
||||
{gitDataResponse.value.recentCommitsOnDefaultBranch.map(
|
||||
(commit, index) => {
|
||||
// FIXME: Performance improvement opportunity: Convert to object lookup
|
||||
const commitExistsOnReleaseBranch = !!gitDataResponse.value?.recentCommitsOnReleaseBranch.find(
|
||||
releaseBranchCommit =>
|
||||
releaseBranchCommit.sha === commit.sha ||
|
||||
// The selected patch commit's sha is included in the commit message,
|
||||
// which means it's part of a previous patch
|
||||
releaseBranchCommit.commit.message.includes(
|
||||
getPatchCommitSuffix({ commitSha: commit.sha }),
|
||||
),
|
||||
);
|
||||
const hasNoParent = !commit.firstParentSha;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }} key={`commit-${index}`}>
|
||||
{commitExistsOnReleaseBranch && (
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate3d(-50%,-50%,0)',
|
||||
zIndex: 10,
|
||||
color: 'green',
|
||||
padding: 6,
|
||||
background: 'rgba(244,244,244,1)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<FileCopyIcon
|
||||
fontSize="small"
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
/>{' '}
|
||||
Already exists on <b>{releaseBranch?.name}</b>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<ListItem
|
||||
disabled={
|
||||
runInvoked || commitExistsOnReleaseBranch || hasNoParent
|
||||
}
|
||||
role={undefined}
|
||||
dense
|
||||
button
|
||||
onClick={() => {
|
||||
if (index === checkedCommitIndex) {
|
||||
setCheckedCommitIndex(-1);
|
||||
} else {
|
||||
setCheckedCommitIndex(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={checkedCommitIndex === index}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
|
||||
<ListItemText
|
||||
style={{ marginRight: 15 }}
|
||||
id={commit.sha}
|
||||
primary={commit.commit.message}
|
||||
secondary={
|
||||
<>
|
||||
<Link
|
||||
color="primary"
|
||||
to={commit.htmlUrl}
|
||||
target="_blank"
|
||||
>
|
||||
{commit.sha}
|
||||
</Link>{' '}
|
||||
{commit.author.htmlUrl && (
|
||||
<Link
|
||||
color="primary"
|
||||
to={commit.author.htmlUrl}
|
||||
target="_blank"
|
||||
>
|
||||
@{commit.author.login}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
aria-label="commit"
|
||||
disabled={
|
||||
runInvoked ||
|
||||
commitExistsOnReleaseBranch ||
|
||||
!releaseBranch
|
||||
}
|
||||
onClick={() => {
|
||||
const repoPath = pluginApiClient.getRepoPath({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
});
|
||||
const host = pluginApiClient.getHost();
|
||||
|
||||
const newTab = window.open(
|
||||
`https://${host}/${repoPath}/compare/${releaseBranch?.name}...${commit.sha}`,
|
||||
'_blank',
|
||||
);
|
||||
newTab?.focus();
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-testid={TEST_IDS.patch.body}>
|
||||
<Description />
|
||||
|
||||
<Box style={{ maxHeight: 450, overflowY: 'auto' }}>
|
||||
<CommitList />
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
disabled={checkedCommitIndex === -1 || progress > 0}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
const selectedPatchCommit =
|
||||
gitDataResponse.value?.recentCommitsOnDefaultBranch[
|
||||
checkedCommitIndex
|
||||
];
|
||||
if (!selectedPatchCommit) {
|
||||
throw new GitReleaseManagerError(
|
||||
'Could not find selected patch commit',
|
||||
);
|
||||
}
|
||||
|
||||
run(selectedPatchCommit);
|
||||
}}
|
||||
>
|
||||
Patch Release Candidate
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const getPatchCommitSuffix = ({ commitSha }: { commitSha: string }) => {
|
||||
return `[Backstage patch ${commitSha}]`;
|
||||
};
|
||||
@@ -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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockApiClient,
|
||||
mockBumpedTag,
|
||||
mockCalverProject,
|
||||
mockReleaseVersionCalver,
|
||||
mockSelectedPatchCommit,
|
||||
mockTagParts,
|
||||
mockUser,
|
||||
} from '../../../test-helpers/test-helpers';
|
||||
import { usePatch } from './usePatch';
|
||||
|
||||
jest.mock('@backstage/core', () => ({
|
||||
...jest.requireActual('@backstage/core'),
|
||||
useApi: () => mockApiClient,
|
||||
}));
|
||||
jest.mock('../../../contexts/UserContext', () => ({
|
||||
useUserContext: () => ({ user: mockUser }),
|
||||
}));
|
||||
|
||||
describe('patch', () => {
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
it('should return the expected responseSteps and progress', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePatch({
|
||||
bumpedTag: mockBumpedTag,
|
||||
latestRelease: mockReleaseVersionCalver,
|
||||
project: mockCalverProject,
|
||||
tagParts: mockTagParts,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await waitFor(() => result.current.run(mockSelectedPatchCommit));
|
||||
});
|
||||
|
||||
expect(result.error).toEqual(undefined);
|
||||
expect(result.current.responseSteps).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should return the expected responseSteps and progress (with onSuccess)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePatch({
|
||||
bumpedTag: mockBumpedTag,
|
||||
latestRelease: mockReleaseVersionCalver,
|
||||
project: mockCalverProject,
|
||||
tagParts: mockTagParts,
|
||||
onSuccess: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await waitFor(() => result.current.run(mockSelectedPatchCommit));
|
||||
});
|
||||
|
||||
expect(result.error).toEqual(undefined);
|
||||
expect(result.current.responseSteps).toHaveLength(10);
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"progress": 100,
|
||||
"responseSteps": Array [
|
||||
Object {
|
||||
"link": "https://mock_branch_links_html",
|
||||
"message": "Fetched release branch \\"rc/1.2.3\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Created temporary commit",
|
||||
"secondaryMessage": "with message \\"mock_commit_message\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Forced branch \\"rc/2020.01.01_1\\" to temporary commit \\"mock_commit_sha\\"",
|
||||
},
|
||||
Object {
|
||||
"link": "https://mock_merge_html_url",
|
||||
"message": "Merged temporary commit into \\"rc/2020.01.01_1\\"",
|
||||
"secondaryMessage": "with message \\"mock_merge_commit_message\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Cherry-picked patch commit to \\"mock_branch_commit_sha\\"",
|
||||
"secondaryMessage": "with message \\"mock_commit_message\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Updated reference \\"mock_update_ref_ref\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Created new tag object",
|
||||
"secondaryMessage": "with name \\"mock_tag_object_tag\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Created new reference \\"mock_createRef_ref\\"",
|
||||
"secondaryMessage": "for tag object \\"mock_tag_object_tag\\"",
|
||||
},
|
||||
Object {
|
||||
"link": "https://mock_update_release_html_url",
|
||||
"message": "Updated release \\"mock_update_release_name\\"",
|
||||
"secondaryMessage": "with tag mock_update_release_tag_name",
|
||||
},
|
||||
Object {
|
||||
"icon": "success",
|
||||
"message": "Success callback successfully called 🚀",
|
||||
},
|
||||
],
|
||||
"run": [Function],
|
||||
"runInvoked": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core';
|
||||
|
||||
import {
|
||||
GetLatestReleaseResult,
|
||||
GetRecentCommitsResultSingle,
|
||||
} from '../../../api/GitReleaseClient';
|
||||
import { CalverTagParts } from '../../../helpers/tagParts/getCalverTagParts';
|
||||
import { ComponentConfigPatch, CardHook } from '../../../types/types';
|
||||
import { getPatchCommitSuffix } from '../helpers/getPatchCommitSuffix';
|
||||
import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef';
|
||||
import { Project } from '../../../contexts/ProjectContext';
|
||||
import { SemverTagParts } from '../../../helpers/tagParts/getSemverTagParts';
|
||||
import { TAG_OBJECT_MESSAGE } from '../../../constants/constants';
|
||||
import { useResponseSteps } from '../../../hooks/useResponseSteps';
|
||||
import { useUserContext } from '../../../contexts/UserContext';
|
||||
|
||||
interface Patch {
|
||||
bumpedTag: string;
|
||||
latestRelease: NonNullable<GetLatestReleaseResult['latestRelease']>;
|
||||
project: Project;
|
||||
tagParts: NonNullable<CalverTagParts | SemverTagParts>;
|
||||
onSuccess?: ComponentConfigPatch['onSuccess'];
|
||||
}
|
||||
|
||||
// Inspiration: https://stackoverflow.com/questions/53859199/how-to-cherry-pick-through-githubs-api
|
||||
export function usePatch({
|
||||
bumpedTag,
|
||||
latestRelease,
|
||||
project,
|
||||
tagParts,
|
||||
onSuccess,
|
||||
}: Patch): CardHook<GetRecentCommitsResultSingle> {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { user } = useUserContext();
|
||||
const {
|
||||
responseSteps,
|
||||
addStepToResponseSteps,
|
||||
asyncCatcher,
|
||||
abortIfError,
|
||||
} = useResponseSteps();
|
||||
|
||||
const releaseBranchName = latestRelease.targetCommitish;
|
||||
|
||||
/**
|
||||
* (1) Here is the branch we want to cherry-pick to:
|
||||
* > branch = GET /repos/$owner/$repo/branches/$branchName
|
||||
* > branchSha = branch.commit.sha
|
||||
* > branchTree = branch.commit.commit.tree.sha
|
||||
*/
|
||||
const [releaseBranchRes, run] = useAsyncFn(
|
||||
async (selectedPatchCommit: GetRecentCommitsResultSingle) => {
|
||||
const { branch: releaseBranch } = await pluginApiClient
|
||||
.getBranch({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
branch: releaseBranchName,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Fetched release branch "${releaseBranch.name}"`,
|
||||
link: releaseBranch.links.html,
|
||||
});
|
||||
|
||||
return {
|
||||
releaseBranch,
|
||||
selectedPatchCommit,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* (2) Create a temporary commit on the branch, which extends as a sibling of
|
||||
* the commit we want but contains the current tree of the target branch:
|
||||
* > parentSha = commit.parents.head // first parent -- there should only be one
|
||||
* > tempCommit = POST /repos/$owner/$repo/git/commits { "message": "temp", "tree": branchTree, "parents": [parentSha] }
|
||||
*/
|
||||
const tempCommitRes = useAsync(async () => {
|
||||
abortIfError(releaseBranchRes.error);
|
||||
if (!releaseBranchRes.value) return undefined;
|
||||
|
||||
const { commit: tempCommit } = await pluginApiClient
|
||||
.createCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
message: `Temporary commit for patch ${tagParts.patch}`,
|
||||
parents: [
|
||||
releaseBranchRes.value.selectedPatchCommit.firstParentSha ?? '',
|
||||
],
|
||||
tree: releaseBranchRes.value.releaseBranch.commit.commit.tree.sha,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Created temporary commit',
|
||||
secondaryMessage: `with message "${tempCommit.message}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...tempCommit,
|
||||
};
|
||||
}, [releaseBranchRes.value, releaseBranchRes.error]);
|
||||
|
||||
/**
|
||||
* (3) Now temporarily force the branch over to that commit:
|
||||
* > PATCH /repos/$owner/$repo/git/refs/heads/$refName { sha = tempCommit.sha, force = true }
|
||||
*/
|
||||
const forceBranchRes = useAsync(async () => {
|
||||
abortIfError(tempCommitRes.error);
|
||||
if (!tempCommitRes.value) return undefined;
|
||||
|
||||
await pluginApiClient
|
||||
.updateRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
sha: tempCommitRes.value.sha,
|
||||
ref: `heads/${releaseBranchName}`,
|
||||
force: true,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Forced branch "${releaseBranchName}" to temporary commit "${tempCommitRes.value.sha}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
trigger: 'next step 🚀 ',
|
||||
};
|
||||
}, [tempCommitRes.value, tempCommitRes.error]);
|
||||
|
||||
/**
|
||||
* (4) Merge the commit we want into this mess:
|
||||
* > merge = POST /repos/$owner/$repo/merges { "base": branchName, "head": commit.sha }
|
||||
*/
|
||||
const mergeRes = useAsync(async () => {
|
||||
abortIfError(forceBranchRes.error);
|
||||
if (!forceBranchRes.value || !releaseBranchRes.value) return undefined;
|
||||
|
||||
const { merge } = await pluginApiClient
|
||||
.merge({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
base: releaseBranchName,
|
||||
head: releaseBranchRes.value.selectedPatchCommit.sha,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Merged temporary commit into "${releaseBranchName}"`,
|
||||
secondaryMessage: `with message "${merge.commit.message}"`,
|
||||
link: merge.htmlUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
...merge,
|
||||
};
|
||||
}, [forceBranchRes.value, forceBranchRes.error]);
|
||||
|
||||
/**
|
||||
* (5) Now that we know what the tree should be, create the cherry-pick commit.
|
||||
* Note that branchSha is the original from up at the top.
|
||||
* > cherry = POST /repos/$owner/$repo/git/commits { "message": "looks good!", "tree": mergeTree, "parents": [branchSha] }
|
||||
*/
|
||||
const cherryPickRes = useAsync(async () => {
|
||||
abortIfError(mergeRes.error);
|
||||
if (!mergeRes.value || !releaseBranchRes.value) return undefined;
|
||||
|
||||
const releaseBranchSha = releaseBranchRes.value.releaseBranch.commit.sha;
|
||||
const selectedPatchCommit = releaseBranchRes.value.selectedPatchCommit;
|
||||
|
||||
const { commit: cherryPickCommit } = await pluginApiClient.createCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
message: `[patch ${bumpedTag}] ${selectedPatchCommit.commit.message}
|
||||
|
||||
${getPatchCommitSuffix({
|
||||
commitSha: selectedPatchCommit.sha,
|
||||
})}`,
|
||||
parents: [releaseBranchSha],
|
||||
tree: mergeRes.value.commit.tree.sha,
|
||||
});
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Cherry-picked patch commit to "${releaseBranchSha}"`,
|
||||
secondaryMessage: `with message "${cherryPickCommit.message}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...cherryPickCommit,
|
||||
};
|
||||
}, [mergeRes.value, mergeRes.error]);
|
||||
|
||||
/**
|
||||
* (6) Replace the temp commit with the real commit:
|
||||
* > PATCH /repos/$owner/$repo/git/refs/heads/$refName { sha = cherry.sha, force = true }
|
||||
*/
|
||||
const updatedRefRes = useAsync(async () => {
|
||||
abortIfError(cherryPickRes.error);
|
||||
if (!cherryPickRes.value) return undefined;
|
||||
|
||||
const { reference: updatedReference } = await pluginApiClient
|
||||
.updateRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: `heads/${releaseBranchName}`,
|
||||
sha: cherryPickRes.value.sha,
|
||||
force: true,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Updated reference "${updatedReference.ref}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...updatedReference,
|
||||
};
|
||||
}, [cherryPickRes.value, cherryPickRes.error]);
|
||||
|
||||
/**
|
||||
* (7) Create tag object: https://developer.github.com/v3/git/tags/#create-a-tag-object
|
||||
* > POST /repos/:owner/:repo/git/tags
|
||||
*/
|
||||
const createdTagObjRes = useAsync(async () => {
|
||||
abortIfError(updatedRefRes.error);
|
||||
if (!updatedRefRes.value) return undefined;
|
||||
|
||||
const { tagObject } = await pluginApiClient
|
||||
.createTagObject({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
tag: bumpedTag,
|
||||
object: updatedRefRes.value.object.sha,
|
||||
message: TAG_OBJECT_MESSAGE,
|
||||
taggerName: user.username,
|
||||
taggerEmail: user.email,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Created new tag object',
|
||||
secondaryMessage: `with name "${tagObject.tagName}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...tagObject,
|
||||
};
|
||||
}, [updatedRefRes.value, updatedRefRes.error]);
|
||||
|
||||
/**
|
||||
* (8) Create a reference: https://developer.github.com/v3/git/refs/#create-a-reference
|
||||
* > POST /repos/:owner/:repo/git/refs
|
||||
*/
|
||||
const createdReferenceRes = useAsync(async () => {
|
||||
abortIfError(createdTagObjRes.error);
|
||||
if (!createdTagObjRes.value) return undefined;
|
||||
|
||||
const { reference } = await pluginApiClient
|
||||
.createRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: `refs/tags/${bumpedTag}`,
|
||||
sha: createdTagObjRes.value.tagSha,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Created new reference "${reference.ref}"`,
|
||||
secondaryMessage: `for tag object "${createdTagObjRes.value.tagName}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...reference,
|
||||
};
|
||||
}, [createdTagObjRes.value, createdTagObjRes.error]);
|
||||
|
||||
/**
|
||||
* (9) Update release
|
||||
*/
|
||||
const updatedReleaseRes = useAsync(async () => {
|
||||
abortIfError(createdReferenceRes.error);
|
||||
if (!createdReferenceRes.value || !releaseBranchRes.value) return undefined;
|
||||
|
||||
const selectedPatchCommit = releaseBranchRes.value.selectedPatchCommit;
|
||||
|
||||
const { release } = await pluginApiClient
|
||||
.updateRelease({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
releaseId: latestRelease.id,
|
||||
tagName: bumpedTag,
|
||||
body: `${latestRelease.body}
|
||||
|
||||
#### [Patch ${tagParts.patch}](${selectedPatchCommit.htmlUrl})
|
||||
|
||||
${selectedPatchCommit.commit.message}`,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Updated release "${release.name}"`,
|
||||
secondaryMessage: `with tag ${release.tagName}`,
|
||||
link: release.htmlUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
...release,
|
||||
};
|
||||
}, [createdReferenceRes.value, createdReferenceRes.error]);
|
||||
|
||||
/**
|
||||
* (10) Run onSuccess if defined
|
||||
*/
|
||||
useAsync(async () => {
|
||||
if (!onSuccess) return;
|
||||
abortIfError(updatedReleaseRes.error);
|
||||
|
||||
if (!updatedReleaseRes.value || !releaseBranchRes.value) return;
|
||||
|
||||
try {
|
||||
await onSuccess?.({
|
||||
updatedReleaseUrl: updatedReleaseRes.value.htmlUrl,
|
||||
updatedReleaseName: updatedReleaseRes.value.name,
|
||||
previousTag: latestRelease.tagName,
|
||||
patchedTag: updatedReleaseRes.value.tagName,
|
||||
patchCommitUrl: releaseBranchRes.value.selectedPatchCommit.htmlUrl,
|
||||
patchCommitMessage:
|
||||
releaseBranchRes.value.selectedPatchCommit.commit.message,
|
||||
});
|
||||
} catch (error) {
|
||||
asyncCatcher(error);
|
||||
}
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Success callback successfully called 🚀',
|
||||
icon: 'success',
|
||||
});
|
||||
}, [updatedReleaseRes.value, updatedReleaseRes.error]);
|
||||
|
||||
const TOTAL_STEPS = 9 + (!!onSuccess ? 1 : 0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
useEffect(() => {
|
||||
setProgress((responseSteps.length / TOTAL_STEPS) * 100);
|
||||
}, [TOTAL_STEPS, responseSteps.length]);
|
||||
|
||||
return {
|
||||
progress,
|
||||
responseSteps,
|
||||
run,
|
||||
runInvoked: Boolean(
|
||||
releaseBranchRes.loading ||
|
||||
releaseBranchRes.value ||
|
||||
releaseBranchRes.error,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockReleaseCandidateCalver,
|
||||
mockReleaseVersionCalver,
|
||||
} from '../../test-helpers/test-helpers';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { PromoteRc } from './PromoteRc';
|
||||
|
||||
jest.mock('./PromoteRcBody', () => ({
|
||||
PromoteRcBody: () => (
|
||||
<div data-testid={TEST_IDS.promoteRc.mockedPromoteRcBody}>Hello</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('PromoteRc', () => {
|
||||
it('return early if no latest release present', () => {
|
||||
const { getByTestId } = render(<PromoteRc latestRelease={null} />);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.components.noLatestRelease),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display not-rc warning', () => {
|
||||
const { getByTestId } = render(
|
||||
<PromoteRc latestRelease={mockReleaseVersionCalver} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_IDS.promoteRc.notRcWarning)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display PromoteRcBody', () => {
|
||||
const { getByTestId } = render(
|
||||
<PromoteRc latestRelease={mockReleaseCandidateCalver} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByTestId(TEST_IDS.promoteRc.mockedPromoteRcBody),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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, AlertTitle } from '@material-ui/lab';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
|
||||
import { ComponentConfigPromoteRc } from '../../types/types';
|
||||
import { GetLatestReleaseResult } from '../../api/GitReleaseClient';
|
||||
import { InfoCardPlus } from '../../components/InfoCardPlus';
|
||||
import { NoLatestRelease } from '../../components/NoLatestRelease';
|
||||
import { PromoteRcBody } from './PromoteRcBody';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
|
||||
interface PromoteRcProps {
|
||||
latestRelease: GetLatestReleaseResult['latestRelease'];
|
||||
onSuccess?: ComponentConfigPromoteRc['onSuccess'];
|
||||
}
|
||||
|
||||
export const PromoteRc = ({ latestRelease, onSuccess }: PromoteRcProps) => {
|
||||
function Body() {
|
||||
if (latestRelease === null) {
|
||||
return <NoLatestRelease />;
|
||||
}
|
||||
|
||||
if (!latestRelease.prerelease) {
|
||||
return (
|
||||
<Box marginBottom={2}>
|
||||
<Alert
|
||||
data-testid={TEST_IDS.promoteRc.notRcWarning}
|
||||
severity="warning"
|
||||
>
|
||||
<AlertTitle>
|
||||
Latest Git release is not a Release Candidate
|
||||
</AlertTitle>
|
||||
One can only promote Release Candidates to Release Versions
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <PromoteRcBody rcRelease={latestRelease} onSuccess={onSuccess} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCardPlus>
|
||||
<Box marginBottom={2}>
|
||||
<Typography variant="h4">Promote Release Candidate</Typography>
|
||||
</Box>
|
||||
|
||||
<Body />
|
||||
</InfoCardPlus>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { mockReleaseCandidateCalver } from '../../test-helpers/test-helpers';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { PromoteRcBody } from './PromoteRcBody';
|
||||
|
||||
jest.mock('./hooks/usePromoteRc', () => ({
|
||||
usePromoteRc: () => ({
|
||||
run: jest.fn(),
|
||||
responseSteps: [],
|
||||
progress: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PromoteRcBody', () => {
|
||||
it('should display CTA', () => {
|
||||
const { getByTestId } = render(
|
||||
<PromoteRcBody rcRelease={mockReleaseCandidateCalver} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_IDS.promoteRc.cta)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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, Typography, Box } from '@material-ui/core';
|
||||
|
||||
import { ComponentConfigPromoteRc } from '../../types/types';
|
||||
import { Differ } from '../../components/Differ';
|
||||
import { GetLatestReleaseResult } from '../../api/GitReleaseClient';
|
||||
import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { usePromoteRc } from './hooks/usePromoteRc';
|
||||
|
||||
interface PromoteRcBodyProps {
|
||||
rcRelease: NonNullable<GetLatestReleaseResult['latestRelease']>;
|
||||
onSuccess?: ComponentConfigPromoteRc['onSuccess'];
|
||||
}
|
||||
|
||||
export const PromoteRcBody = ({ rcRelease, onSuccess }: PromoteRcBodyProps) => {
|
||||
const releaseVersion = rcRelease.tagName.replace('rc-', 'version-');
|
||||
|
||||
const { progress, responseSteps, run, runInvoked } = usePromoteRc({
|
||||
rcRelease,
|
||||
releaseVersion,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
if (responseSteps.length > 0) {
|
||||
return (
|
||||
<ResponseStepDialog
|
||||
progress={progress}
|
||||
responseSteps={responseSteps}
|
||||
title="Promote Release Candidate"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box marginBottom={2}>
|
||||
<Typography>
|
||||
Promotes the current Release Candidate to a <b>Release Version</b>.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={2}>
|
||||
<Typography>
|
||||
<Differ
|
||||
icon="tag"
|
||||
current={rcRelease.tagName}
|
||||
next={releaseVersion}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
data-testid={TEST_IDS.promoteRc.cta}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={runInvoked}
|
||||
onClick={() => run()}
|
||||
>
|
||||
Promote Release Candidate
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockApiClient,
|
||||
mockCalverProject,
|
||||
mockReleaseCandidateCalver,
|
||||
mockUser,
|
||||
} from '../../../test-helpers/test-helpers';
|
||||
import { usePromoteRc } from './usePromoteRc';
|
||||
|
||||
jest.mock('@backstage/core', () => ({
|
||||
...jest.requireActual('@backstage/core'),
|
||||
useApi: () => mockApiClient,
|
||||
}));
|
||||
jest.mock('../../../contexts/ProjectContext', () => ({
|
||||
useProjectContext: () => ({
|
||||
project: mockCalverProject,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../contexts/UserContext', () => ({
|
||||
useUserContext: () => ({ user: mockUser }),
|
||||
}));
|
||||
|
||||
describe('usePromoteRc', () => {
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
it('should return the expected responseSteps and progress', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePromoteRc({
|
||||
rcRelease: mockReleaseCandidateCalver,
|
||||
releaseVersion: 'version-1.2.3',
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await waitFor(() => result.current.run());
|
||||
});
|
||||
|
||||
expect(result.error).toEqual(undefined);
|
||||
expect(result.current.responseSteps).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should return the expected responseSteps and progress (with onSuccess)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePromoteRc({
|
||||
rcRelease: mockReleaseCandidateCalver,
|
||||
releaseVersion: 'version-1.2.3',
|
||||
onSuccess: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await waitFor(() => result.current.run());
|
||||
});
|
||||
|
||||
expect(result.current.responseSteps).toHaveLength(5);
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"progress": 100,
|
||||
"responseSteps": Array [
|
||||
Object {
|
||||
"message": "Fetched most recent commit from release branch",
|
||||
"secondaryMessage": "with sha \\"latestCommit.sha\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Created Tag Object",
|
||||
"secondaryMessage": "with sha \\"mock_tag_object_sha\\"",
|
||||
},
|
||||
Object {
|
||||
"message": "Create Tag Reference",
|
||||
"secondaryMessage": "with ref \\"mock_createRef_ref\\"",
|
||||
},
|
||||
Object {
|
||||
"link": "https://mock_update_release_html_url",
|
||||
"message": "Promoted \\"mock_update_release_name\\"",
|
||||
"secondaryMessage": "from \\"rc-2020.01.01_1\\" to \\"mock_update_release_tag_name\\"",
|
||||
},
|
||||
Object {
|
||||
"icon": "success",
|
||||
"message": "Success callback successfully called 🚀",
|
||||
},
|
||||
],
|
||||
"run": [Function],
|
||||
"runInvoked": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
import { useAsync, useAsyncFn } from 'react-use';
|
||||
import { useApi } from '@backstage/core';
|
||||
|
||||
import { CardHook, ComponentConfigPromoteRc } from '../../../types/types';
|
||||
import { GetLatestReleaseResult } from '../../../api/GitReleaseClient';
|
||||
import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef';
|
||||
import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError';
|
||||
import { TAG_OBJECT_MESSAGE } from '../../../constants/constants';
|
||||
import { useProjectContext } from '../../../contexts/ProjectContext';
|
||||
import { useResponseSteps } from '../../../hooks/useResponseSteps';
|
||||
import { useUserContext } from '../../../contexts/UserContext';
|
||||
|
||||
interface PromoteRc {
|
||||
rcRelease: NonNullable<GetLatestReleaseResult['latestRelease']>;
|
||||
releaseVersion: string;
|
||||
onSuccess?: ComponentConfigPromoteRc['onSuccess'];
|
||||
}
|
||||
|
||||
export function usePromoteRc({
|
||||
rcRelease,
|
||||
releaseVersion,
|
||||
onSuccess,
|
||||
}: PromoteRc): CardHook<void> {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { user } = useUserContext();
|
||||
const { project } = useProjectContext();
|
||||
const {
|
||||
responseSteps,
|
||||
addStepToResponseSteps,
|
||||
asyncCatcher,
|
||||
abortIfError,
|
||||
} = useResponseSteps();
|
||||
|
||||
/**
|
||||
* (1) Fetch most recent release branch commit
|
||||
*/
|
||||
const [latestReleaseBranchCommitSha, run] = useAsyncFn(async () => {
|
||||
const { commit: latestCommit } = await pluginApiClient
|
||||
.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: rcRelease.targetCommitish,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Fetched most recent commit from release branch',
|
||||
secondaryMessage: `with sha "${latestCommit.sha}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...latestCommit,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* (2) Create tag object for our soon-to-be-created annotated tag
|
||||
*/
|
||||
const tagObjectRes = useAsync(async () => {
|
||||
abortIfError(latestReleaseBranchCommitSha.error);
|
||||
if (!latestReleaseBranchCommitSha.value) return undefined;
|
||||
|
||||
const { tagObject } = await pluginApiClient
|
||||
.createTagObject({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
tag: releaseVersion,
|
||||
object: latestReleaseBranchCommitSha.value.sha,
|
||||
taggerName: user.username,
|
||||
taggerEmail: user.email,
|
||||
message: TAG_OBJECT_MESSAGE,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Created Tag Object',
|
||||
secondaryMessage: `with sha "${tagObject.tagSha}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...tagObject,
|
||||
};
|
||||
}, [latestReleaseBranchCommitSha.value, latestReleaseBranchCommitSha.error]);
|
||||
|
||||
/**
|
||||
* (3) Create reference for tag object
|
||||
*/
|
||||
const createRcRes = useAsync(async () => {
|
||||
abortIfError(tagObjectRes.error);
|
||||
if (!tagObjectRes.value) return undefined;
|
||||
|
||||
const { reference: createdRef } = await pluginApiClient
|
||||
.createRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: `refs/tags/${releaseVersion}`,
|
||||
sha: tagObjectRes.value.tagSha,
|
||||
})
|
||||
.catch(error => {
|
||||
if (error?.body?.message === 'Reference already exists') {
|
||||
throw new GitReleaseManagerError(
|
||||
`Tag reference "${releaseVersion}" already exists`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Create Tag Reference',
|
||||
secondaryMessage: `with ref "${createdRef.ref}"`,
|
||||
});
|
||||
|
||||
return {
|
||||
...createdRef,
|
||||
};
|
||||
}, [tagObjectRes.value, tagObjectRes.error]);
|
||||
|
||||
/**
|
||||
* (4) Promote Release Candidate to Release Version
|
||||
*/
|
||||
const promotedReleaseRes = useAsync(async () => {
|
||||
abortIfError(createRcRes.error);
|
||||
if (!createRcRes.value) return undefined;
|
||||
|
||||
const { release } = await pluginApiClient
|
||||
.updateRelease({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
releaseId: rcRelease.id,
|
||||
tagName: releaseVersion,
|
||||
prerelease: false,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: `Promoted "${release.name}"`,
|
||||
secondaryMessage: `from "${rcRelease.tagName}" to "${release.tagName}"`,
|
||||
link: release.htmlUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
...release,
|
||||
};
|
||||
}, [createRcRes.value, createRcRes.error]);
|
||||
|
||||
/**
|
||||
* (5) Run onSuccess if defined
|
||||
*/
|
||||
useAsync(async () => {
|
||||
if (onSuccess && !!promotedReleaseRes.value) {
|
||||
abortIfError(promotedReleaseRes.error);
|
||||
|
||||
try {
|
||||
await onSuccess?.({
|
||||
gitReleaseUrl: promotedReleaseRes.value.htmlUrl,
|
||||
gitReleaseName: promotedReleaseRes.value.name,
|
||||
previousTagUrl: rcRelease.htmlUrl,
|
||||
previousTag: rcRelease.tagName,
|
||||
updatedTagUrl: promotedReleaseRes.value.htmlUrl,
|
||||
updatedTag: promotedReleaseRes.value.tagName,
|
||||
});
|
||||
} catch (error) {
|
||||
asyncCatcher(error);
|
||||
}
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: 'Success callback successfully called 🚀',
|
||||
icon: 'success',
|
||||
});
|
||||
}
|
||||
}, [promotedReleaseRes.value, promotedReleaseRes.error]);
|
||||
|
||||
const TOTAL_STEPS = 4 + (!!onSuccess ? 1 : 0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
useEffect(() => {
|
||||
setProgress((responseSteps.length / TOTAL_STEPS) * 100);
|
||||
}, [TOTAL_STEPS, responseSteps.length]);
|
||||
|
||||
return {
|
||||
progress,
|
||||
responseSteps,
|
||||
run,
|
||||
runInvoked: Boolean(
|
||||
promotedReleaseRes.loading ||
|
||||
promotedReleaseRes.value ||
|
||||
promotedReleaseRes.error,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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 { render, waitFor, screen } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockApiClient,
|
||||
mockCalverProject,
|
||||
mockSearchCalver,
|
||||
mockUser,
|
||||
} from '../../test-helpers/test-helpers';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
import { Owner } from './Owner';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
useNavigate: jest.fn(),
|
||||
useLocation: jest.fn(() => ({
|
||||
search: mockSearchCalver,
|
||||
})),
|
||||
}));
|
||||
jest.mock('@backstage/core', () => ({
|
||||
...jest.requireActual('@backstage/core'),
|
||||
useApi: () => mockApiClient,
|
||||
}));
|
||||
jest.mock('../../contexts/ProjectContext', () => ({
|
||||
useProjectContext: jest.fn(() => ({
|
||||
project: mockCalverProject,
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../contexts/UserContext', () => ({
|
||||
useUserContext: jest.fn(() => ({
|
||||
user: mockUser,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Owner', () => {
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
it('should render select', async () => {
|
||||
const { getByTestId } = render(<Owner />);
|
||||
|
||||
expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => screen.getByTestId(TEST_IDS.form.owner.select));
|
||||
expect(getByTestId(TEST_IDS.form.owner.select)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render select for empty owners', async () => {
|
||||
(useProjectContext as jest.Mock).mockReturnValue({
|
||||
project: { ...mockCalverProject, owner: '' },
|
||||
});
|
||||
|
||||
const { getAllByTestId, getByTestId } = render(<Owner />);
|
||||
|
||||
expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => screen.getAllByTestId(TEST_IDS.form.owner.empty));
|
||||
expect(getAllByTestId(TEST_IDS.form.owner.empty)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
<p
|
||||
class="MuiFormHelperText-root Mui-required"
|
||||
data-testid="grm--form--owner--empty"
|
||||
>
|
||||
Select an owner (org or user)
|
||||
</p>,
|
||||
<p
|
||||
class="MuiFormHelperText-root Mui-required"
|
||||
data-testid="grm--form--owner--empty"
|
||||
>
|
||||
Custom queries can be made via the query param
|
||||
|
||||
<strong>
|
||||
owner
|
||||
</strong>
|
||||
</p>,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
(mockApiClient.getOwners as jest.Mock).mockImplementationOnce(async () => {
|
||||
throw new Error('Kaboom');
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<Owner />);
|
||||
|
||||
expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument();
|
||||
await waitFor(() => screen.getByTestId(TEST_IDS.form.owner.error));
|
||||
expect(getByTestId(TEST_IDS.form.owner.error)).toMatchInlineSnapshot(`
|
||||
<p
|
||||
class="MuiFormHelperText-root Mui-error Mui-required"
|
||||
data-testid="grm--form--owner--error"
|
||||
>
|
||||
Encountered an error (
|
||||
Kaboom
|
||||
)
|
||||
</p>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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 { useNavigate } from 'react-router';
|
||||
import { useAsync } from 'react-use';
|
||||
import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Box,
|
||||
} from '@material-ui/core';
|
||||
import { Progress, useApi } from '@backstage/core';
|
||||
|
||||
import { gitReleaseManagerApiRef } from '../../api/serviceApiRef';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { useFormClasses } from './styles';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
import { useQueryHandler } from '../../hooks/useQueryHandler';
|
||||
import { useUserContext } from '../../contexts/UserContext';
|
||||
|
||||
export function Owner() {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { project } = useProjectContext();
|
||||
const { user } = useUserContext();
|
||||
const formClasses = useFormClasses();
|
||||
const navigate = useNavigate();
|
||||
const { getQueryParamsWithUpdates } = useQueryHandler();
|
||||
|
||||
const { loading, error, value } = useAsync(() => pluginApiClient.getOwners());
|
||||
const owners = value?.owners ?? [];
|
||||
const customOwnerFromUrl = !owners
|
||||
.concat(['', user.username])
|
||||
.includes(project.owner);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
className={formClasses.formControl}
|
||||
required
|
||||
disabled={project.isProvidedViaProps}
|
||||
error={!!error}
|
||||
>
|
||||
{loading ? (
|
||||
<Box data-testid={TEST_IDS.form.owner.loading}>
|
||||
<Progress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<InputLabel id="owner-select-label">Owners</InputLabel>
|
||||
<Select
|
||||
data-testid={TEST_IDS.form.owner.select}
|
||||
labelId="owner-select-label"
|
||||
id="owner-select"
|
||||
value={project.owner}
|
||||
defaultValue=""
|
||||
onChange={event => {
|
||||
const { queryParams } = getQueryParamsWithUpdates({
|
||||
updates: [
|
||||
{ key: 'repo', value: '' },
|
||||
{ key: 'owner', value: event.target.value as string },
|
||||
],
|
||||
});
|
||||
|
||||
navigate(`?${queryParams}`, { replace: true });
|
||||
}}
|
||||
className={formClasses.selectEmpty}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem value={user.username}>
|
||||
<strong>{user.username}</strong>
|
||||
</MenuItem>
|
||||
|
||||
{!error && customOwnerFromUrl && (
|
||||
<MenuItem value={project.owner}>
|
||||
<strong>From URL: {project.owner}</strong>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{owners.map((orgName, index) => (
|
||||
<MenuItem key={`organization-${index}`} value={orgName}>
|
||||
{orgName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{error && (
|
||||
<FormHelperText data-testid={TEST_IDS.form.owner.error}>
|
||||
Encountered an error ({error.message})
|
||||
</FormHelperText>
|
||||
)}
|
||||
|
||||
{!error && project.owner.length === 0 && (
|
||||
<>
|
||||
<FormHelperText data-testid={TEST_IDS.form.owner.empty}>
|
||||
Select an owner (org or user)
|
||||
</FormHelperText>
|
||||
<FormHelperText data-testid={TEST_IDS.form.owner.empty}>
|
||||
Custom queries can be made via the query param{' '}
|
||||
<strong>owner</strong>
|
||||
</FormHelperText>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 { render, waitFor, screen } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockApiClient,
|
||||
mockCalverProject,
|
||||
mockSearchCalver,
|
||||
} from '../../test-helpers/test-helpers';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
import { Repo } from './Repo';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
useNavigate: jest.fn(),
|
||||
useLocation: jest.fn(() => ({
|
||||
search: mockSearchCalver,
|
||||
})),
|
||||
}));
|
||||
jest.mock('@backstage/core', () => ({
|
||||
...jest.requireActual('@backstage/core'),
|
||||
useApi: () => mockApiClient,
|
||||
}));
|
||||
jest.mock('../../contexts/ProjectContext', () => ({
|
||||
useProjectContext: jest.fn(() => ({
|
||||
project: mockCalverProject,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Repo', () => {
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
it('should render select', async () => {
|
||||
const { getByTestId } = render(<Repo />);
|
||||
|
||||
expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => screen.getByTestId(TEST_IDS.form.repo.select));
|
||||
expect(getByTestId(TEST_IDS.form.repo.select)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render select for empty repo', async () => {
|
||||
(useProjectContext as jest.Mock).mockReturnValue({
|
||||
project: { ...mockCalverProject, repo: '' },
|
||||
});
|
||||
|
||||
const { getAllByTestId, getByTestId } = render(<Repo />);
|
||||
|
||||
expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => screen.getAllByTestId(TEST_IDS.form.repo.empty));
|
||||
expect(getAllByTestId(TEST_IDS.form.repo.empty)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
<p
|
||||
class="MuiFormHelperText-root Mui-required"
|
||||
data-testid="grm--form--repo--empty"
|
||||
>
|
||||
Select a repository
|
||||
</p>,
|
||||
<p
|
||||
class="MuiFormHelperText-root Mui-required"
|
||||
data-testid="grm--form--repo--empty"
|
||||
>
|
||||
Custom queries can be made via the query param
|
||||
|
||||
<strong>
|
||||
repo
|
||||
</strong>
|
||||
</p>,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
(mockApiClient.getRepositories as jest.Mock).mockImplementationOnce(
|
||||
async () => {
|
||||
throw new Error('Kaboom');
|
||||
},
|
||||
);
|
||||
|
||||
const { getByTestId } = render(<Repo />);
|
||||
|
||||
expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument();
|
||||
await waitFor(() => screen.getByTestId(TEST_IDS.form.repo.error));
|
||||
expect(getByTestId(TEST_IDS.form.repo.error)).toMatchInlineSnapshot(`
|
||||
<p
|
||||
class="MuiFormHelperText-root Mui-error Mui-required"
|
||||
data-testid="grm--form--repo--error"
|
||||
>
|
||||
Encountered an error (
|
||||
Kaboom
|
||||
")
|
||||
</p>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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 { useAsync } from 'react-use';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Box,
|
||||
} from '@material-ui/core';
|
||||
import { Progress, useApi } from '@backstage/core';
|
||||
|
||||
import { gitReleaseManagerApiRef } from '../../api/serviceApiRef';
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { useFormClasses } from './styles';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
import { useQueryHandler } from '../../hooks/useQueryHandler';
|
||||
|
||||
export function Repo() {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { project } = useProjectContext();
|
||||
const navigate = useNavigate();
|
||||
const formClasses = useFormClasses();
|
||||
const { getQueryParamsWithUpdates } = useQueryHandler();
|
||||
|
||||
const { loading, error, value } = useAsync(
|
||||
async () => pluginApiClient.getRepositories({ owner: project.owner }),
|
||||
[project.owner],
|
||||
);
|
||||
|
||||
if (project.owner.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repositories = value?.repositories ?? [];
|
||||
const customRepoFromUrl = !repositories.concat(['']).includes(project.repo);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
className={formClasses.formControl}
|
||||
required
|
||||
disabled={project.isProvidedViaProps}
|
||||
error={!!error}
|
||||
>
|
||||
{loading ? (
|
||||
<Box data-testid={TEST_IDS.form.repo.loading}>
|
||||
<Progress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<InputLabel id="repo-select-label">Repositories</InputLabel>
|
||||
<Select
|
||||
data-testid={TEST_IDS.form.repo.select}
|
||||
labelId="repo-select-label"
|
||||
id="repo-select"
|
||||
value={project.repo}
|
||||
defaultValue=""
|
||||
onChange={event => {
|
||||
const { queryParams } = getQueryParamsWithUpdates({
|
||||
updates: [{ key: 'repo', value: event.target.value as string }],
|
||||
});
|
||||
|
||||
navigate(`?${queryParams}`, { replace: true });
|
||||
}}
|
||||
className={formClasses.selectEmpty}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
|
||||
{!error && customRepoFromUrl && (
|
||||
<MenuItem value={project.repo}>
|
||||
<strong>From URL: {project.repo}</strong>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{repositories.map((repositoryName, index) => (
|
||||
<MenuItem key={`repository-${index}`} value={repositoryName}>
|
||||
{repositoryName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{error && (
|
||||
<FormHelperText data-testid={TEST_IDS.form.repo.error}>
|
||||
Encountered an error ({error.message}")
|
||||
</FormHelperText>
|
||||
)}
|
||||
|
||||
{!error && project.repo.length === 0 && (
|
||||
<>
|
||||
<FormHelperText data-testid={TEST_IDS.form.repo.empty}>
|
||||
Select a repository
|
||||
</FormHelperText>
|
||||
<FormHelperText data-testid={TEST_IDS.form.repo.empty}>
|
||||
Custom queries can be made via the query param{' '}
|
||||
<strong>repo</strong>
|
||||
</FormHelperText>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 { Owner } from './Owner';
|
||||
import { Repo } from './Repo';
|
||||
import { VersioningStrategy } from './VersioningStrategy';
|
||||
|
||||
export function RepoDetailsForm() {
|
||||
return (
|
||||
<>
|
||||
<VersioningStrategy />
|
||||
|
||||
<Owner />
|
||||
|
||||
<Repo />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
mockSemverProject,
|
||||
mockSearchCalver,
|
||||
} from '../../test-helpers/test-helpers';
|
||||
import { VersioningStrategy } from './VersioningStrategy';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
useLocation: jest.fn(() => ({
|
||||
search: mockSearchCalver,
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../contexts/ProjectContext', () => ({
|
||||
useProjectContext: () => ({
|
||||
project: mockSemverProject,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Repo', () => {
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
it('should render radio group with default values and handle changes', async () => {
|
||||
const { getByLabelText } = render(<VersioningStrategy />);
|
||||
|
||||
const radio1 = getByLabelText('Semantic versioning');
|
||||
const radio2 = getByLabelText('Calendar versioning');
|
||||
|
||||
expect(radio1).toBeChecked();
|
||||
expect(radio2).not.toBeChecked();
|
||||
|
||||
fireEvent.click(radio2);
|
||||
expect(mockNavigate.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"?versioningStrategy=calver&owner=mock_owner&repo=mock_repo",
|
||||
Object {
|
||||
"replace": true,
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormLabel,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from '@material-ui/core';
|
||||
|
||||
import { TEST_IDS } from '../../test-helpers/test-ids';
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
import { useQueryHandler } from '../../hooks/useQueryHandler';
|
||||
import { VERSIONING_STRATEGIES } from '../../constants/constants';
|
||||
|
||||
export function VersioningStrategy() {
|
||||
const navigate = useNavigate();
|
||||
const { project } = useProjectContext();
|
||||
const { getParsedQuery, getQueryParamsWithUpdates } = useQueryHandler();
|
||||
|
||||
useEffect(() => {
|
||||
const { parsedQuery } = getParsedQuery();
|
||||
|
||||
if (!parsedQuery.versioningStrategy && !project.isProvidedViaProps) {
|
||||
const { queryParams } = getQueryParamsWithUpdates({
|
||||
updates: [
|
||||
{ key: 'versioningStrategy', value: project.versioningStrategy },
|
||||
],
|
||||
});
|
||||
|
||||
navigate(`?${queryParams}`, { replace: true });
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
required
|
||||
disabled={project.isProvidedViaProps}
|
||||
>
|
||||
<FormLabel component="legend">Versioning strategy</FormLabel>
|
||||
|
||||
<RadioGroup
|
||||
data-testid={TEST_IDS.form.versioningStrategy.radioGroup}
|
||||
aria-label="calendar-strategy"
|
||||
name="calendar-strategy"
|
||||
value={project.versioningStrategy}
|
||||
onChange={event => {
|
||||
const { queryParams } = getQueryParamsWithUpdates({
|
||||
updates: [{ key: 'versioningStrategy', value: event.target.value }],
|
||||
});
|
||||
|
||||
navigate(`?${queryParams}`, { replace: true });
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
value={VERSIONING_STRATEGIES.semver}
|
||||
control={<Radio />}
|
||||
label="Semantic versioning"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
value={VERSIONING_STRATEGIES.calver}
|
||||
control={<Radio />}
|
||||
label="Calendar versioning"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 { createStyles, makeStyles, Theme } from '@material-ui/core';
|
||||
|
||||
export const useFormClasses = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
formControl: {
|
||||
margin: theme.spacing(1),
|
||||
minWidth: 120,
|
||||
},
|
||||
selectEmpty: {
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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 React from 'react';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import {
|
||||
makeStyles,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from '@material-ui/core';
|
||||
import { Progress } from '@backstage/core';
|
||||
|
||||
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 { 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 <Progress />;
|
||||
}
|
||||
|
||||
if (!stats.value) {
|
||||
return <Alert severity="error">Couldn't find any stats :(</Alert>;
|
||||
}
|
||||
|
||||
const { allReleases, allTags } = stats.value;
|
||||
const { mappedReleases } = getMappedReleases({ allReleases, project });
|
||||
const { releaseStats } = getReleaseStats({
|
||||
mappedReleases,
|
||||
allTags,
|
||||
project,
|
||||
});
|
||||
|
||||
return (
|
||||
<ReleaseStatsContext.Provider value={{ releaseStats }}>
|
||||
<Info />
|
||||
|
||||
<TableContainer>
|
||||
<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(releaseStats.releases).map(
|
||||
([baseVersion, releaseStat], index) => {
|
||||
return (
|
||||
<Row
|
||||
key={`row-${index}`}
|
||||
baseVersion={baseVersion}
|
||||
releaseStat={releaseStat}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{(releaseStats.unmappableTags.length > 0 ||
|
||||
releaseStats.unmatchedTags.length > 0 ||
|
||||
releaseStats.unmatchedReleases.length > 0) && <Warn />}
|
||||
</TableContainer>
|
||||
</ReleaseStatsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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,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
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Box margin={1}>
|
||||
<Typography variant="h4">In-depth</Typography>
|
||||
</Box>
|
||||
|
||||
<Box style={{ display: 'flex' }}>
|
||||
<Box margin={1} style={{ display: 'flex', flex: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">Release time</Typography>
|
||||
|
||||
<Typography variant="body2">
|
||||
<strong>Release time</strong> is derived by comparing{' '}
|
||||
<i>createdAt</i> 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.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
margin={1}
|
||||
style={{ display: 'flex', flex: 1, flexDirection: 'column' }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6">In numbers</Typography>
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Average release time</strong>:{' '}
|
||||
<AverageReleaseTime averageReleaseTime={averageReleaseTime} />
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Longest release</strong>:{' '}
|
||||
<LongestReleaseTime averageReleaseTime={averageReleaseTime} />
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
{progress === 0 && (
|
||||
<MaterialTooltip
|
||||
title={`This action will send ~${
|
||||
releaseCommitPairs.length * 2
|
||||
} requests`}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => run()}
|
||||
size="small"
|
||||
>
|
||||
Crunch the numbers
|
||||
</Button>
|
||||
</MaterialTooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={4}>
|
||||
<BarChart
|
||||
width={700}
|
||||
height={70 + averageReleaseTime.length * 22}
|
||||
data={
|
||||
averageReleaseTime.length > 0
|
||||
? averageReleaseTime
|
||||
: [{ version: 'x.y.z', days: 0 }]
|
||||
}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<XAxis type="number" />
|
||||
<YAxis dataKey="version" type="category" />
|
||||
<Tooltip labelStyle={{ color: '#000', fontWeight: 'bold' }} />
|
||||
<Legend />
|
||||
<Bar dataKey="days" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
|
||||
{progress > 0 && progress < 100 && (
|
||||
<Box marginTop={1}>
|
||||
<LinearProgressWithLabel progress={progress} responseSteps={[]} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 )
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
style={{
|
||||
padding: 20,
|
||||
marginLeft: 25,
|
||||
marginRight: 25,
|
||||
marginBottom: 25,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Summary />
|
||||
|
||||
<InDepth />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Box margin={1}>
|
||||
<Typography variant="h4">Summary</Typography>
|
||||
|
||||
<Typography variant="body2">
|
||||
<strong>Total releases</strong>: {summary.totalReleases}
|
||||
</Typography>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small" className={classes.table}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>Patches</TableCell>
|
||||
<TableCell>Patches per release</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell component="th" scope="row">
|
||||
Release Candidate
|
||||
</TableCell>
|
||||
<TableCell>{summary.totalCandidatePatches}</TableCell>
|
||||
<TableCell>
|
||||
{getDecimalNumber(
|
||||
summary.totalCandidatePatches / summary.totalReleases,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell component="th" scope="row">
|
||||
Release Version
|
||||
</TableCell>
|
||||
<TableCell>{summary.totalVersionPatches}</TableCell>
|
||||
<TableCell>
|
||||
{getDecimalNumber(
|
||||
summary.totalVersionPatches / summary.totalReleases,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell component="th" scope="row">
|
||||
Total
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{summary.totalCandidatePatches + summary.totalVersionPatches}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getDecimalNumber(
|
||||
(summary.totalCandidatePatches +
|
||||
summary.totalVersionPatches) /
|
||||
summary.totalReleases,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+131
@@ -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 { 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',
|
||||
tagSha: 'sha-1.0.0',
|
||||
tagType: 'tag' as const,
|
||||
},
|
||||
{
|
||||
tagName: 'rc-1.0.1',
|
||||
tagSha: 'sha-1.0.1',
|
||||
tagType: 'tag' as const,
|
||||
},
|
||||
],
|
||||
versions: [],
|
||||
};
|
||||
|
||||
const releaseWithoutPatches = {
|
||||
baseVersion: '2.0',
|
||||
createdAt: '2021-01-01T10:11:12Z',
|
||||
htmlUrl: 'html_url',
|
||||
candidates: [
|
||||
{
|
||||
tagName: 'rc-2.0.0',
|
||||
tagSha: 'sha-2.0.0',
|
||||
tagType: 'tag' as const,
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
tagName: 'version-2.0.0',
|
||||
tagSha: 'sha-2.0.0',
|
||||
tagType: 'tag' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const releaseWithPatches = {
|
||||
baseVersion: '3.0',
|
||||
createdAt: '2021-01-01T10:11:12Z',
|
||||
htmlUrl: 'html_url',
|
||||
candidates: [
|
||||
{
|
||||
tagName: 'rc-3.0.1',
|
||||
tagSha: 'sha-3.0.1',
|
||||
tagType: 'tag' as const,
|
||||
},
|
||||
{
|
||||
tagName: 'rc-3.0.0',
|
||||
tagSha: 'sha-3.0.0',
|
||||
tagType: 'tag' as const,
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
tagName: 'version-3.0.1',
|
||||
tagSha: 'sha-3.0.1',
|
||||
tagType: 'tag' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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": "2.0",
|
||||
"endCommit": Object {
|
||||
"tagName": "version-2.0.0",
|
||||
"tagSha": "sha-2.0.0",
|
||||
"tagType": "tag",
|
||||
},
|
||||
"startCommit": Object {
|
||||
"tagName": "rc-2.0.0",
|
||||
"tagSha": "sha-2.0.0",
|
||||
"tagType": "tag",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"baseVersion": "3.0",
|
||||
"endCommit": Object {
|
||||
"tagName": "version-3.0.1",
|
||||
"tagSha": "sha-3.0.1",
|
||||
"tagType": "tag",
|
||||
},
|
||||
"startCommit": Object {
|
||||
"tagName": "rc-3.0.0",
|
||||
"tagSha": "sha-3.0.0",
|
||||
"tagType": "tag",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Missing Release Version (likely prerelease)
|
||||
if (!endTag) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return acc.concat({
|
||||
baseVersion: release.baseVersion,
|
||||
startCommit: { ...startTag },
|
||||
endCommit: { ...endTag },
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
releaseCommitPairs,
|
||||
};
|
||||
}
|
||||
@@ -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 { useApi } from '@backstage/core';
|
||||
|
||||
import { getReleaseCommitPairs } from '../helpers/getReleaseCommitPairs';
|
||||
import { gitReleaseManagerApiRef } from '../../../../api/serviceApiRef';
|
||||
import { useProjectContext } from '../../../../contexts/ProjectContext';
|
||||
import { useReleaseStatsContext } from '../../contexts/ReleaseStatsContext';
|
||||
import { getTagDates } from '../../helpers/getTagDates';
|
||||
|
||||
export type ReleaseCommitPairs = Array<{
|
||||
baseVersion: string;
|
||||
startCommit: {
|
||||
tagName: string;
|
||||
tagSha: string;
|
||||
tagType: 'tag' | 'commit';
|
||||
};
|
||||
endCommit: {
|
||||
tagName: string;
|
||||
tagSha: string;
|
||||
tagType: 'tag' | 'commit';
|
||||
};
|
||||
}>;
|
||||
|
||||
type ReleaseTime = {
|
||||
version: string;
|
||||
daysWithHours: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
startCommitCreatedAt?: string;
|
||||
endCommitCreatedAt?: string;
|
||||
};
|
||||
|
||||
export function useGetReleaseTimes() {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { project } = useProjectContext();
|
||||
const { releaseStats } = useReleaseStatsContext();
|
||||
const [averageReleaseTime, setAverageReleaseTime] = useState<ReleaseTime[]>(
|
||||
[],
|
||||
);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const { releaseCommitPairs } = getReleaseCommitPairs({ releaseStats });
|
||||
|
||||
const [releaseTimeResult, run] = useAsyncFn(() => {
|
||||
setProgress(0);
|
||||
return getAndSetReleaseTime({ pairIndex: 0 });
|
||||
});
|
||||
|
||||
useAsync(async () => {
|
||||
if (averageReleaseTime.length === 0) return;
|
||||
if (releaseCommitPairs.length === averageReleaseTime.length) return;
|
||||
|
||||
await getAndSetReleaseTime({ pairIndex: averageReleaseTime.length });
|
||||
}, [releaseTimeResult.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({ pairIndex }: { pairIndex: number }) {
|
||||
const { baseVersion, startCommit, endCommit } = releaseCommitPairs[
|
||||
pairIndex
|
||||
];
|
||||
|
||||
const {
|
||||
startDate: startCommitCreatedAt,
|
||||
endDate: endCommitCreatedAt,
|
||||
} = await getTagDates({
|
||||
pluginApiClient,
|
||||
project,
|
||||
startTag: startCommit,
|
||||
endTag: endCommit,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {
|
||||
Collapse,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from '@material-ui/core';
|
||||
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
|
||||
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
|
||||
import { Link } from '@backstage/core';
|
||||
|
||||
import { ReleaseStats } from '../contexts/ReleaseStatsContext';
|
||||
import { RowCollapsed } from './RowCollapsed/RowCollapsed';
|
||||
|
||||
const useRowStyles = makeStyles({
|
||||
root: {
|
||||
'& > *': {
|
||||
borderBottom: 'unset',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface RowProps {
|
||||
baseVersion: string;
|
||||
releaseStat: ReleaseStats['releases']['0'];
|
||||
}
|
||||
|
||||
export function Row({ baseVersion, releaseStat }: RowProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const classes = useRowStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow className={classes.root}>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
aria-label="expand row"
|
||||
size="small"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{open ? <KeyboardArrowDownIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
|
||||
<TableCell component="th" scope="row">
|
||||
<Link to={releaseStat.htmlUrl} target="_blank">
|
||||
{baseVersion}
|
||||
{releaseStat.versions.length === 0 ? ' (prerelease)' : ''}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{releaseStat.createdAt
|
||||
? DateTime.fromISO(releaseStat.createdAt).toFormat('yyyy-MM-dd')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{releaseStat.candidates.length}</TableCell>
|
||||
|
||||
<TableCell>{Math.max(0, releaseStat.versions.length - 1)}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<RowCollapsed releaseStat={releaseStat} />
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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, Typography } from '@material-ui/core';
|
||||
|
||||
import { ReleaseStats } from '../../contexts/ReleaseStatsContext';
|
||||
|
||||
export function ReleaseTagList({
|
||||
releaseStat,
|
||||
}: {
|
||||
releaseStat: ReleaseStats['releases']['0'];
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{releaseStat.versions.length > 0 && (
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{releaseStat.versions.map(version => (
|
||||
<Typography variant="body1" key={version.tagName}>
|
||||
{version.tagName}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{releaseStat.versions.length > 0 && (
|
||||
<Box
|
||||
margin={1}
|
||||
style={{
|
||||
position: 'relative',
|
||||
transform: 'rotate(-45deg)',
|
||||
fontSize: 30,
|
||||
}}
|
||||
>
|
||||
{' 🚀 '}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{releaseStat.candidates.map(candidate => (
|
||||
<Typography variant="body1" key={candidate.tagName}>
|
||||
{candidate.tagName}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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 { useAsync } from 'react-use';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { Progress, useApi } from '@backstage/core';
|
||||
|
||||
import { getDecimalNumber } from '../../helpers/getDecimalNumber';
|
||||
import { getTagDates } from '../../helpers/getTagDates';
|
||||
import { gitReleaseManagerApiRef } from '../../../../api/serviceApiRef';
|
||||
import { ReleaseStats } from '../../contexts/ReleaseStatsContext';
|
||||
import { useProjectContext } from '../../../../contexts/ProjectContext';
|
||||
|
||||
interface ReleaseTimeProps {
|
||||
releaseStat: ReleaseStats['releases']['0'];
|
||||
}
|
||||
|
||||
export function ReleaseTime({ releaseStat }: ReleaseTimeProps) {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { project } = useProjectContext();
|
||||
|
||||
const releaseTimes = useAsync(() =>
|
||||
getTagDates({
|
||||
pluginApiClient,
|
||||
project,
|
||||
startTag: [...releaseStat.candidates].reverse()[0],
|
||||
endTag: releaseStat.versions[0],
|
||||
}),
|
||||
);
|
||||
|
||||
if (releaseTimes.loading || releaseTimes.loading) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Progress />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (releaseTimes.error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Failed to fetch the first Release Candidate commit (
|
||||
{releaseTimes.error.message})
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { days = 0, hours = 0 } =
|
||||
releaseTimes.value?.startDate && releaseTimes.value?.endDate
|
||||
? DateTime.fromISO(releaseTimes.value.endDate)
|
||||
.diff(DateTime.fromISO(releaseTimes.value.startDate), [
|
||||
'days',
|
||||
'hours',
|
||||
])
|
||||
.toObject()
|
||||
: { days: -1 };
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">
|
||||
{releaseStat.versions.length === 0 ? '-' : 'Release completed '}
|
||||
{releaseTimes.value?.endDate &&
|
||||
DateTime.fromISO(releaseTimes.value.endDate).toFormat('yyyy-MM-dd')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="secondary">
|
||||
{days === -1 ? (
|
||||
<>Ongoing</>
|
||||
) : (
|
||||
<>
|
||||
Completed in: {days} days {getDecimalNumber(hours, 1)} hours
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">
|
||||
Release Candidate created{' '}
|
||||
{releaseTimes.value?.startDate &&
|
||||
DateTime.fromISO(releaseTimes.value.startDate).toFormat(
|
||||
'yyyy-MM-dd',
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 { Box } from '@material-ui/core';
|
||||
|
||||
import { ReleaseStats } from '../../contexts/ReleaseStatsContext';
|
||||
import { ReleaseTagList } from './ReleaseTagList';
|
||||
import { ReleaseTime } from './ReleaseTime';
|
||||
|
||||
interface RowCollapsedProps {
|
||||
releaseStat: ReleaseStats['releases']['0'];
|
||||
}
|
||||
|
||||
export function RowCollapsed({ releaseStat }: RowCollapsedProps) {
|
||||
return (
|
||||
<Box
|
||||
margin={1}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
paddingLeft: '10%',
|
||||
paddingRight: '10%',
|
||||
}}
|
||||
>
|
||||
<ReleaseTagList releaseStat={releaseStat} />
|
||||
|
||||
<ReleaseTime releaseStat={releaseStat} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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,73 @@
|
||||
/*
|
||||
* 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, Button } from '@material-ui/core';
|
||||
|
||||
import { useProjectContext } from '../../contexts/ProjectContext';
|
||||
import { useReleaseStatsContext } from './contexts/ReleaseStatsContext';
|
||||
|
||||
export const Warn = () => {
|
||||
const { releaseStats } = useReleaseStatsContext();
|
||||
const { project } = useProjectContext();
|
||||
|
||||
return (
|
||||
<Box marginTop={2}>
|
||||
<Alert severity="warning" style={{ marginBottom: 10 }}>
|
||||
{releaseStats.unmappableTags.length > 0 && (
|
||||
<div>
|
||||
Failed to map <strong>{releaseStats.unmappableTags.length}</strong>{' '}
|
||||
tags to releases
|
||||
</div>
|
||||
)}
|
||||
|
||||
{releaseStats.unmatchedTags.length > 0 && (
|
||||
<div>
|
||||
Failed to match <strong>{releaseStats.unmatchedTags.length}</strong>{' '}
|
||||
tags to {project.versioningStrategy}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{releaseStats.unmatchedReleases.length > 0 && (
|
||||
<div>
|
||||
Failed to match{' '}
|
||||
<strong>{releaseStats.unmatchedReleases.length}</strong> releases to{' '}
|
||||
{project.versioningStrategy}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Here's all unmapped/unmatched tags/releases", {
|
||||
unmatchedReleases: releaseStats.unmatchedReleases,
|
||||
unmatchedTags: releaseStats.unmatchedTags,
|
||||
unmappableTags: releaseStats.unmappableTags,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Log all unmapped/unmatched tags/releases to the console
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError';
|
||||
|
||||
export interface ReleaseStats {
|
||||
unmappableTags: string[];
|
||||
unmatchedTags: string[];
|
||||
unmatchedReleases: string[];
|
||||
releases: {
|
||||
[baseVersion: string]: {
|
||||
baseVersion: string;
|
||||
createdAt: string | null;
|
||||
htmlUrl: string;
|
||||
candidates: {
|
||||
tagName: string;
|
||||
tagSha: string;
|
||||
tagType: 'tag' | 'commit';
|
||||
}[];
|
||||
versions: {
|
||||
tagName: string;
|
||||
tagSha: string;
|
||||
tagType: 'tag' | 'commit';
|
||||
}[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const ReleaseStatsContext = createContext<
|
||||
{ releaseStats: ReleaseStats } | undefined
|
||||
>(undefined);
|
||||
|
||||
export const useReleaseStatsContext = () => {
|
||||
const { releaseStats } = useContext(ReleaseStatsContext) ?? {};
|
||||
|
||||
if (!releaseStats) {
|
||||
throw new GitReleaseManagerError('releaseStats not found');
|
||||
}
|
||||
|
||||
return {
|
||||
releaseStats,
|
||||
};
|
||||
};
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export function getDecimalNumber(n: number, decimals = 2) {
|
||||
if (isNaN(n)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (n.toString().includes('.')) {
|
||||
return parseFloat(n.toFixed(decimals));
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 [],
|
||||
"createdAt": "2021-01-01T10:11:12Z",
|
||||
"htmlUrl": "html_url",
|
||||
"versions": Array [],
|
||||
},
|
||||
"1.1": Object {
|
||||
"baseVersion": "1.1",
|
||||
"candidates": Array [],
|
||||
"createdAt": "2021-01-01T10:11:12Z",
|
||||
"htmlUrl": "html_url",
|
||||
"versions": Array [],
|
||||
},
|
||||
},
|
||||
"unmappableTags": Array [],
|
||||
"unmatchedReleases": Array [],
|
||||
"unmatchedTags": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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 { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts';
|
||||
import { GetAllReleasesResult } from '../../../api/GitReleaseClient';
|
||||
import { Project } from '../../../contexts/ProjectContext';
|
||||
import { ReleaseStats } from '../contexts/ReleaseStatsContext';
|
||||
import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts';
|
||||
|
||||
export function getMappedReleases({
|
||||
allReleases,
|
||||
project,
|
||||
}: {
|
||||
allReleases: GetAllReleasesResult['releases'];
|
||||
project: Project;
|
||||
}) {
|
||||
return {
|
||||
mappedReleases: allReleases.reduce(
|
||||
(acc: ReleaseStats, release) => {
|
||||
const match =
|
||||
project.versioningStrategy === 'semver'
|
||||
? release.tagName.match(semverRegexp)
|
||||
: release.tagName.match(calverRegexp);
|
||||
|
||||
if (!match) {
|
||||
acc.unmatchedReleases.push(release.tagName);
|
||||
return acc;
|
||||
}
|
||||
|
||||
const baseVersion =
|
||||
project.versioningStrategy === 'semver'
|
||||
? `${match[2]}.${match[3]}`
|
||||
: match[2];
|
||||
|
||||
if (!acc.releases[baseVersion]) {
|
||||
acc.releases[baseVersion] = {
|
||||
baseVersion,
|
||||
createdAt: release.createdAt,
|
||||
htmlUrl: release.htmlUrl,
|
||||
candidates: [],
|
||||
versions: [],
|
||||
};
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
releases: {},
|
||||
unmappableTags: [],
|
||||
unmatchedReleases: [],
|
||||
unmatchedTags: [],
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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: [],
|
||||
versions: [],
|
||||
},
|
||||
'1.1': {
|
||||
baseVersion: '1.1',
|
||||
createdAt: '2021-01-01T10:11:12Z',
|
||||
htmlUrl: 'html_url',
|
||||
candidates: [],
|
||||
|
||||
versions: [],
|
||||
},
|
||||
},
|
||||
unmappableTags: [],
|
||||
unmatchedReleases: [],
|
||||
unmatchedTags: [],
|
||||
},
|
||||
allTags: [
|
||||
{ tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.0' },
|
||||
{ tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.1' },
|
||||
{ tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.2' },
|
||||
{ tagType: 'tag' as const, tagSha: 'sha', tagName: 'version-1.0.2' },
|
||||
{ tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.1.1' },
|
||||
|
||||
{ tagType: 'tag' as const, tagSha: 'unmatchable', tagName: 'rc-1/2/3' },
|
||||
{
|
||||
tagType: 'tag' as const,
|
||||
tagSha: 'unmappable',
|
||||
tagName: 'rc-123.123.123',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"releaseStats": Object {
|
||||
"releases": Object {
|
||||
"1.0": Object {
|
||||
"baseVersion": "1.0",
|
||||
"candidates": Array [
|
||||
Object {
|
||||
"tagName": "rc-1.0.0",
|
||||
"tagSha": "sha",
|
||||
"tagType": "tag",
|
||||
},
|
||||
Object {
|
||||
"tagName": "rc-1.0.1",
|
||||
"tagSha": "sha",
|
||||
"tagType": "tag",
|
||||
},
|
||||
Object {
|
||||
"tagName": "rc-1.0.2",
|
||||
"tagSha": "sha",
|
||||
"tagType": "tag",
|
||||
},
|
||||
],
|
||||
"createdAt": "2021-01-01T10:11:12Z",
|
||||
"htmlUrl": "html_url",
|
||||
"versions": Array [
|
||||
Object {
|
||||
"tagName": "version-1.0.2",
|
||||
"tagSha": "sha",
|
||||
"tagType": "tag",
|
||||
},
|
||||
],
|
||||
},
|
||||
"1.1": Object {
|
||||
"baseVersion": "1.1",
|
||||
"candidates": Array [
|
||||
Object {
|
||||
"tagName": "rc-1.1.1",
|
||||
"tagSha": "sha",
|
||||
"tagType": "tag",
|
||||
},
|
||||
],
|
||||
"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",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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 { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts';
|
||||
import { GetAllTagsResult } from '../../../api/GitReleaseClient';
|
||||
import { Project } from '../../../contexts/ProjectContext';
|
||||
import { ReleaseStats } from '../contexts/ReleaseStatsContext';
|
||||
import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts';
|
||||
|
||||
export function getReleaseStats({
|
||||
allTags,
|
||||
project,
|
||||
mappedReleases,
|
||||
}: {
|
||||
allTags: GetAllTagsResult['tags'];
|
||||
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'];
|
||||
dest.push(tag);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
...mappedReleases,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
releaseStats,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 '../contexts/ReleaseStatsContext';
|
||||
|
||||
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;
|
||||
},
|
||||
{
|
||||
totalReleases: 0,
|
||||
totalCandidatePatches: 0,
|
||||
totalVersionPatches: 0,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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 {
|
||||
createMockCommit,
|
||||
createMockTag,
|
||||
mockApiClient,
|
||||
mockSemverProject,
|
||||
} from '../../../test-helpers/test-helpers';
|
||||
import { getTagDates } from './getTagDates';
|
||||
|
||||
describe('getTagDates', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should get tag dates for startTag when is tag and endTag is undefined', async () => {
|
||||
(mockApiClient.getTag as jest.Mock).mockResolvedValueOnce(
|
||||
createMockTag({ date: 'TAG-START' }),
|
||||
);
|
||||
|
||||
const result = await getTagDates({
|
||||
pluginApiClient: mockApiClient,
|
||||
project: mockSemverProject,
|
||||
startTag: {
|
||||
tagSha: 'sha-start',
|
||||
tagType: 'tag',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"endDate": undefined,
|
||||
"startDate": "TAG-START",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should get tag dates for startTag when startTag is commit and endTag is undefined', async () => {
|
||||
(mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce(
|
||||
createMockCommit({ createdAt: 'COMMIT_START' }),
|
||||
);
|
||||
|
||||
const result = await getTagDates({
|
||||
pluginApiClient: mockApiClient,
|
||||
project: mockSemverProject,
|
||||
startTag: {
|
||||
tagSha: 'sha-start',
|
||||
tagType: 'commit',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"endDate": undefined,
|
||||
"startDate": "COMMIT_START",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should get tag dates when startTag & endTag both are of type tag', async () => {
|
||||
(mockApiClient.getTag as jest.Mock)
|
||||
.mockResolvedValueOnce(createMockTag({ date: 'TAG-START' }))
|
||||
.mockResolvedValueOnce(createMockTag({ date: 'TAG-END' }));
|
||||
|
||||
const result = await getTagDates({
|
||||
pluginApiClient: mockApiClient,
|
||||
project: mockSemverProject,
|
||||
startTag: {
|
||||
tagSha: 'sha-start',
|
||||
tagType: 'tag',
|
||||
},
|
||||
endTag: {
|
||||
tagSha: 'sha-end',
|
||||
tagType: 'tag',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"endDate": "TAG-END",
|
||||
"startDate": "TAG-START",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should get commit createdAt when startTag & endTag both are of type commit', async () => {
|
||||
(mockApiClient.getCommit as jest.Mock)
|
||||
.mockResolvedValueOnce(createMockCommit({ createdAt: 'COMMIT_START' }))
|
||||
.mockResolvedValueOnce(createMockCommit({ createdAt: 'COMMIT_END' }));
|
||||
|
||||
const result = await getTagDates({
|
||||
pluginApiClient: mockApiClient,
|
||||
project: mockSemverProject,
|
||||
startTag: {
|
||||
tagSha: 'sha-start',
|
||||
tagType: 'commit',
|
||||
},
|
||||
endTag: {
|
||||
tagSha: 'sha-end',
|
||||
tagType: 'commit',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"endDate": "COMMIT_END",
|
||||
"startDate": "COMMIT_START",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should get commit createdAt when startTag is of type tag but endTag is of type commit', async () => {
|
||||
(mockApiClient.getTag as jest.Mock).mockResolvedValueOnce(
|
||||
createMockTag({ objectSha: 'OBJECT_SHA_START' }),
|
||||
);
|
||||
(mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce(
|
||||
createMockCommit({ createdAt: 'COMMIT_START' }),
|
||||
);
|
||||
|
||||
(mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce(
|
||||
createMockCommit({ createdAt: 'COMMIT_END' }),
|
||||
);
|
||||
|
||||
const result = await getTagDates({
|
||||
pluginApiClient: mockApiClient,
|
||||
project: mockSemverProject,
|
||||
startTag: {
|
||||
tagSha: 'sha-start',
|
||||
tagType: 'tag',
|
||||
},
|
||||
endTag: {
|
||||
tagSha: 'sha-end',
|
||||
tagType: 'commit',
|
||||
},
|
||||
});
|
||||
|
||||
const { owner, repo } = mockSemverProject;
|
||||
expect(mockApiClient.getTag).toHaveBeenCalledWith({
|
||||
owner,
|
||||
repo,
|
||||
tagSha: 'sha-start',
|
||||
});
|
||||
expect((mockApiClient.getCommit as jest.Mock).mock.calls).toEqual([
|
||||
[{ owner, ref: 'sha-end', repo }],
|
||||
[{ owner, ref: 'OBJECT_SHA_START', repo }],
|
||||
]);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"endDate": "COMMIT_START",
|
||||
"startDate": "COMMIT_END",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should get commit createdAt when endTag is of type tag but startTag is of type commit', async () => {
|
||||
(mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce(
|
||||
createMockCommit({ createdAt: 'COMMIT_START' }),
|
||||
);
|
||||
|
||||
(mockApiClient.getTag as jest.Mock).mockResolvedValueOnce(
|
||||
createMockTag({ objectSha: 'OBJECT_SHA_END' }),
|
||||
);
|
||||
(mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce(
|
||||
createMockCommit({ createdAt: 'COMMIT_END' }),
|
||||
);
|
||||
|
||||
const result = await getTagDates({
|
||||
pluginApiClient: mockApiClient,
|
||||
project: mockSemverProject,
|
||||
startTag: {
|
||||
tagSha: 'sha-start',
|
||||
tagType: 'commit',
|
||||
},
|
||||
endTag: {
|
||||
tagSha: 'sha-end',
|
||||
tagType: 'tag',
|
||||
},
|
||||
});
|
||||
|
||||
const { owner, repo } = mockSemverProject;
|
||||
expect(mockApiClient.getTag).toHaveBeenCalledWith({
|
||||
owner,
|
||||
repo,
|
||||
tagSha: 'sha-end',
|
||||
});
|
||||
expect((mockApiClient.getCommit as jest.Mock).mock.calls).toEqual([
|
||||
[{ owner, ref: 'sha-start', repo }],
|
||||
[{ owner, ref: 'OBJECT_SHA_END', repo }],
|
||||
]);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"endDate": "COMMIT_END",
|
||||
"startDate": "COMMIT_START",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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 { GitReleaseApi } from '../../../api/GitReleaseClient';
|
||||
import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError';
|
||||
import { Project } from '../../../contexts/ProjectContext';
|
||||
|
||||
interface GetTagDates {
|
||||
pluginApiClient: GitReleaseApi;
|
||||
project: Project;
|
||||
startTag: {
|
||||
tagSha: string;
|
||||
tagType: 'tag' | 'commit';
|
||||
};
|
||||
endTag?: {
|
||||
tagSha: string;
|
||||
tagType: 'tag' | 'commit';
|
||||
};
|
||||
}
|
||||
|
||||
export const getTagDates = async ({
|
||||
pluginApiClient,
|
||||
project,
|
||||
startTag,
|
||||
endTag,
|
||||
}: GetTagDates) => {
|
||||
if (!endTag) {
|
||||
if (startTag.tagType === 'tag') {
|
||||
const { tag: startTagResponse } = await pluginApiClient.getTag({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
tagSha: startTag.tagSha,
|
||||
});
|
||||
|
||||
return {
|
||||
startDate: startTagResponse.date,
|
||||
endDate: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// If tagType is not a 'tag', it has to be a commit
|
||||
const { commit: startCommit } = await pluginApiClient.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: startTag.tagSha,
|
||||
});
|
||||
|
||||
return {
|
||||
startDate: startCommit.createdAt,
|
||||
endDate: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (startTag.tagType === 'tag' && endTag.tagType === 'tag') {
|
||||
const [
|
||||
{ tag: startTagResponse },
|
||||
{ tag: endTagResponse },
|
||||
] = await Promise.all([
|
||||
pluginApiClient.getTag({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
tagSha: startTag.tagSha,
|
||||
}),
|
||||
pluginApiClient.getTag({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
tagSha: endTag.tagSha,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
startDate: startTagResponse.date,
|
||||
endDate: endTagResponse.date,
|
||||
};
|
||||
}
|
||||
|
||||
if (startTag.tagType === 'commit' && endTag.tagType === 'commit') {
|
||||
const [{ commit: startCommit }, { commit: endCommit }] = await Promise.all([
|
||||
pluginApiClient.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: startTag.tagSha,
|
||||
}),
|
||||
pluginApiClient.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: endTag.tagSha,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
startDate: startCommit.createdAt,
|
||||
endDate: endCommit.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (startTag.tagType === 'tag' && endTag.tagType === 'commit') {
|
||||
const [{ date: startDate }, { commit: endCommit }] = await Promise.all([
|
||||
getCommitFromTag({ pluginApiClient, project, tag: startTag }),
|
||||
pluginApiClient.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: endTag.tagSha,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate: endCommit.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (startTag.tagType === 'commit' && endTag.tagType === 'tag') {
|
||||
const [{ commit: startCommit }, { date: endDate }] = await Promise.all([
|
||||
pluginApiClient.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: startTag.tagSha,
|
||||
}),
|
||||
getCommitFromTag({ pluginApiClient, project, tag: endTag }),
|
||||
]);
|
||||
|
||||
return {
|
||||
startDate: startCommit.createdAt,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
throw new GitReleaseManagerError(
|
||||
`Failed to get tag dates for tags with type "${startTag.tagType}" and "${endTag.tagType}"`,
|
||||
);
|
||||
};
|
||||
|
||||
async function getCommitFromTag({
|
||||
pluginApiClient,
|
||||
project,
|
||||
tag,
|
||||
}: {
|
||||
pluginApiClient: GetTagDates['pluginApiClient'];
|
||||
project: GetTagDates['project'];
|
||||
tag: GetTagDates['startTag'] | NonNullable<GetTagDates['endTag']>;
|
||||
}) {
|
||||
const { tag: tagResponse } = await pluginApiClient.getTag({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
tagSha: tag.tagSha,
|
||||
});
|
||||
const { commit: startCommit } = await pluginApiClient.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: tagResponse.objectSha,
|
||||
});
|
||||
|
||||
return {
|
||||
date: startCommit.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef';
|
||||
import { useProjectContext } from '../../../contexts/ProjectContext';
|
||||
|
||||
export const useGetStats = () => {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { project } = useProjectContext();
|
||||
|
||||
const stats = useAsync(async () => {
|
||||
const [{ releases: allReleases }, { tags: allTags }] = await Promise.all([
|
||||
pluginApiClient.getAllReleases({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
}),
|
||||
pluginApiClient.getAllTags({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
allReleases,
|
||||
allTags,
|
||||
};
|
||||
}, [project]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 { createResponseStepError } from './createResponseStepError';
|
||||
|
||||
describe('createResponseStepError', () => {
|
||||
it('should work', () => {
|
||||
const result = createResponseStepError(new Error('banana'));
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"icon": "failure",
|
||||
"message": "Something went wrong ❌",
|
||||
"secondaryMessage": "Error message: banana",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 { ResponseStep } from '../types/types';
|
||||
|
||||
export function createResponseStepError(error: Error): ResponseStep {
|
||||
return {
|
||||
message: 'Something went wrong ❌',
|
||||
secondaryMessage: `Error message: ${error.message}`,
|
||||
icon: 'failure',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 {
|
||||
mockCalverProject,
|
||||
mockSemverProject,
|
||||
} from '../test-helpers/test-helpers';
|
||||
import { getBumpedTag } from './getBumpedTag';
|
||||
|
||||
describe('getBumpedTag', () => {
|
||||
describe('calver', () => {
|
||||
it('should increment patch by 1', () => {
|
||||
const result = getBumpedTag({
|
||||
project: mockCalverProject,
|
||||
tag: 'rc-2020.01.01_1',
|
||||
bumpLevel: 'patch',
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bumpedTag": "rc-2020.01.01_2",
|
||||
"error": undefined,
|
||||
"tagParts": Object {
|
||||
"calver": "2020.01.01",
|
||||
"patch": 2,
|
||||
"prefix": "rc",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should increment patch by 1 regardless of semver-specific arg "bumpLevel"', () => {
|
||||
const result = getBumpedTag({
|
||||
project: mockCalverProject,
|
||||
tag: 'rc-2020.01.01_1',
|
||||
bumpLevel: 'major',
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bumpedTag": "rc-2020.01.01_2",
|
||||
"error": undefined,
|
||||
"tagParts": Object {
|
||||
"calver": "2020.01.01",
|
||||
"patch": 2,
|
||||
"prefix": "rc",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('semver', () => {
|
||||
it('should increment patch by 1', () => {
|
||||
const result = getBumpedTag({
|
||||
project: mockSemverProject,
|
||||
tag: 'rc-1.2.3',
|
||||
bumpLevel: 'patch',
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bumpedTag": "rc-1.2.4",
|
||||
"error": undefined,
|
||||
"tagParts": Object {
|
||||
"major": 1,
|
||||
"minor": 2,
|
||||
"patch": 4,
|
||||
"prefix": "rc",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should increment minor by 1', () => {
|
||||
const result = getBumpedTag({
|
||||
project: mockSemverProject,
|
||||
tag: 'rc-1.2.3',
|
||||
bumpLevel: 'minor',
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bumpedTag": "rc-1.3.0",
|
||||
"error": undefined,
|
||||
"tagParts": Object {
|
||||
"major": 1,
|
||||
"minor": 3,
|
||||
"patch": 0,
|
||||
"prefix": "rc",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should increment major by 1', () => {
|
||||
const result = getBumpedTag({
|
||||
project: mockSemverProject,
|
||||
tag: 'rc-1.2.3',
|
||||
bumpLevel: 'major',
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bumpedTag": "rc-2.0.0",
|
||||
"error": undefined,
|
||||
"tagParts": Object {
|
||||
"major": 2,
|
||||
"minor": 0,
|
||||
"patch": 0,
|
||||
"prefix": "rc",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors', () => {
|
||||
it('should propagate errors for invalid tags', () => {
|
||||
const result = getBumpedTag({
|
||||
project: mockCalverProject,
|
||||
tag: '😬',
|
||||
bumpLevel: 'patch',
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": Object {
|
||||
"subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"😬\\"",
|
||||
"title": "Invalid tag",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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 { CalverTagParts } from './tagParts/getCalverTagParts';
|
||||
import { getTagParts } from './tagParts/getTagParts';
|
||||
import { isCalverTagParts } from './isCalverTagParts';
|
||||
import { Project } from '../contexts/ProjectContext';
|
||||
import { SEMVER_PARTS } from '../constants/constants';
|
||||
import { SemverTagParts } from './tagParts/getSemverTagParts';
|
||||
|
||||
export function getBumpedTag({
|
||||
project,
|
||||
tag,
|
||||
bumpLevel,
|
||||
}: {
|
||||
project: Project;
|
||||
tag: string;
|
||||
bumpLevel: keyof typeof SEMVER_PARTS;
|
||||
}) {
|
||||
const tagParts = getTagParts({ project, tag });
|
||||
|
||||
if (tagParts.error !== undefined) {
|
||||
return {
|
||||
error: tagParts.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (isCalverTagParts(project, tagParts.tagParts)) {
|
||||
return getPatchedCalverTag(tagParts.tagParts);
|
||||
}
|
||||
|
||||
return getBumpedSemverTag(tagParts.tagParts, bumpLevel);
|
||||
}
|
||||
|
||||
function getPatchedCalverTag(tagParts: CalverTagParts) {
|
||||
const bumpedTagParts: CalverTagParts = {
|
||||
...tagParts,
|
||||
patch: tagParts.patch + 1,
|
||||
};
|
||||
const bumpedTag = `${bumpedTagParts.prefix}-${bumpedTagParts.calver}_${bumpedTagParts.patch}`;
|
||||
|
||||
return {
|
||||
bumpedTag,
|
||||
tagParts: bumpedTagParts,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getBumpedSemverTag(
|
||||
tagParts: SemverTagParts,
|
||||
semverBumpLevel: keyof typeof SEMVER_PARTS,
|
||||
) {
|
||||
const { bumpedTagParts } = getBumpedSemverTagParts(tagParts, semverBumpLevel);
|
||||
|
||||
const bumpedTag = `${bumpedTagParts.prefix}-${bumpedTagParts.major}.${bumpedTagParts.minor}.${bumpedTagParts.patch}`;
|
||||
|
||||
return {
|
||||
bumpedTag,
|
||||
tagParts: bumpedTagParts,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBumpedSemverTagParts(
|
||||
tagParts: SemverTagParts,
|
||||
semverBumpLevel: keyof typeof SEMVER_PARTS,
|
||||
) {
|
||||
const bumpedTagParts = {
|
||||
...tagParts,
|
||||
};
|
||||
|
||||
if (semverBumpLevel === 'major') {
|
||||
bumpedTagParts.major = bumpedTagParts.major + 1;
|
||||
bumpedTagParts.minor = 0;
|
||||
bumpedTagParts.patch = 0;
|
||||
}
|
||||
|
||||
if (semverBumpLevel === 'minor') {
|
||||
bumpedTagParts.minor = bumpedTagParts.minor + 1;
|
||||
bumpedTagParts.patch = 0;
|
||||
}
|
||||
|
||||
if (semverBumpLevel === 'patch') {
|
||||
bumpedTagParts.patch = bumpedTagParts.patch + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
bumpedTagParts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 { DateTime } from 'luxon';
|
||||
|
||||
import {
|
||||
mockCalverProject,
|
||||
mockReleaseVersionCalver,
|
||||
mockReleaseVersionSemver,
|
||||
mockSemverProject,
|
||||
} from '../test-helpers/test-helpers';
|
||||
import { getReleaseCandidateGitInfo } from './getReleaseCandidateGitInfo';
|
||||
|
||||
describe('getReleaseCandidateGitInfo', () => {
|
||||
describe('DateTime', () => {
|
||||
it('should format dates as expected', () => {
|
||||
const formattedDate = DateTime.now().toFormat('yyyy.MM.dd');
|
||||
|
||||
expect(formattedDate).toMatch(/^\d{4}.\d{2}.\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calver', () => {
|
||||
it('should return correct Git info', () => {
|
||||
expect(
|
||||
getReleaseCandidateGitInfo({
|
||||
project: mockCalverProject,
|
||||
latestRelease: mockReleaseVersionCalver,
|
||||
semverBumpLevel: 'minor',
|
||||
injectedDate: '2021.01.28',
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"rcBranch": "rc/2021.01.28",
|
||||
"rcReleaseTag": "rc-2021.01.28_0",
|
||||
"releaseName": "Version 2021.01.28",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('semver', () => {
|
||||
it("should return correct Git info when there's previous releases", () => {
|
||||
expect(
|
||||
getReleaseCandidateGitInfo({
|
||||
project: mockSemverProject,
|
||||
latestRelease: mockReleaseVersionSemver,
|
||||
semverBumpLevel: 'minor',
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"rcBranch": "rc/1.3.0",
|
||||
"rcReleaseTag": "rc-1.3.0",
|
||||
"releaseName": "Version 1.3.0",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return correct Git info when there's no previous release", () => {
|
||||
expect(
|
||||
getReleaseCandidateGitInfo({
|
||||
project: mockSemverProject,
|
||||
latestRelease: null,
|
||||
semverBumpLevel: 'minor',
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"rcBranch": "rc/0.0.1",
|
||||
"rcReleaseTag": "rc-0.0.1",
|
||||
"releaseName": "Version 0.0.1",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 { DateTime } from 'luxon';
|
||||
|
||||
import { getBumpedSemverTagParts } from './getBumpedTag';
|
||||
import { GetLatestReleaseResult } from '../api/GitReleaseClient';
|
||||
import { getSemverTagParts } from './tagParts/getSemverTagParts';
|
||||
import { Project } from '../contexts/ProjectContext';
|
||||
import { SEMVER_PARTS } from '../constants/constants';
|
||||
|
||||
interface GetReleaseCandidateGitInfo {
|
||||
project: Project;
|
||||
latestRelease: GetLatestReleaseResult['latestRelease'];
|
||||
semverBumpLevel: keyof typeof SEMVER_PARTS;
|
||||
injectedDate?: string;
|
||||
}
|
||||
|
||||
export const getReleaseCandidateGitInfo = ({
|
||||
project,
|
||||
latestRelease,
|
||||
semverBumpLevel,
|
||||
injectedDate = DateTime.now().toFormat('yyyy.MM.dd'),
|
||||
}: GetReleaseCandidateGitInfo) => {
|
||||
if (project.versioningStrategy === 'calver') {
|
||||
return {
|
||||
rcBranch: `rc/${injectedDate}`,
|
||||
rcReleaseTag: `rc-${injectedDate}_0`,
|
||||
releaseName: `Version ${injectedDate}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!latestRelease) {
|
||||
return {
|
||||
rcBranch: 'rc/0.0.1',
|
||||
rcReleaseTag: 'rc-0.0.1',
|
||||
releaseName: 'Version 0.0.1',
|
||||
};
|
||||
}
|
||||
|
||||
const semverTagParts = getSemverTagParts(latestRelease.tagName);
|
||||
if (semverTagParts.error !== undefined) {
|
||||
return {
|
||||
error: semverTagParts.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { bumpedTagParts } = getBumpedSemverTagParts(
|
||||
semverTagParts.tagParts,
|
||||
semverBumpLevel,
|
||||
);
|
||||
|
||||
const bumpedTag = `${bumpedTagParts.major}.${bumpedTagParts.minor}.${bumpedTagParts.patch}`;
|
||||
|
||||
return {
|
||||
rcBranch: `rc/${bumpedTag}`,
|
||||
rcReleaseTag: `rc-${bumpedTag}`,
|
||||
releaseName: `Version ${bumpedTag}`,
|
||||
};
|
||||
};
|
||||
@@ -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 { getShortCommitHash } from './getShortCommitHash';
|
||||
|
||||
describe('getShortCommitHash', () => {
|
||||
it('should get the short version of the commit hash', () => {
|
||||
const result = getShortCommitHash(
|
||||
'bd3fc6f6351018a748bb7f94c0ecf6c1577d8e06',
|
||||
);
|
||||
|
||||
expect(result).toEqual('bd3fc6f');
|
||||
expect(result.length).toEqual(7);
|
||||
});
|
||||
|
||||
it('should throw for invalid commit hashes (too short)', () => {
|
||||
expect(() => getShortCommitHash('bd3')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid shortCommitHash: less than 7 characters"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 { GitReleaseManagerError } from '../errors/GitReleaseManagerError';
|
||||
|
||||
export function getShortCommitHash(hash: string) {
|
||||
const shortCommitHash = hash.substr(0, 7);
|
||||
|
||||
if (shortCommitHash.length < 7) {
|
||||
throw new GitReleaseManagerError(
|
||||
'Invalid shortCommitHash: less than 7 characters',
|
||||
);
|
||||
}
|
||||
|
||||
return shortCommitHash;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user