Migrate ReleaseManagerAsAService to BOSS

Signed-off-by: Erik Engervall <erik.engervall@gmail.com>
This commit is contained in:
Erik Engervall
2021-04-12 18:31:35 +02:00
parent bf16957fd8
commit ce1749f2d6
77 changed files with 6927 additions and 0 deletions
@@ -0,0 +1,9 @@
---
title: Release Manager as a Service
author: '@erikengervall'
authorUrl: https://github.com/erikengervall
category: Release management
description: Manage releases without having to juggle git commands
documentation: https://github.com/backstage/backstage/tree/master/plugins/release-manager-as-a-service
iconUrl: img/release-manager-as-a-service.svg
npmPackageName: '@backstage/plugin-release-manager-as-a-service'
@@ -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

+1
View File
@@ -30,6 +30,7 @@
"@backstage/plugin-org": "^0.3.10",
"@backstage/plugin-pagerduty": "0.3.2",
"@backstage/plugin-register-component": "^0.2.12",
"@backstage/plugin-release-manager-as-a-service": "^0.1.1",
"@backstage/plugin-rollbar": "^0.3.3",
"@backstage/plugin-scaffolder": "^0.8.0",
"@backstage/plugin-search": "^0.3.4",
+1
View File
@@ -46,3 +46,4 @@ export { plugin as Kafka } from '@backstage/plugin-kafka';
export { todoPlugin } from '@backstage/plugin-todo';
export { badgesPlugin } from '@backstage/plugin-badges';
export { githubDeploymentsPlugin } from '@backstage/plugin-github-deployments';
export { releaseManagerAsAServicePlugin } from '@backstage/plugin-release-manager-as-a-service';
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
@@ -0,0 +1,45 @@
# Release Manager as a Service (RMaaS)
## Overview
`RMaaS` enables developers to manage their releases without having to juggle git commands.
Does it bundle and ship your code? **No**.
What `RMaaS` does is manage your **[releases](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/managing-releases-in-a-repository)** on GitHub, building and shipping is entirely up to you as a developer to handle in your CI.
`RMaaS` is build with industry standards in mind and the flow is as follows:
![](./src/cards/info/rmaas-flow.png)
> **GitHub Enterprise (GHE)**: The source control system where releases reside in a practical sense. Read more about GitHub releases here. Note that this plugin works just as well with a non-enterprise account.
>
> **Release Candidate (RC)**: A GHE pre-release intended primarily for internal testing
>
> **Release Version**: A GHE release intended for end users
Looking at the flow above, a common release lifecycle could be:
- User presses **Create Release Candidate**
- `RMaaS`
1. Creates a release branch `rc/<version>`
1. Creates Release Candidate tag `rc-<version>`
1. Creates a GitHub 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**
- `RMaaS`
1. The selected commit is cherry-picked to the release branch
1. The release tag is bumped
1. Updates GitHub 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**
- `RMaaS`
1. Creates Release Version tag `version-<version>`
1. Promotes the GitHub 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
@@ -0,0 +1,13 @@
# release-manager-as-a-service
Welcome to the release-manager-as-a-service plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/release-manager-as-a-service](http://localhost:3000/release-manager-as-a-service).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
@@ -0,0 +1,55 @@
/*
* 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 {
releaseManagerAsAServicePlugin,
ReleaseManagerAsAServicePage,
} from '../src/plugin';
createDevApp()
.registerPlugin(releaseManagerAsAServicePlugin)
.addPage({
element: (
<ReleaseManagerAsAServicePage
project={{
github: {
org: 'erikengervall',
repo: 'playground',
},
name: 'ReleaseManagerAsAService (semver)',
versioningStrategy: 'semver',
}}
/>
),
title: 'Root Page',
})
.addPage({
element: (
<ReleaseManagerAsAServicePage
project={{
github: {
org: 'erikengervall',
repo: 'playground-2',
},
name: 'ReleaseManagerAsAService (calver)',
versioningStrategy: 'calver',
}}
/>
),
title: 'Another page',
})
.render();
@@ -0,0 +1,51 @@
{
"name": "@backstage/plugin-release-manager-as-a-service",
"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.3",
"@backstage/integration": "^0.5.1",
"@backstage/theme": "^0.2.5",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.0.12",
"date-fns": "^2.19.0",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-use": "^15.3.3",
"react": "^16.13.1"
},
"devDependencies": {
"@backstage/cli": "^0.6.6",
"@backstage/dev-utils": "^0.1.13",
"@backstage/test-utils": "^0.1.9",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"msw": "^0.21.2",
"cross-fetch": "^3.0.6"
},
"files": [
"dist"
]
}
@@ -0,0 +1,179 @@
/*
* 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 { Alert } from '@material-ui/lab';
import { CircularProgress, makeStyles } from '@material-ui/core';
import { useAsync } from 'react-use';
import React, { useState } from 'react';
import { useApi, ContentHeader, ErrorBoundary } from '@backstage/core';
import { CreateRc } from './cards/createRc/CreateRc';
import { getGitHubBatchInfo } from './sideEffects/getGitHubBatchInfo';
import { Info } from './cards/info/Info';
import { Patch } from './cards/patchRc/Patch';
import {
ComponentConfigCreateRc,
ComponentConfigPatch,
ComponentConfigPromoteRc,
GhGetBranchResponse,
GhGetReleaseResponse,
GhGetRepositoryResponse,
Project,
SetRefetch,
} from './types/types';
import { PromoteRc } from './cards/promoteRc/PromoteRc';
import { releaseManagerAsAServiceApiRef } from './api/serviceApiRef';
import {
ApiClientContext,
useApiClientContext,
} from './components/ProjectContext';
import { RMaaSApiClient } from './api/RMaaSApiClient';
interface ReleaseManagerAsAServiceProps {
project: Project;
components?: {
default?: {
createRc?: ComponentConfigCreateRc;
promoteRc?: ComponentConfigPromoteRc;
patch?: ComponentConfigPatch;
};
custom?: ({
project,
setRefetch,
latestRelease,
releaseBranch,
repository,
}: {
project: Project;
setRefetch: SetRefetch;
latestRelease: GhGetReleaseResponse | null;
releaseBranch: GhGetBranchResponse | null;
repository: GhGetRepositoryResponse;
}) => JSX.Element[];
};
}
const useStyles = makeStyles(() => ({
root: {
maxWidth: '999px',
},
}));
export function ReleaseManagerAsAService({
project,
components,
}: ReleaseManagerAsAServiceProps) {
const pluginApiClient = useApi(releaseManagerAsAServiceApiRef);
const RMaaSApi = new RMaaSApiClient({
pluginApiClient,
repoPath: `${project.github.org}/${project.github.repo}`,
});
const classes = useStyles();
return (
<ApiClientContext.Provider value={RMaaSApi}>
<div className={classes.root}>
<ContentHeader title="Release Manager as a Service (RMaaS™️)" />
<Cards project={project} components={components} />
</div>
</ApiClientContext.Provider>
);
}
function Cards({ project, components }: ReleaseManagerAsAServiceProps) {
const apiClient = useApiClientContext();
const [refetch, setRefetch] = useState(0);
const gitHubBatchInfo = useAsync(getGitHubBatchInfo({ apiClient }), [
project,
refetch,
]);
if (gitHubBatchInfo.error) {
return <Alert severity="error">{gitHubBatchInfo.error.message}</Alert>;
}
if (gitHubBatchInfo.loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</div>
);
}
if (gitHubBatchInfo.value === undefined) {
return <Alert severity="error">Failed to fetch latest GHE release</Alert>;
}
if (!gitHubBatchInfo.value.repository.permissions.push) {
return (
<Alert severity="error">
You lack push permissions for repository "{project.github.org}/
{project.github.repo}"
</Alert>
);
}
return (
<ErrorBoundary>
<Info
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
project={project}
/>
{components?.default?.createRc?.omit !== true && (
<CreateRc
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
defaultBranch={gitHubBatchInfo.value.repository.default_branch}
project={project}
setRefetch={setRefetch}
successCb={components?.default?.createRc?.successCb}
/>
)}
{components?.default?.promoteRc?.omit !== true && (
<PromoteRc
latestRelease={gitHubBatchInfo.value.latestRelease}
setRefetch={setRefetch}
successCb={components?.default?.promoteRc?.successCb}
/>
)}
{components?.default?.patch?.omit !== true && (
<Patch
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
project={project}
setRefetch={setRefetch}
successCb={components?.default?.patch?.successCb}
/>
)}
{components
?.custom?.({
project,
setRefetch,
latestRelease: gitHubBatchInfo.value.latestRelease,
releaseBranch: gitHubBatchInfo.value.releaseBranch,
repository: gitHubBatchInfo.value.repository,
})
.map((customElement, index) => (
<div key={`rmaas-custom-element-${index}`}>{customElement}</div>
))}
</ErrorBoundary>
);
}
@@ -0,0 +1,51 @@
/*
* 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 { readGitHubIntegrationConfigs } from '@backstage/integration';
export class PluginApiClientConfig {
private readonly githubAuthApi: OAuthApi;
readonly baseUrl: string;
constructor({
configApi,
githubAuthApi,
}: {
configApi: ConfigApi;
githubAuthApi: OAuthApi;
}) {
this.githubAuthApi = githubAuthApi;
const configs = readGitHubIntegrationConfigs(
configApi.getOptionalConfigArray('integrations.github') ?? [],
);
const githubIntegrationConfig = configs.find(v => v.host === 'github.com');
this.baseUrl =
githubIntegrationConfig?.apiBaseUrl ?? 'https://api.github.com';
}
public async getOctokit() {
const token = await this.githubAuthApi.getAccessToken(['repo']);
return {
octokit: new Octokit({
auth: token,
baseUrl: this.baseUrl,
}),
};
}
}
@@ -0,0 +1,412 @@
/*
* 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 {
GhCompareCommitsResponse,
GhCreateCommitResponse,
GhCreateReferenceResponse,
GhCreateReleaseResponse,
GhCreateTagObjectResponse,
GhGetBranchResponse,
GhGetCommitResponse,
GhGetReleaseResponse,
GhGetRepositoryResponse,
GhMergeResponse,
GhUpdateReferenceResponse,
GhUpdateReleaseResponse,
} from '../types/types';
import { CalverTagParts } from '../helpers/tagParts/getCalverTagParts';
import { getRcGheInfo } from '../cards/createRc/getRcGheInfo';
import { PluginApiClientConfig } from './PluginApiClientConfig';
import { SemverTagParts } from '../helpers/tagParts/getSemverTagParts';
/**
* Docs
* https://github.com/octokit/request.js/#the-data-parameter--set-request-body-directly
*/
export class RMaaSApiClient {
private readonly pluginApiClient: PluginApiClientConfig;
private readonly repoPath: string;
private readonly githubCommonPath: string;
constructor({
pluginApiClient,
repoPath,
}: {
pluginApiClient: PluginApiClientConfig;
repoPath: string;
}) {
this.pluginApiClient = pluginApiClient;
this.repoPath = repoPath;
this.githubCommonPath = `/repos/${this.repoPath}`;
}
public getRepoPath() {
return this.repoPath;
}
async getRecentCommits({
releaseBranchName,
}: { releaseBranchName?: string } = {}) {
const { octokit } = await this.pluginApiClient.getOctokit();
const sha = releaseBranchName ? `?sha=${releaseBranchName}` : '';
const recentCommits: GhGetCommitResponse[] = (
await octokit.request(`${this.githubCommonPath}/commits${sha}`)
).data;
return { recentCommits };
}
async getReleases() {
const { octokit } = await this.pluginApiClient.getOctokit();
const releases: GhGetReleaseResponse[] = (
await octokit.request(`${this.githubCommonPath}/releases`)
).data;
return { releases };
}
async getRelease({ releaseId }: { releaseId: number }) {
const { octokit } = await this.pluginApiClient.getOctokit();
const latestRelease: GhGetReleaseResponse = (
await octokit.request(`${this.githubCommonPath}/releases/${releaseId}`)
).data;
return { latestRelease };
}
async getRepository() {
const { octokit } = await this.pluginApiClient.getOctokit();
const repository: GhGetRepositoryResponse = (
await octokit.request(this.githubCommonPath)
).data;
return { repository };
}
async getLatestCommit({ defaultBranch }: { defaultBranch: string }) {
const { octokit } = await this.pluginApiClient.getOctokit();
const latestCommit: GhGetCommitResponse = (
await octokit.request(
`${this.githubCommonPath}/commits/refs/heads/${defaultBranch}`,
)
).data;
return { latestCommit };
}
async getBranch({ branchName }: { branchName: string }) {
const { octokit } = await this.pluginApiClient.getOctokit();
const branch: GhGetBranchResponse = (
await octokit.request(`${this.githubCommonPath}/branches/${branchName}`)
).data;
return { branch };
}
createRc = {
createRef: async ({
mostRecentSha,
targetBranch,
}: {
mostRecentSha: string;
targetBranch: string;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const createdRef: GhCreateReferenceResponse = (
await octokit.request(`${this.githubCommonPath}/git/refs`, {
method: 'POST',
data: {
ref: `refs/heads/${targetBranch}`,
sha: mostRecentSha,
},
})
).data;
return { createdRef };
},
getComparison: async ({
previousReleaseBranch,
nextReleaseBranch,
}: {
previousReleaseBranch: string;
nextReleaseBranch: string;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const comparison: GhCompareCommitsResponse = (
await octokit.request(
`${this.githubCommonPath}/compare/${previousReleaseBranch}...${nextReleaseBranch}`,
)
).data;
return { comparison };
},
createRelease: async ({
nextGheInfo,
releaseBody,
}: {
nextGheInfo: ReturnType<typeof getRcGheInfo>;
releaseBody: string;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const createReleaseResponse: GhCreateReleaseResponse = (
await octokit.request(`${this.githubCommonPath}/releases`, {
method: 'POST',
data: {
tag_name: nextGheInfo.rcReleaseTag,
name: nextGheInfo.releaseName,
target_commitish: nextGheInfo.rcBranch,
body: releaseBody,
prerelease: true,
},
})
).data;
return { createReleaseResponse };
},
};
patch = {
createTempCommit: async ({
tagParts,
releaseBranchTree,
selectedPatchCommit,
}: {
tagParts: SemverTagParts | CalverTagParts;
releaseBranchTree: string;
selectedPatchCommit: GhGetCommitResponse;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const tempCommit: GhCreateCommitResponse = (
await octokit.request(`${this.githubCommonPath}/git/commits`, {
method: 'POST',
data: {
message: `Temporary commit for patch ${tagParts.patch}`,
tree: releaseBranchTree,
parents: [selectedPatchCommit.parents[0].sha],
},
})
).data;
return { tempCommit };
},
forceBranchHeadToTempCommit: async ({
releaseBranchName,
tempCommit,
}: {
releaseBranchName: string;
tempCommit: GhCreateCommitResponse;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
await octokit.request(
`${this.githubCommonPath}/git/refs/heads/${releaseBranchName}`,
{
method: 'PATCH',
data: {
sha: tempCommit.sha,
force: true,
},
},
);
},
merge: async ({ base, head }: { base: string; head: string }) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const merge: GhMergeResponse = (
await octokit.request(`${this.githubCommonPath}/merges`, {
method: 'POST',
data: { base, head },
})
).data;
return { merge };
},
createCherryPickCommit: async ({
bumpedTag,
selectedPatchCommit,
mergeTree,
releaseBranchSha,
}: {
bumpedTag: string;
selectedPatchCommit: GhGetCommitResponse;
mergeTree: string;
releaseBranchSha: string;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const cherryPickCommit: GhCreateCommitResponse = (
await octokit.request(`${this.githubCommonPath}/git/commits`, {
method: 'POST',
data: {
message: `[patch ${bumpedTag}] ${selectedPatchCommit.commit.message}`,
tree: mergeTree,
parents: [releaseBranchSha],
},
})
).data;
return { cherryPickCommit };
},
replaceTempCommit: async ({
releaseBranchName,
cherryPickCommit,
}: {
releaseBranchName: string;
cherryPickCommit: GhCreateCommitResponse;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const updatedReference: GhUpdateReferenceResponse = (
await octokit.request(
`${this.githubCommonPath}/git/refs/heads/${releaseBranchName}`,
{
method: 'PATCH',
data: {
sha: cherryPickCommit.sha,
force: true,
},
},
)
).data;
return { updatedReference };
},
createTagObject: async ({
bumpedTag,
updatedReference,
}: {
bumpedTag: string;
updatedReference: GhUpdateReferenceResponse;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const tagObjectResponse: GhCreateTagObjectResponse = (
await octokit.request(`${this.githubCommonPath}/git/tags`, {
method: 'POST',
data: {
type: 'commit',
message:
'Tag generated by your friendly neighborhood Release Manager as a Service',
tag: bumpedTag,
object: updatedReference.object.sha,
},
})
).data;
return { tagObjectResponse };
},
createReference: async ({
bumpedTag,
tagObjectResponse,
}: {
bumpedTag: string;
tagObjectResponse: GhCreateTagObjectResponse;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const reference: GhCreateReferenceResponse = (
await octokit.request(`${this.githubCommonPath}/git/refs`, {
method: 'POST',
data: {
ref: `refs/tags/${bumpedTag}`,
sha: tagObjectResponse.sha,
},
})
).data;
return { reference };
},
updateRelease: async ({
bumpedTag,
latestRelease,
tagParts,
selectedPatchCommit,
}: {
bumpedTag: string;
latestRelease: GhGetReleaseResponse;
tagParts: SemverTagParts | CalverTagParts;
selectedPatchCommit: GhGetCommitResponse;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const release: GhUpdateReleaseResponse = (
await octokit.request(
`${this.githubCommonPath}/releases/${latestRelease.id}`,
{
method: 'PATCH',
data: {
tag_name: bumpedTag,
body: `${latestRelease.body}
#### [Patch ${tagParts.patch}](${selectedPatchCommit.html_url})
${selectedPatchCommit.commit.message}`,
},
},
)
).data;
return { release };
},
};
promoteRc = {
promoteRelease: async ({
releaseId,
releaseVersion,
}: {
releaseId: GhGetReleaseResponse['id'];
releaseVersion: string;
}) => {
const { octokit } = await this.pluginApiClient.getOctokit();
const release: GhGetReleaseResponse = (
await octokit.request(
`${this.githubCommonPath}/releases/${releaseId}`,
{
method: 'PATCH',
data: {
tag_name: releaseVersion,
prerelease: false,
},
},
)
).data;
return { release };
},
};
}
@@ -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 { createApiRef } from '@backstage/core';
import { PluginApiClientConfig } from './PluginApiClientConfig';
export const releaseManagerAsAServiceApiRef = createApiRef<PluginApiClientConfig>(
{
id: 'plugin.release-manager-as-a-service.service',
description:
'Used by the Release Manager as a Service plugin to make requests',
},
);
@@ -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 { render } from '@testing-library/react';
import {
mockCalverProject,
mockNextGheInfo,
mockRcRelease,
mockReleaseBranch,
mockReleaseVersion,
mockSemverProject,
mockApiClient,
} from '../../test-helpers/test-helpers';
import { TEST_IDS } from '../../test-helpers/test-ids';
jest.mock('../../components/ProjectContext', () => ({
useApiClientContext: () => mockApiClient,
}));
jest.mock('./getRcGheInfo', () => ({
getRcGheInfo: () => mockNextGheInfo,
}));
import { CreateRc } from './CreateRc';
describe('CreateRc', () => {
it('should display CTA', () => {
const { getByTestId } = render(
<CreateRc
defaultBranch="mockDefaultBranch"
latestRelease={mockRcRelease}
project={mockCalverProject}
setRefetch={jest.fn()}
releaseBranch={mockReleaseBranch}
/>,
);
expect(getByTestId(TEST_IDS.createRc.cta)).toBeInTheDocument();
});
it('should display select element for semver', () => {
const { getByTestId } = render(
<CreateRc
defaultBranch="mockDefaultBranch"
latestRelease={mockReleaseVersion}
project={mockSemverProject}
setRefetch={jest.fn()}
releaseBranch={mockReleaseBranch}
/>,
);
expect(getByTestId(TEST_IDS.createRc.semverSelect)).toBeInTheDocument();
});
});
@@ -0,0 +1,205 @@
/*
* 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 } from '@material-ui/lab';
import {
Button,
FormControl,
InputLabel,
MenuItem,
Select,
Typography,
} from '@material-ui/core';
import { useAsyncFn } from 'react-use';
import { createGheRc } from './sideEffects/createGheRc';
import { Differ } from '../../components/Differ';
import { getRcGheInfo } from './getRcGheInfo';
import { InfoCardPlus } from '../../components/InfoCardPlus';
import {
ComponentConfigCreateRc,
GhGetBranchResponse,
GhGetReleaseResponse,
GhGetRepositoryResponse,
Project,
SetRefetch,
} from '../../types/types';
import { ResponseStepList } from '../../components/ResponseStepList/ResponseStepList';
import { useStyles } from '../../styles/styles';
import { useApiClientContext } from '../../components/ProjectContext';
import { SEMVER_PARTS } from '../../constants/constants';
import { TEST_IDS } from '../../test-helpers/test-ids';
interface CreateRcProps {
defaultBranch: GhGetRepositoryResponse['default_branch'];
latestRelease: GhGetReleaseResponse | null;
project: Project;
releaseBranch: GhGetBranchResponse | null;
setRefetch: SetRefetch;
successCb?: ComponentConfigCreateRc['successCb'];
}
export const CreateRc = ({
defaultBranch,
latestRelease,
project,
releaseBranch,
setRefetch,
successCb,
}: CreateRcProps) => {
const apiClient = useApiClientContext();
const classes = useStyles();
const [semverBumpLevel, setSemverBumpLevel] = useState<'major' | 'minor'>(
SEMVER_PARTS.minor,
);
const [nextGheInfo, setNextGheInfo] = useState(
getRcGheInfo({ latestRelease, project, semverBumpLevel }),
);
useEffect(() => {
setNextGheInfo(getRcGheInfo({ latestRelease, project, semverBumpLevel }));
}, [semverBumpLevel, setNextGheInfo, latestRelease, project]);
const [createReleaseResponse, callCreateGheRc] = useAsyncFn(
async (...args) => {
const createGheRcResponseSteps = await createGheRc({
apiClient,
defaultBranch,
latestRelease,
nextGheInfo: args[0],
successCb,
});
return createGheRcResponseSteps;
},
);
if (createReleaseResponse.error) {
return (
<Alert severity="error">{createReleaseResponse.error.message}</Alert>
);
}
const tagAlreadyExists =
latestRelease !== null &&
latestRelease.tag_name === nextGheInfo.rcReleaseTag;
const conflictingPreRelease =
latestRelease !== null && latestRelease.prerelease;
function Description() {
if (conflictingPreRelease) {
return (
<Alert className={classes.paragraph} severity="warning">
The most recent release is already a Release Candidate
</Alert>
);
}
if (tagAlreadyExists) {
return (
<Alert className={classes.paragraph} severity="warning">
There's already a tag named{' '}
<strong>{nextGheInfo.rcReleaseTag}</strong>
</Alert>
);
}
return (
<div className={classes.paragraph}>
<Typography>
<Differ
icon="branch"
prev={releaseBranch?.name}
next={nextGheInfo.rcBranch}
/>
</Typography>
<Typography>
<Differ
icon="tag"
prev={latestRelease?.tag_name}
next={nextGheInfo.rcReleaseTag}
/>
</Typography>
</div>
);
}
function CTA() {
if (createReleaseResponse.loading || createReleaseResponse.value) {
return (
<ResponseStepList
responseSteps={createReleaseResponse.value}
loading={createReleaseResponse.loading}
title="Create RC result"
setRefetch={setRefetch}
/>
);
}
return (
<Button
data-testid={TEST_IDS.createRc.cta}
disabled={conflictingPreRelease || tagAlreadyExists}
variant="contained"
color="primary"
onClick={() => callCreateGheRc(nextGheInfo)}
>
Create RC
</Button>
);
}
return (
<InfoCardPlus>
<Typography variant="h4" className={classes.paragraph}>
Create Release Candidate
</Typography>
{project.versioningStrategy === 'semver' &&
latestRelease &&
!conflictingPreRelease && (
<div
className={classes.paragraph}
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>
</div>
)}
<Description />
<CTA />
</InfoCardPlus>
);
};
@@ -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 { format } from 'date-fns';
import { GhGetReleaseResponse } from '../../types/types';
import {
mockSemverProject,
mockCalverProject,
} from '../../test-helpers/test-helpers';
import { getRcGheInfo } from './getRcGheInfo';
const injectedDate = format(1611869955783, 'yyyy.MM.dd');
describe('getRCGheInfo', () => {
describe('calver', () => {
const latestRelease = {
tag_name: 'rc-2020.01.01_0',
} as GhGetReleaseResponse;
it('should return correct Ghe info', () => {
expect(
getRcGheInfo({
project: mockCalverProject,
latestRelease,
semverBumpLevel: 'minor',
injectedDate,
}),
).toMatchInlineSnapshot(`
Object {
"rcBranch": "rc/2021.01.28",
"rcReleaseTag": "rc-2021.01.28_0",
"releaseName": "Version 2021.01.28",
}
`);
});
});
describe('semver', () => {
const latestRelease = {
tag_name: 'rc-1.1.1',
} as GhGetReleaseResponse;
it("should return correct Ghe info when there's previous releases", () => {
expect(
getRcGheInfo({
project: mockSemverProject,
latestRelease,
semverBumpLevel: 'minor',
}),
).toMatchInlineSnapshot(`
Object {
"rcBranch": "rc/1.2.0",
"rcReleaseTag": "rc-1.2.0",
"releaseName": "Version 1.2.0",
}
`);
});
it("should return correct Ghe info when there's no previous release", () => {
expect(
getRcGheInfo({
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,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 { format } from 'date-fns';
import { getBumpedSemverTagParts } from '../../helpers/getBumpedTag';
import { getSemverTagParts } from '../../helpers/tagParts/getSemverTagParts';
import { Project, GhGetReleaseResponse } from '../../types/types';
import { SEMVER_PARTS } from '../../constants/constants';
export const getRcGheInfo = ({
project,
latestRelease,
semverBumpLevel,
injectedDate = format(new Date(), 'yyyy.MM.dd'), // '0012-01-01T13:37:00.000Z'
}: {
project: Project;
latestRelease: GhGetReleaseResponse | null;
semverBumpLevel: keyof typeof SEMVER_PARTS;
injectedDate?: string;
}) => {
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 tagParts = getSemverTagParts(latestRelease.tag_name);
const { bumpedTagParts } = getBumpedSemverTagParts(tagParts, semverBumpLevel);
const bumpedTag = `${bumpedTagParts.major}.${bumpedTagParts.minor}.${bumpedTagParts.patch}`;
return {
rcBranch: `rc/${bumpedTag}`,
rcReleaseTag: `rc-${bumpedTag}`,
releaseName: `Version ${bumpedTag}`,
};
};
@@ -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 {
mockDefaultBranch,
mockReleaseVersion,
mockNextGheInfo,
mockApiClient,
} from '../../../test-helpers/test-helpers';
import { createGheRc } from './createGheRc';
describe('createGheRc', () => {
beforeEach(jest.clearAllMocks);
it('should work', async () => {
const result = await createGheRc({
apiClient: mockApiClient,
defaultBranch: mockDefaultBranch,
latestRelease: mockReleaseVersion,
nextGheInfo: mockNextGheInfo,
});
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"link": "mock_latestCommit_html_url",
"message": "Fetched latest commit from \\"mock_defaultBranch\\"",
"secondaryMessage": "with message \\"mock_latestCommit_message\\"",
},
Object {
"message": "Cut Release Branch",
"secondaryMessage": "with ref \\"mock_createRef_ref\\"",
},
Object {
"link": "mock_compareCommits_html_url",
"message": "Fetched commit comparision",
"secondaryMessage": "rc/1.2.3...rc/1.2.3",
},
Object {
"link": "mock_createRelease_html_url",
"message": "Created Release Candidate \\"mock_createRelease_name\\"",
"secondaryMessage": "with tag \\"rc-1.2.3\\"",
},
]
`);
});
});
@@ -0,0 +1,129 @@
/*
* 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 { getRcGheInfo } from '../getRcGheInfo';
import {
ComponentConfigCreateRc,
GhCreateReferenceResponse,
GhGetReleaseResponse,
GhGetRepositoryResponse,
ResponseStep,
} from '../../../types/types';
import { RMaaSApiClient } from '../../../api/RMaaSApiClient';
import { ReleaseManagerAsAServiceError } from '../../../errors/ReleaseManagerAsAServiceError';
interface CreateGheRC {
apiClient: RMaaSApiClient;
defaultBranch: GhGetRepositoryResponse['default_branch'];
latestRelease: GhGetReleaseResponse | null;
nextGheInfo: ReturnType<typeof getRcGheInfo>;
successCb?: ComponentConfigCreateRc['successCb'];
}
export async function createGheRc({
apiClient,
defaultBranch,
latestRelease,
nextGheInfo,
successCb,
}: CreateGheRC) {
const responseSteps: ResponseStep[] = [];
/**
* 1. Get the default branch's most recent commit
*/
const { latestCommit } = await apiClient.getLatestCommit({
defaultBranch,
});
responseSteps.push({
message: `Fetched latest commit from "${defaultBranch}"`,
secondaryMessage: `with message "${latestCommit.commit.message}"`,
link: latestCommit.html_url,
});
/**
* 2. Create a new ref based on the default branch's most recent sha
*/
const mostRecentSha = latestCommit.sha;
let createdRef: GhCreateReferenceResponse;
try {
createdRef = (
await apiClient.createRc.createRef({
mostRecentSha,
targetBranch: nextGheInfo.rcBranch,
})
).createdRef;
} catch (error) {
if (error.body.message === 'Reference already exists') {
throw new ReleaseManagerAsAServiceError(
`Branch "${nextGheInfo.rcBranch}" already exists: .../tree/${nextGheInfo.rcBranch}`,
);
}
throw error;
}
responseSteps.push({
message: 'Cut Release Branch',
secondaryMessage: `with ref "${createdRef.ref}"`,
});
/**
* 3. Compose a body for the release
*/
const previousReleaseBranch = latestRelease
? latestRelease.target_commitish
: defaultBranch;
const nextReleaseBranch = nextGheInfo.rcBranch;
const { comparison } = await apiClient.createRc.getComparison({
previousReleaseBranch,
nextReleaseBranch,
});
const releaseBody = `**Compare** ${comparison.html_url}
**Ahead by** ${comparison.ahead_by} commits
**Release branch** ${createdRef.ref}
---
`;
responseSteps.push({
message: 'Fetched commit comparision',
secondaryMessage: `${previousReleaseBranch}...${nextReleaseBranch}`,
link: comparison.html_url,
});
/**
* 4. Creates the release itself in GHE
*/
const { createReleaseResponse } = await apiClient.createRc.createRelease({
nextGheInfo,
releaseBody,
});
responseSteps.push({
message: `Created Release Candidate "${createReleaseResponse.name}"`,
secondaryMessage: `with tag "${nextGheInfo.rcReleaseTag}"`,
link: createReleaseResponse.html_url,
});
await successCb?.({
gitHubReleaseUrl: createReleaseResponse.html_url,
gitHubReleaseName: createReleaseResponse.name,
comparisonUrl: comparison.html_url,
previousTag: latestRelease?.tag_name,
createdTag: createReleaseResponse.tag_name,
});
return responseSteps;
}
@@ -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 React from 'react';
import { render } from '@testing-library/react';
import {
mockCalverProject,
mockReleaseBranch,
} from '../../test-helpers/test-helpers';
import { TEST_IDS } from '../../test-helpers/test-ids';
import { Info } from './Info';
describe('Info', () => {
it('should return early if no latestRelease exists', () => {
const { getByTestId } = render(
<Info
latestRelease={null}
project={mockCalverProject}
releaseBranch={mockReleaseBranch}
/>,
);
expect(getByTestId(TEST_IDS.info.info)).toBeInTheDocument();
});
});
@@ -0,0 +1,133 @@
/*
* 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 { Link, Typography } from '@material-ui/core';
import { Differ } from '../../components/Differ';
import { InfoCardPlus } from '../../components/InfoCardPlus';
import {
GhGetBranchResponse,
GhGetReleaseResponse,
Project,
} from '../../types/types';
import { useStyles } from '../../styles/styles';
import { TEST_IDS } from '../../test-helpers/test-ids';
import rmaasFlowImage from './rmaas-flow.png';
interface InfoCardProps {
releaseBranch: GhGetBranchResponse | null;
latestRelease: GhGetReleaseResponse | null;
project: Project;
}
export const Info = ({
releaseBranch,
latestRelease,
project,
}: InfoCardProps) => {
const classes = useStyles();
return (
<InfoCardPlus>
<div style={{ marginBottom: '1em' }} data-testid={TEST_IDS.info.info}>
<Typography variant="h6">Terminology</Typography>
<Typography>
<strong>GitHub Enterprise (GHE)</strong>: The source control system
where releases reside in a practical sense. Read more about GitHub
releases{' '}
<Link
href="https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/managing-releases-in-a-repository"
target="_blank"
>
here
</Link>
. Note that this plugin works just as well with a non-enterprise
account.
</Typography>
<Typography>
<strong>Release Candidate</strong>: A GHE <i>pre-release</i> intended
primarily for internal testing
</Typography>
<Typography>
<strong>Release Version</strong>: A GHE release intended for end users
</Typography>
</div>
<div style={{ marginBottom: '1em' }}>
<Typography variant="h6">Flow</Typography>
<Typography className={classes.paragraph}>
RMaaS 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>
<Typography className={classes.paragraph}>
Here's an overview of the flow:
</Typography>
<img alt="rmaas-flow" src={rmaasFlowImage} style={{ width: '100%' }} />
</div>
<div style={{ marginBottom: '1em' }}>
<Typography variant="h6">Details</Typography>
<Typography>
Repository:{' '}
<Differ
icon="ghe"
next={`${project.github.org}/${project.github.repo}`}
/>
</Typography>
{project.slack && (
<Typography>
Slack channel:{' '}
<Differ
icon="slack"
next={
project.slack.link ? (
<Link href={project.slack.link} target="_blank">
#{project.slack.channel}
</Link>
) : (
<>(#{project.slack.channel})</>
)
}
/>
</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?.tag_name} />
</Typography>
</div>
</InfoCardPlus>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

@@ -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 { 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';
describe('Patch', () => {
it('should return early if no latestRelease exists', () => {
const { getByTestId } = render(
<Patch
latestRelease={null}
project={mockCalverProject}
setRefetch={jest.fn()}
releaseBranch={mockReleaseBranch}
/>,
);
expect(
getByTestId(TEST_IDS.components.noLatestRelease),
).toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Typography } from '@material-ui/core';
import { getBumpedTag } from '../../helpers/getBumpedTag';
import { InfoCardPlus } from '../../components/InfoCardPlus';
import { NoLatestRelease } from '../../components/NoLatestRelease';
import {
ComponentConfigPatch,
GhGetBranchResponse,
GhGetReleaseResponse,
Project,
SetRefetch,
} from '../../types/types';
import { useStyles } from '../../styles/styles';
import { PatchBody } from './PatchBody';
interface PatchProps {
latestRelease: GhGetReleaseResponse | null;
project: Project;
releaseBranch: GhGetBranchResponse | null;
setRefetch: SetRefetch;
successCb?: ComponentConfigPatch['successCb'];
}
export const Patch = ({
latestRelease,
project,
releaseBranch,
setRefetch,
successCb,
}: PatchProps) => {
const classes = useStyles();
function Body() {
if (latestRelease === null) {
return <NoLatestRelease />;
}
const { bumpedTag, tagParts } = getBumpedTag({
project,
tag: latestRelease.tag_name,
bumpLevel: 'patch',
});
return (
<PatchBody
bumpedTag={bumpedTag}
latestRelease={latestRelease}
releaseBranch={releaseBranch}
setRefetch={setRefetch}
successCb={successCb}
tagParts={tagParts}
/>
);
}
return (
<InfoCardPlus>
<Typography variant="h4" className={classes.paragraph}>
Patch Release {latestRelease?.prerelease ? 'Candidate' : 'Version'}
</Typography>
<Body />
</InfoCardPlus>
);
};
@@ -0,0 +1,77 @@
/*
* 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 {
mockBumpedTag,
mockRcRelease,
mockReleaseBranch,
mockReleaseVersion,
mockTagParts,
mockApiClient,
} from '../../test-helpers/test-helpers';
jest.mock('../../components/ProjectContext', () => ({
useApiClientContext: () => mockApiClient,
}));
import { PatchBody } from './PatchBody';
import { TEST_IDS } from '../../test-helpers/test-ids';
describe('PatchBody', () => {
beforeEach(jest.clearAllMocks);
it('should render error', async () => {
mockApiClient.getBranch.mockImplementationOnce(() => {
throw new Error('lmao hehe');
});
const { getByTestId } = render(
<PatchBody
latestRelease={mockRcRelease}
setRefetch={jest.fn()}
releaseBranch={mockReleaseBranch}
bumpedTag={mockBumpedTag}
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={mockReleaseVersion}
setRefetch={jest.fn()}
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,301 @@
/*
* 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 { Alert, AlertTitle } from '@material-ui/lab';
import { useAsync, useAsyncFn } from 'react-use';
import FileCopyIcon from '@material-ui/icons/FileCopy';
import Paper from '@material-ui/core/Paper';
import {
Button,
Checkbox,
CircularProgress,
IconButton,
Link,
List,
ListItem,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
Typography,
} from '@material-ui/core';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { Differ } from '../../components/Differ';
import {
ComponentConfigPatch,
GhGetBranchResponse,
GhGetCommitResponse,
GhGetReleaseResponse,
SetRefetch,
} from '../../types/types';
import { CalverTagParts } from '../../helpers/tagParts/getCalverTagParts';
import { ResponseStepList } from '../../components/ResponseStepList/ResponseStepList';
import { SemverTagParts } from '../../helpers/tagParts/getSemverTagParts';
import { useApiClientContext } from '../../components/ProjectContext';
import { useStyles } from '../../styles/styles';
import { TEST_IDS } from '../../test-helpers/test-ids';
import { patch } from './sideEffects/patch';
interface PatchBodyProps {
bumpedTag: string;
latestRelease: GhGetReleaseResponse;
releaseBranch: GhGetBranchResponse | null;
setRefetch: SetRefetch;
successCb?: ComponentConfigPatch['successCb'];
tagParts: NonNullable<CalverTagParts | SemverTagParts>;
}
export const PatchBody = ({
bumpedTag,
latestRelease,
releaseBranch,
setRefetch,
successCb,
tagParts,
}: PatchBodyProps) => {
const apiClient = useApiClientContext();
const [checkedCommitIndex, setCheckedCommitIndex] = useState(-1);
const gheDataResponse = useAsync(async () => {
const [
{ branch: releaseBranchResponse },
{ recentCommits },
] = await Promise.all([
apiClient.getBranch({ branchName: latestRelease.target_commitish }),
apiClient.getRecentCommits(),
]);
const {
recentCommits: recentReleaseBranchCommits,
} = await apiClient.getRecentCommits({
releaseBranchName: releaseBranchResponse.name,
});
return {
releaseBranch: releaseBranchResponse,
recentReleaseBranchCommits,
recentCommits,
};
});
const [patchGheRcResponse, patchGheRcFn] = useAsyncFn(async (...args) => {
const selectedPatchCommit: GhGetCommitResponse = args[0];
const patchResponseSteps = await patch({
apiClient,
bumpedTag,
latestRelease,
selectedPatchCommit,
successCb,
tagParts,
});
return patchResponseSteps;
});
if (gheDataResponse.error) {
return (
<Alert data-testid={TEST_IDS.patch.error} severity="error">
{gheDataResponse.error.message}
</Alert>
);
}
if (patchGheRcResponse.error) {
return <Alert severity="error">{patchGheRcResponse.error.message}</Alert>;
}
if (gheDataResponse.loading) {
return <CircularProgress data-testid={TEST_IDS.patch.loading} />;
}
function Description() {
const classes = useStyles();
return (
<>
{!latestRelease.prerelease && (
<Alert
data-testid={TEST_IDS.patch.notPrerelease}
className={classes.paragraph}
severity="info"
>
<AlertTitle>
The current GHE release is a <b>Release Version</b>
</AlertTitle>
It's still possible to patch it, but be extra mindful of changes
</Alert>
)}
<Typography className={classes.paragraph}>
<Differ icon="tag" prev={latestRelease.tag_name} next={bumpedTag} />
</Typography>
</>
);
}
function CommitList() {
if (!gheDataResponse.value?.recentCommits) {
return null;
}
return (
<List>
{gheDataResponse.value.recentCommits.map((commit, index) => {
const commitExistsOnReleaseBranch = !!gheDataResponse.value?.recentReleaseBranchCommits.find(
({ sha }) => {
return sha === commit.sha;
},
);
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={
patchGheRcResponse.loading ||
(patchGheRcResponse.value &&
patchGheRcResponse.value.length > 0) ||
commitExistsOnReleaseBranch
}
role={undefined}
dense
button
onClick={() => {
if (index === checkedCommitIndex) {
setCheckedCommitIndex(-1);
} else {
setCheckedCommitIndex(index);
}
}}
>
<ListItemIcon>
<Checkbox
edge="start"
checked={checkedCommitIndex === index}
tabIndex={-1}
/>
</ListItemIcon>
<ListItemText
id={commit.sha}
primary={commit.commit.message}
secondary={
<>
{commit.sha}{' '}
<Link
color="primary"
href={commit.author.html_url}
target="_blank"
>
@{commit.author.login}
</Link>
</>
}
/>
<ListItemSecondaryAction>
<IconButton
aria-label="commit"
disabled={commitExistsOnReleaseBranch}
onClick={() => {
const repoPath = apiClient.getRepoPath();
const newTab = window.open(
`https://github.com/${repoPath}/compare/${releaseBranch?.name}...${commit.sha}`,
'_blank',
);
newTab?.focus();
}}
>
<OpenInNewIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</div>
);
})}
</List>
);
}
function CTA() {
if (patchGheRcResponse.loading || patchGheRcResponse.value) {
return (
<ResponseStepList
responseSteps={patchGheRcResponse.value}
loading={patchGheRcResponse.loading}
title="Patch result"
setRefetch={setRefetch}
closeable
/>
);
}
if (!gheDataResponse.value?.recentCommits[checkedCommitIndex]) {
return (
<Button disabled variant="contained" color="primary">
Patch Release Candidate
</Button>
);
}
return (
<Button
disabled={checkedCommitIndex === -1}
variant="contained"
color="primary"
onClick={() => {
// FIXME: Optional chaining shouldn't be needed here due to the if-statement above
patchGheRcFn(
gheDataResponse.value?.recentCommits[checkedCommitIndex],
);
}}
>
Patch Release Candidate
</Button>
);
}
return (
<div data-testid={TEST_IDS.patch.body}>
<Description />
<CommitList />
<CTA />
</div>
);
};
@@ -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 {
mockBumpedTag,
mockReleaseVersion,
mockSelectedPatchCommit,
mockTagParts,
mockApiClient,
} from '../../../test-helpers/test-helpers';
import { patch } from './patch';
describe('patch', () => {
beforeEach(jest.clearAllMocks);
it('should work', async () => {
const result = await patch({
apiClient: mockApiClient,
latestRelease: mockReleaseVersion,
bumpedTag: mockBumpedTag,
selectedPatchCommit: mockSelectedPatchCommit,
tagParts: mockTagParts,
});
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"link": "mock_branch__links_html",
"message": "Fetched release branch \\"rc/1.2.3\\"",
},
Object {
"message": "Created temporary commit",
"secondaryMessage": "with message \\"mock_commit_message\\"",
},
Object {
"link": "mock_merge_html_url",
"message": "Merged temporary commit into \\"rc/1.2.3\\"",
"secondaryMessage": "with message \\"mock_merge_commit_message\\"",
},
Object {
"message": "Cherry-picked patch commit to \\"mock_branch_commit_sha\\"",
"secondaryMessage": "with message \\"undefined\\"",
},
Object {
"message": "Updated reference \\"mock_reference_ref\\"",
},
Object {
"message": "Created new tag object",
"secondaryMessage": "with name \\"mock_tag_object_tag\\"",
},
Object {
"message": "Created new reference \\"mock_reference_ref\\"",
"secondaryMessage": "for tag object \\"mock_tag_object_tag\\"",
},
Object {
"link": "mock_update_release_html_url",
"message": "Updated release \\"mock_update_release_name\\"",
"secondaryMessage": "with tag mock_update_release_tag_name",
},
]
`);
});
});
@@ -0,0 +1,194 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ComponentConfigPatch,
GhGetCommitResponse,
GhGetReleaseResponse,
ResponseStep,
} from '../../../types/types';
import { CalverTagParts } from '../../../helpers/tagParts/getCalverTagParts';
import { ReleaseManagerAsAServiceError } from '../../../errors/ReleaseManagerAsAServiceError';
import { RMaaSApiClient } from '../../../api/RMaaSApiClient';
import { SemverTagParts } from '../../../helpers/tagParts/getSemverTagParts';
interface Patch {
apiClient: RMaaSApiClient;
bumpedTag: string;
latestRelease: GhGetReleaseResponse;
selectedPatchCommit: GhGetCommitResponse;
successCb?: ComponentConfigPatch['successCb'];
tagParts: NonNullable<CalverTagParts | SemverTagParts>;
}
/**
* Inspo: https://stackoverflow.com/questions/53859199/how-to-cherry-pick-through-githubs-api
*/
export async function patch({
apiClient,
bumpedTag,
latestRelease,
selectedPatchCommit,
successCb,
tagParts,
}: Patch) {
const responseSteps: ResponseStep[] = [];
if (!selectedPatchCommit || !selectedPatchCommit.sha) {
throw new ReleaseManagerAsAServiceError('Invalid commit');
}
const releaseBranchName = latestRelease.target_commitish;
/**
* 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 { branch: releaseBranch } = await apiClient.getBranch({
branchName: releaseBranchName,
});
const releaseBranchSha = releaseBranch.commit.sha;
const releaseBranchTree = releaseBranch.commit.commit.tree.sha;
responseSteps.push({
message: `Fetched release branch "${releaseBranch.name}"`,
link: releaseBranch._links.html,
});
/**
* 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 { tempCommit } = await apiClient.patch.createTempCommit({
releaseBranchTree,
selectedPatchCommit,
tagParts,
});
responseSteps.push({
message: 'Created temporary commit',
secondaryMessage: `with message "${tempCommit.message}"`,
});
/**
* 3. Now temporarily force the branch over to that commit:
* > PATCH /repos/$owner/$repo/git/refs/heads/$refName { sha = tempCommit.sha, force = true }
*/
await apiClient.patch.forceBranchHeadToTempCommit({
tempCommit,
releaseBranchName,
});
/**
* 4. Merge the commit we want into this mess:
* > merge = POST /repos/$owner/$repo/merges { "base": branchName, "head": commit.sha }
*/
const { merge } = await apiClient.patch.merge({
base: releaseBranchName,
head: selectedPatchCommit.sha,
});
responseSteps.push({
message: `Merged temporary commit into "${releaseBranchName}"`,
secondaryMessage: `with message "${merge.commit.message}"`,
link: merge.html_url,
});
/**
* and get that tree!
* > mergeTree = merge.commit.tree.sha
*/
const mergeTree = merge.commit.tree.sha;
/**
* 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 { cherryPickCommit } = await apiClient.patch.createCherryPickCommit({
bumpedTag,
mergeTree,
releaseBranchSha,
selectedPatchCommit,
});
responseSteps.push({
message: `Cherry-picked patch commit to "${releaseBranchSha}"`,
secondaryMessage: `with message "${cherryPickCommit.message}"`,
});
/**
* 6. Replace the temp commit with the real commit:
* > PATCH /repos/$owner/$repo/git/refs/heads/$refName { sha = cherry.sha, force = true }
*/
const { updatedReference } = await apiClient.patch.replaceTempCommit({
cherryPickCommit,
releaseBranchName,
});
responseSteps.push({
message: `Updated reference "${updatedReference.ref}"`,
});
/**
* 7. Create tag object: https://developer.github.com/v3/git/tags/#create-a-tag-object
* > POST /repos/:owner/:repo/git/tags
*/
const { tagObjectResponse } = await apiClient.patch.createTagObject({
bumpedTag,
updatedReference,
});
responseSteps.push({
message: 'Created new tag object',
secondaryMessage: `with name "${tagObjectResponse.tag}"`,
});
/**
* 8. Create a reference: https://developer.github.com/v3/git/refs/#create-a-reference
* > POST /repos/:owner/:repo/git/refs
*/
const { reference } = await apiClient.patch.createReference({
bumpedTag,
tagObjectResponse,
});
responseSteps.push({
message: `Created new reference "${reference.ref}"`,
secondaryMessage: `for tag object "${tagObjectResponse.tag}"`,
});
/**
* 9. Update release
*/
const { release: updatedRelease } = await apiClient.patch.updateRelease({
bumpedTag,
latestRelease,
selectedPatchCommit,
tagParts,
});
responseSteps.push({
message: `Updated release "${updatedRelease.name}"`,
secondaryMessage: `with tag ${updatedRelease.tag_name}`,
link: updatedRelease.html_url,
});
await successCb?.({
updatedReleaseUrl: updatedRelease.html_url,
updatedReleaseName: updatedRelease.name,
previousTag: latestRelease.tag_name,
patchedTag: updatedRelease.tag_name,
patchCommitUrl: selectedPatchCommit.html_url,
patchCommitMessage: selectedPatchCommit.commit.message,
});
return responseSteps;
}
@@ -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 React from 'react';
import { render } from '@testing-library/react';
import {
mockRcRelease,
mockReleaseVersion,
} from '../../test-helpers/test-helpers';
import { TEST_IDS } from '../../test-helpers/test-ids';
jest.mock('./PromoteRcBody', () => ({
PromoteRcBody: () => (
<div data-testid={TEST_IDS.promoteRc.mockedPromoteRcBody}>Hello</div>
),
}));
import { PromoteRc } from './PromoteRc';
describe('PromoteRc', () => {
it('return early if no latest release present', () => {
const { getByTestId } = render(
<PromoteRc latestRelease={null} setRefetch={jest.fn()} />,
);
expect(
getByTestId(TEST_IDS.components.noLatestRelease),
).toBeInTheDocument();
});
it('should display not-rc warning', () => {
const { getByTestId } = render(
<PromoteRc latestRelease={mockReleaseVersion} setRefetch={jest.fn()} />,
);
expect(getByTestId(TEST_IDS.promoteRc.notRcWarning)).toBeInTheDocument();
});
it('should display PromoteRcBody', () => {
const { getByTestId } = render(
<PromoteRc latestRelease={mockRcRelease} setRefetch={jest.fn()} />,
);
expect(
getByTestId(TEST_IDS.promoteRc.mockedPromoteRcBody),
).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 { Alert, AlertTitle } from '@material-ui/lab';
import { Typography } from '@material-ui/core';
import { InfoCardPlus } from '../../components/InfoCardPlus';
import { NoLatestRelease } from '../../components/NoLatestRelease';
import {
ComponentConfigPromoteRc,
GhGetReleaseResponse,
SetRefetch,
} from '../../types/types';
import { PromoteRcBody } from './PromoteRcBody';
import { useStyles } from '../../styles/styles';
import { TEST_IDS } from '../../test-helpers/test-ids';
interface PromoteRcProps {
latestRelease: GhGetReleaseResponse | null;
setRefetch: SetRefetch;
successCb?: ComponentConfigPromoteRc['successCb'];
}
export const PromoteRc = ({
latestRelease,
setRefetch,
successCb,
}: PromoteRcProps) => {
const classes = useStyles();
function Body() {
if (latestRelease === null) {
return <NoLatestRelease />;
}
if (!latestRelease.prerelease) {
return (
<Alert
data-testid={TEST_IDS.promoteRc.notRcWarning}
className={classes.paragraph}
severity="warning"
>
<AlertTitle>Latest GHE release is not a Release Candidate</AlertTitle>
One can only promote Release Candidates to Release Versions
</Alert>
);
}
return (
<PromoteRcBody
rcRelease={latestRelease}
setRefetch={setRefetch}
successCb={successCb}
/>
);
}
return (
<InfoCardPlus>
<Typography variant="h4" className={classes.paragraph}>
Promote Release Candidate
</Typography>
<Body />
</InfoCardPlus>
);
};
@@ -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 React from 'react';
import { render } from '@testing-library/react';
import { mockRcRelease, mockApiClient } from '../../test-helpers/test-helpers';
import { TEST_IDS } from '../../test-helpers/test-ids';
jest.mock('../../components/ProjectContext', () => ({
useApiClientContext: () => mockApiClient,
}));
import { PromoteRcBody } from './PromoteRcBody';
describe('PromoteRcBody', () => {
it('should display CTA', () => {
const { getByTestId } = render(
<PromoteRcBody rcRelease={mockRcRelease} setRefetch={jest.fn()} />,
);
expect(getByTestId(TEST_IDS.promoteRc.cta)).toBeInTheDocument();
});
});
@@ -0,0 +1,100 @@
/*
* 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 { Button, Typography } from '@material-ui/core';
import { useAsyncFn } from 'react-use';
import { Differ } from '../../components/Differ';
import {
ComponentConfigPromoteRc,
GhGetReleaseResponse,
SetRefetch,
} from '../../types/types';
import { promoteGheRc } from './sideEffects/promoteGheRc';
import { ResponseStepList } from '../../components/ResponseStepList/ResponseStepList';
import { useApiClientContext } from '../../components/ProjectContext';
import { useStyles } from '../../styles/styles';
import { TEST_IDS } from '../../test-helpers/test-ids';
interface PromoteRcBodyProps {
rcRelease: GhGetReleaseResponse;
setRefetch: SetRefetch;
successCb?: ComponentConfigPromoteRc['successCb'];
}
export const PromoteRcBody = ({
rcRelease,
setRefetch,
successCb,
}: PromoteRcBodyProps) => {
const apiClient = useApiClientContext();
const classes = useStyles();
const releaseVersion = rcRelease.tag_name.replace('rc-', 'version-');
const [promoteGheRcResponse, promoseGheRcFn] = useAsyncFn(
promoteGheRc({ apiClient, rcRelease, releaseVersion, successCb }),
);
if (promoteGheRcResponse.error) {
return <Alert severity="error">{promoteGheRcResponse.error.message}</Alert>;
}
function Description() {
return (
<>
<Typography className={classes.paragraph}>
Promotes the current Release Candidate to a <b>Release Version</b>.
</Typography>
<Typography className={classes.paragraph}>
<Differ icon="tag" prev={rcRelease.tag_name} next={releaseVersion} />
</Typography>
</>
);
}
function CTA() {
if (promoteGheRcResponse.loading || promoteGheRcResponse.value) {
return (
<ResponseStepList
responseSteps={promoteGheRcResponse.value}
title="Promote RC result"
setRefetch={setRefetch}
loading={promoteGheRcResponse.loading}
/>
);
}
return (
<Button
data-testid={TEST_IDS.promoteRc.cta}
variant="contained"
color="primary"
onClick={() => promoseGheRcFn()}
>
Promote Release Candidate
</Button>
);
}
return (
<div>
<Description />
<CTA />
</div>
);
};
@@ -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 {
mockRcRelease,
mockApiClient,
} from '../../../test-helpers/test-helpers';
import { promoteGheRc } from './promoteGheRc';
describe('promoteGheRc', () => {
beforeEach(jest.clearAllMocks);
it('should work', async () => {
const result = await promoteGheRc({
apiClient: mockApiClient,
rcRelease: mockRcRelease,
releaseVersion: 'version-1.2.3',
})();
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"link": "mock_release_html_url",
"message": "Promoted \\"mock_release_name\\"",
"secondaryMessage": "from \\"rc-2020.01.01_1\\" to \\"mock_release_tag_name\\"",
},
]
`);
});
});
@@ -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 {
ComponentConfigPromoteRc,
GhGetReleaseResponse,
ResponseStep,
} from '../../../types/types';
import { RMaaSApiClient } from '../../../api/RMaaSApiClient';
interface PromoteGheRc {
apiClient: RMaaSApiClient;
rcRelease: GhGetReleaseResponse;
releaseVersion: string;
successCb?: ComponentConfigPromoteRc['successCb'];
}
export function promoteGheRc({
apiClient,
rcRelease,
releaseVersion,
successCb,
}: PromoteGheRc) {
return async (): Promise<ResponseStep[]> => {
const responseSteps: ResponseStep[] = [];
const { release } = await apiClient.promoteRc.promoteRelease({
releaseId: rcRelease.id,
releaseVersion,
});
responseSteps.push({
message: `Promoted "${release.name}"`,
secondaryMessage: `from "${rcRelease.tag_name}" to "${release.tag_name}"`,
link: release.html_url,
});
await successCb?.({
gitHubReleaseUrl: release.html_url,
gitHubReleaseName: release.name,
previousTagUrl: rcRelease.html_url,
previousTag: rcRelease.tag_name,
updatedTagUrl: release.html_url,
updatedTag: release.tag_name,
});
return responseSteps;
};
}
@@ -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, { 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';
interface DifferProps {
next?: string | ReactNode;
prev?: string;
prefix?: string;
icon?: 'tag' | 'branch' | 'ghe' | 'slack' | 'versioning';
}
const Icon = ({ icon }: { icon: DifferProps['icon'] }) => {
switch (icon) {
case 'tag':
return (
<LocalOfferIcon style={{ verticalAlign: 'middle' }} fontSize="small" />
);
case 'branch':
return (
<CallSplitIcon style={{ verticalAlign: 'middle' }} fontSize="small" />
);
case 'ghe':
return (
<GitHubIcon style={{ verticalAlign: 'middle' }} fontSize="small" />
);
case 'slack':
return <ChatIcon style={{ verticalAlign: 'middle' }} fontSize="small" />;
case 'versioning':
return (
<DynamicFeedIcon style={{ verticalAlign: 'middle' }} fontSize="small" />
);
default:
return null;
}
};
export const Differ = ({ prev, next, prefix, icon }: DifferProps) => {
return (
<>
{icon && (
<span>
<Icon icon={icon} />{' '}
</span>
)}
{prefix && <span>{prefix}: </span>}
{prev && (
<>
<span style={{ color: grey[700] }}>{prev}</span>
<span>{' → '}</span>
</>
)}
<b>{next ?? 'None'}</b>
</>
);
};
@@ -0,0 +1,28 @@
/*
* 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,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.
*/
import React from 'react';
import { Divider as MaterialDivider } from '@material-ui/core';
import { TEST_IDS } from '../test-helpers/test-ids';
export const Divider = () => {
return (
<div style={{ margin: '20px 0' }} data-testid={TEST_IDS.components.divider}>
<MaterialDivider />
</div>
);
};
@@ -0,0 +1,28 @@
/*
* 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.infoCardPlus)).toBeInTheDocument();
});
});
@@ -0,0 +1,39 @@
/*
* 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(() => ({
card: {
marginBottom: '3em',
},
}));
export const InfoCardPlus = ({ children }: { children?: React.ReactNode }) => {
const classes = useStyles();
return (
<div
style={{ position: 'relative' }}
data-testid={TEST_IDS.info.infoCardPlus}
>
<InfoCard className={classes.card}>{children}</InfoCard>
</div>
);
};
@@ -0,0 +1,30 @@
/*
* 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 { NoLatestRelease } from './NoLatestRelease';
describe('NoLatestRelease', () => {
it('render NoLatestRelease', () => {
const { getByTestId } = render(<NoLatestRelease />);
expect(
getByTestId(TEST_IDS.components.noLatestRelease),
).toBeInTheDocument();
});
});
@@ -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 { useStyles } from '../styles/styles';
import { TEST_IDS } from '../test-helpers/test-ids';
export const NoLatestRelease = () => {
const classes = useStyles();
return (
<Alert
data-testid={TEST_IDS.components.noLatestRelease}
className={classes.paragraph}
severity="warning"
>
Unable to find any GHE releases
</Alert>
);
};
@@ -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 { createContext, useContext } from 'react';
import { RMaaSApiClient } from '../api/RMaaSApiClient';
import { ReleaseManagerAsAServiceError } from '../errors/ReleaseManagerAsAServiceError';
export const ApiClientContext = createContext<RMaaSApiClient | undefined>(
undefined,
);
export const useApiClientContext = () => {
const apiClient = useContext(ApiClientContext);
if (!apiClient) {
throw new ReleaseManagerAsAServiceError('apiClient not found');
}
return apiClient;
};
@@ -0,0 +1,28 @@
/*
* 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 { ReloadButton } from './ReloadButton';
describe('ReloadButton', () => {
it('render ReloadButton', () => {
const { getByTestId } = render(<ReloadButton />);
expect(getByTestId(TEST_IDS.components.reloadButton)).toBeInTheDocument();
});
});
@@ -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 React from 'react';
import { Button } from '@material-ui/core';
import { TEST_IDS } from '../test-helpers/test-ids';
interface ReloadButtonProps {
style?: React.CSSProperties;
}
export const ReloadButton = ({ style }: ReloadButtonProps) => (
<Button
data-testid={TEST_IDS.components.reloadButton}
variant="contained"
color="primary"
onClick={() => window.location.reload()}
style={style}
>
Reload page to see effects
</Button>
);
@@ -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 { render } from '@testing-library/react';
import { TEST_IDS } from '../../test-helpers/test-ids';
import { ResponseStepList } from './ResponseStepList';
describe('ResponseStepList', () => {
it('should render loading state when loading', () => {
const { getByTestId } = render(
<ResponseStepList
loading
setRefetch={jest.fn()}
title="mock_responseStepList_title"
/>,
);
expect(
getByTestId(TEST_IDS.components.circularProgress),
).toBeInTheDocument();
});
it('should render loading state when no responseSteps', () => {
const { getByTestId } = render(
<ResponseStepList
loading={false}
setRefetch={jest.fn()}
title="mock_responseStepList_title"
/>,
);
expect(
getByTestId(TEST_IDS.components.circularProgress),
).toBeInTheDocument();
});
it('should render dialog content when loading is completed', () => {
const { getByTestId } = render(
<ResponseStepList
loading={false}
setRefetch={jest.fn()}
title="mock_responseStepList_title"
responseSteps={[]}
/>,
);
expect(
getByTestId(TEST_IDS.components.responseStepListDialogContent),
).toBeInTheDocument();
});
});
@@ -0,0 +1,113 @@
/*
* 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 { List, CircularProgress } from '@material-ui/core';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import { ResponseStep, SetRefetch } from '../../types/types';
import { TEST_IDS } from '../../test-helpers/test-ids';
import { ResponseStepListItem } from './ResponseStepListItem';
interface ResponseStepListProps {
responseSteps?: ResponseStep[];
setRefetch: SetRefetch;
title: string;
animationDelay?: number;
loading: boolean;
closeable?: boolean;
denseList?: boolean;
}
export const ResponseStepList = ({
responseSteps,
animationDelay,
setRefetch,
loading = false,
closeable = false,
denseList = false,
title,
children,
}: PropsWithChildren<ResponseStepListProps>) => {
const [open, setOpen] = React.useState(true);
const handleClose = () => setOpen(false);
return (
<Dialog
open={open}
onClose={() => {
if (closeable) {
handleClose();
}
}}
maxWidth="md"
fullWidth
>
<DialogTitle>{title}</DialogTitle>
{loading || !responseSteps ? (
<div style={{ margin: 10, textAlign: 'center' }}>
<CircularProgress
data-testid={TEST_IDS.components.circularProgress}
/>
</div>
) : (
<>
<DialogContent
data-testid={TEST_IDS.components.responseStepListDialogContent}
>
<List dense={denseList}>
{responseSteps.map((responseStep, index) => (
<ResponseStepListItem
key={`ResponseStepListItem-${index}`}
responseStep={responseStep}
index={index}
animationDelay={animationDelay}
/>
))}
</List>
{children}
</DialogContent>
<DialogActions>
{closeable && (
<Button
onClick={() => handleClose()}
color="primary"
variant="contained"
size="medium"
>
Close
</Button>
)}
<Button
onClick={() => setRefetch(Date.now())}
color="primary"
variant="contained"
size="large"
>
Ok
</Button>
</DialogActions>
</>
)}
</Dialog>
);
};
@@ -0,0 +1,96 @@
/*
* 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 { ResponseStepListItem } from './ResponseStepListItem';
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();
});
});
@@ -0,0 +1,135 @@
/*
* 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, useState } from 'react';
import { green, red } from '@material-ui/core/colors';
import {
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 { TEST_IDS } from '../../test-helpers/test-ids';
import { ResponseStep } from '../../types/types';
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,
index,
animationDelay = 300,
}: ResponseStepListItemProps) => {
const classes = useStyles({ animationDelay });
const [renderMe, setRenderMe] = useState(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
setRenderMe(true);
}, animationDelay * index);
return () => clearTimeout(timeoutId);
}, [animationDelay, index, setRenderMe]);
function ItemIcon() {
if (responseStep.icon === 'success') {
return (
<CheckCircleOutline
data-testid={TEST_IDS.components.responseStepListItemIconSuccess}
style={{ color: green[500] }}
/>
);
}
if (responseStep.icon === 'failure') {
return (
<ErrorOutlineIcon
data-testid={TEST_IDS.components.responseStepListItemIconFailure}
style={{ color: 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} ${renderMe ? classes.shown : classes.hidden}`}
data-testid={TEST_IDS.components.responseStepListItem}
>
<ListItemIcon>
<ItemIcon />
</ListItemIcon>
<ListItemText
primary={responseStep.message}
secondary={responseStep.secondaryMessage}
/>
</ListItem>
);
};
@@ -0,0 +1,30 @@
/*
* 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 {
"SEMVER_PARTS": Object {
"major": "major",
"minor": "minor",
"patch": "patch",
},
}
`);
});
});
@@ -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.
*/
export const SEMVER_PARTS: {
major: 'major';
minor: 'minor';
patch: 'patch';
} = {
major: 'major',
minor: 'minor',
patch: 'patch',
} as const;
@@ -0,0 +1,22 @@
/*
* 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 ReleaseManagerAsAServiceError extends Error {
constructor(message: string) {
super(message);
this.name = 'ReleaseManagerAsAServiceError';
}
}
@@ -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 { getNewDate } from './date';
describe('getNewDate', () => {
it('should get a date', () => {
const newDate = getNewDate();
expect(newDate.toISOString()).toMatch(
/[\d]{4}-[\d]{2}-[\d]{2}T[\d]{2}:[\d]{2}:[\d]{2}.[\d]{3}Z/,
);
});
});
@@ -0,0 +1,16 @@
/*
* 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 getNewDate = () => new Date();
@@ -0,0 +1,124 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
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",
"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",
"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",
"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",
"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",
"tagParts": Object {
"major": 2,
"minor": 0,
"patch": 0,
"prefix": "rc",
},
}
`);
});
});
});
@@ -0,0 +1,94 @@
/*
* 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 '../types/types';
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 (isCalverTagParts(project, tagParts)) {
return getPatchedCalverTag(tagParts);
}
return getBumpedSemverTag(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,
};
}
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,
};
}
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,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 { 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,28 @@
/*
* 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 { ReleaseManagerAsAServiceError } from '../errors/ReleaseManagerAsAServiceError';
export function getShortCommitHash(hash: string) {
const shortCommitHash = hash.substr(0, 7);
if (shortCommitHash.length < 7) {
throw new ReleaseManagerAsAServiceError(
'Invalid shortCommitHash: less than 7 characters',
);
}
return shortCommitHash;
}
@@ -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 {
mockCalverProject,
mockSemverProject,
} from '../test-helpers/test-helpers';
import { isCalverTagParts } from './isCalverTagParts';
describe('isCalverTagParts', () => {
describe('calver', () => {
it('should return true', () =>
expect(isCalverTagParts(mockCalverProject, {})).toEqual(true));
});
describe('semver', () => {
it('should return false', () =>
expect(isCalverTagParts(mockSemverProject, {})).toEqual(false));
});
});
@@ -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 { CalverTagParts } from './tagParts/getCalverTagParts';
import { Project } from '../types/types';
export function isCalverTagParts(
project: Project,
_tagParts: unknown,
): _tagParts is CalverTagParts {
return project.versioningStrategy === 'calver';
}
@@ -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 { ReleaseManagerAsAServiceError } from '../../errors/ReleaseManagerAsAServiceError';
export type CalverTagParts = {
prefix: string;
calver: string;
patch: number;
};
export function getCalverTagParts(tag: string) {
const result = tag.match(
/(rc|version)-([0-9]{4}\.[0-9]{2}\.[0-9]{2})_([0-9]+)/,
);
if (result === null || result.length < 4) {
throw new ReleaseManagerAsAServiceError('Invalid calver tag');
}
const tagParts: CalverTagParts = {
prefix: result[1],
calver: result[2],
patch: parseInt(result[3], 10),
};
return tagParts;
}
@@ -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 { ReleaseManagerAsAServiceError } from '../../errors/ReleaseManagerAsAServiceError';
export type SemverTagParts = {
prefix: string;
major: number;
minor: number;
patch: number;
};
export function getSemverTagParts(tag: string) {
const result = tag.match(/(rc|version)-([0-9]+)\.([0-9]+)\.([0-9]+)/);
if (result === null || result.length < 4) {
throw new ReleaseManagerAsAServiceError('Invalid semver tag');
}
const tagParts: SemverTagParts = {
prefix: result[1],
major: parseInt(result[2], 10),
minor: parseInt(result[3], 10),
patch: parseInt(result[4], 10),
};
return tagParts;
}
@@ -0,0 +1,112 @@
/*
* 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 { getTagParts } from './getTagParts';
describe('getTagParts', () => {
describe('calver', () => {
it('should return tagParts for RC tag', () =>
expect(
getTagParts({ project: mockCalverProject, tag: 'rc-2020.01.01_1' }),
).toMatchInlineSnapshot(`
Object {
"calver": "2020.01.01",
"patch": 1,
"prefix": "rc",
}
`));
it('should return tagParts for Version tag', () =>
expect(
getTagParts({
project: mockCalverProject,
tag: 'version-2020.01.01_1',
}),
).toMatchInlineSnapshot(`
Object {
"calver": "2020.01.01",
"patch": 1,
"prefix": "version",
}
`));
it('should return null for invalid prefix', () => {
expect(() =>
getTagParts({
project: mockCalverProject,
tag: 'invalid-2020.01.01_1',
}),
).toThrowErrorMatchingInlineSnapshot(`"Invalid calver tag"`);
});
it('should return null for invalid calver (missing padded zero)', () => {
expect(() =>
getTagParts({ project: mockCalverProject, tag: 'rc-2020.1.01_1' }),
).toThrowErrorMatchingInlineSnapshot(`"Invalid calver tag"`);
});
it('should return null for invalid calver (missing day)', () => {
expect(() =>
getTagParts({ project: mockCalverProject, tag: 'rc-2020.01_1' }),
).toThrowErrorMatchingInlineSnapshot(`"Invalid calver tag"`);
});
it('should return null for invalid patch (letter instead of number)', () => {
expect(() =>
getTagParts({ project: mockCalverProject, tag: 'rc-2020.01.01_a' }),
).toThrowErrorMatchingInlineSnapshot(`"Invalid calver tag"`);
});
});
describe('semver', () => {
it('should return tagParts for RC tag', () =>
expect(getTagParts({ project: mockSemverProject, tag: 'rc-1.2.3' }))
.toMatchInlineSnapshot(`
Object {
"major": 1,
"minor": 2,
"patch": 3,
"prefix": "rc",
}
`));
it('should return tagParts for Version tag', () =>
expect(getTagParts({ project: mockSemverProject, tag: 'version-1.2.3' }))
.toMatchInlineSnapshot(`
Object {
"major": 1,
"minor": 2,
"patch": 3,
"prefix": "version",
}
`));
it('should throw for invalid prefix', () => {
expect(() =>
getTagParts({ project: mockSemverProject, tag: 'invalid-1.2.3' }),
).toThrowErrorMatchingInlineSnapshot(`"Invalid semver tag"`);
});
it('should throw for invalid semver (missing patch)', () => {
expect(() =>
getTagParts({ project: mockSemverProject, tag: 'rc-1.2' }),
).toThrowErrorMatchingInlineSnapshot(`"Invalid semver tag"`);
});
});
});
@@ -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 { getCalverTagParts } from './getCalverTagParts';
import { getSemverTagParts } from './getSemverTagParts';
import { Project } from '../../types/types';
export function getTagParts({
project,
tag,
}: {
project: Project;
tag: string;
}) {
if (project.versioningStrategy === 'calver') {
return getCalverTagParts(tag);
}
return getSemverTagParts(tag);
}
@@ -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 {
releaseManagerAsAServicePlugin,
ReleaseManagerAsAServicePage,
} from './plugin';
@@ -0,0 +1,22 @@
/*
* 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 { releaseManagerAsAServicePlugin } from './plugin';
describe('release-manager-as-a-service', () => {
it('should export plugin', () => {
expect(releaseManagerAsAServicePlugin).toBeDefined();
});
});
@@ -0,0 +1,54 @@
/*
* 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 {
configApiRef,
createPlugin,
createApiFactory,
githubAuthApiRef,
createRoutableExtension,
} from '@backstage/core';
import { releaseManagerAsAServiceApiRef } from './api/serviceApiRef';
import { PluginApiClientConfig } from './api/PluginApiClientConfig';
import { rootRouteRef } from './routes';
export const releaseManagerAsAServicePlugin = createPlugin({
id: 'release-manager-as-a-service',
routes: {
root: rootRouteRef,
},
apis: [
createApiFactory({
api: releaseManagerAsAServiceApiRef,
deps: {
configApi: configApiRef,
githubAuthApi: githubAuthApiRef,
},
factory: ({ configApi, githubAuthApi }) =>
new PluginApiClientConfig({ configApi, githubAuthApi }),
}),
],
});
export const ReleaseManagerAsAServicePage = releaseManagerAsAServicePlugin.provide(
createRoutableExtension({
component: () =>
import('./ReleaseManagerAsAService').then(
m => m.ReleaseManagerAsAService,
),
mountPoint: rootRouteRef,
}),
);
@@ -0,0 +1,20 @@
/*
* 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 { createRouteRef } from '@backstage/core';
export const rootRouteRef = createRouteRef({
title: 'release-manager-as-a-service',
});
@@ -0,0 +1,17 @@
/*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
@@ -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 { RMaaSApiClient } from '../api/RMaaSApiClient';
import { getLatestRelease } from './getLatestRelease';
interface GetGitHubBatchInfo {
apiClient: RMaaSApiClient;
}
export const getGitHubBatchInfo = ({
apiClient,
}: GetGitHubBatchInfo) => async () => {
const [{ repository }, latestRelease] = await Promise.all([
apiClient.getRepository(),
getLatestRelease({ apiClient }),
]);
if (latestRelease === null) {
return {
latestRelease,
releaseBranch: null,
repository,
};
}
const { branch } = await apiClient.getBranch({
branchName: latestRelease.target_commitish,
});
return {
latestRelease,
releaseBranch: branch,
repository,
};
};
@@ -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 { mockApiClient } from '../test-helpers/test-helpers';
import { getLatestRelease } from './getLatestRelease';
describe('getLatestRelease', () => {
beforeEach(jest.clearAllMocks);
it('should return the latest release with id=1', async () => {
const result = await getLatestRelease({ apiClient: mockApiClient });
expect(result).toMatchInlineSnapshot(`
Object {
"body": "mock_latest_release",
"html_url": "mock_release_html_url",
"id": 1,
"prerelease": false,
}
`);
});
it('should return early with `null` if no releases found', async () => {
mockApiClient.getReleases.mockImplementationOnce(() => ({ releases: [] }));
const result = await getLatestRelease({ apiClient: mockApiClient });
expect(result).toMatchInlineSnapshot(`null`);
});
});
@@ -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 { RMaaSApiClient } from '../api/RMaaSApiClient';
interface GetLatestRelease {
apiClient: RMaaSApiClient;
}
export async function getLatestRelease({ apiClient }: GetLatestRelease) {
const { releases } = await apiClient.getReleases();
if (releases.length === 0) {
return null;
}
const { latestRelease } = await apiClient.getRelease({
releaseId: releases[0].id,
});
return latestRelease;
}
@@ -0,0 +1,22 @@
/*
* 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 { makeStyles, Theme } from '@material-ui/core';
export const useStyles = makeStyles((_theme: Theme) => ({
paragraph: {
marginBottom: '1em',
},
}));
@@ -0,0 +1,165 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as testHelpers from './test-helpers';
describe('testHelpers', () => {
it('should match snapshot', () => {
expect(testHelpers).toMatchInlineSnapshot(`
Object {
"mockApiClient": Object {
"createRc": Object {
"createRef": [MockFunction],
"createRelease": [MockFunction],
"getComparison": [MockFunction],
},
"getBranch": [MockFunction],
"getLatestCommit": [MockFunction],
"getOctokit": [Function],
"getProject": [MockFunction],
"getRecentCommits": [MockFunction],
"getRelease": [MockFunction],
"getReleases": [MockFunction],
"getRepoPath": [MockFunction],
"getRepository": [MockFunction],
"githubAuthApi": Object {
"getAccessToken": [MockFunction],
},
"patch": Object {
"createCherryPickCommit": [MockFunction],
"createReference": [MockFunction],
"createTagObject": [MockFunction],
"createTempCommit": [MockFunction],
"forceBranchHeadToTempCommit": [MockFunction],
"merge": [MockFunction],
"replaceTempCommit": [MockFunction],
"updateRelease": [MockFunction],
},
"pluginApiClient": Object {
"baseUrl": "http://mock_base_url.hehe",
"getOctokit": [MockFunction],
},
"project": Object {
"github": Object {
"org": "mock_org",
"repo": "mock_repo",
},
"name": "mock_name",
"versioningStrategy": "semver",
},
"promoteRc": Object {
"promoteRelease": [MockFunction],
},
},
"mockBumpedTag": "rc-2020.01.01_1337",
"mockCalverProject": Object {
"github": Object {
"org": "mock_org",
"repo": "mock_repo",
},
"name": "mock_name",
"versioningStrategy": "calver",
},
"mockDefaultBranch": "mock_defaultBranch",
"mockNextGheInfo": Object {
"rcBranch": "rc/1.2.3",
"rcReleaseTag": "rc-1.2.3",
"releaseName": "Version 1.2.3",
},
"mockRcRelease": Object {
"body": "mock_body",
"html_url": "mock_release_html_url",
"id": 1,
"prerelease": true,
"tag_name": "rc-2020.01.01_1",
"target_commitish": "rc/1.2.3",
},
"mockRecentCommits": Array [
Object {
"author": Object {
"html_url": "mock_recentCommits_author_html_url",
"login": "mock_recentCommit_author_login",
},
"commit": Object {
"message": "mock_latestCommit_message",
},
"html_url": "mock_latestCommit_html_url",
"node_id": "1",
"sha": "mock_latestCommit_sha",
},
Object {
"author": Object {
"html_url": "mock_recentCommits_author_html_url",
"login": "mock_recentCommit_author_login",
},
"commit": Object {
"message": "mock_latestCommit_message",
},
"html_url": "mock_latestCommit_html_url",
"node_id": "2",
"sha": "mock_latestCommit_sha",
},
],
"mockReleaseBranch": Object {
"_links": Object {
"html": "mock_branch__links_html",
},
"commit": Object {
"commit": Object {
"tree": Object {
"sha": "mock_branch_commit_commit_tree_sha",
},
},
"sha": "mock_branch_commit_sha",
},
"name": "rc/1.2.3",
},
"mockReleaseVersion": Object {
"body": "mock_body",
"html_url": "mock_release_html_url",
"id": 1,
"prerelease": false,
"tag_name": "version-2020.01.01_1",
"target_commitish": "rc/1.2.3",
},
"mockSelectedPatchCommit": Object {
"author": Object {
"html_url": "mock_recentCommits_author_html_url",
"login": "mock_recentCommit_author_login",
},
"commit": Object {
"message": "mock_latestCommit_message",
},
"html_url": "mock_latestCommit_html_url",
"node_id": "mock_selected_patch_commit",
"sha": "mock_latestCommit_sha",
},
"mockSemverProject": Object {
"github": Object {
"org": "mock_org",
"repo": "mock_repo",
},
"name": "mock_name",
"versioningStrategy": "semver",
},
"mockTagParts": Object {
"calver": "2020.01.01",
"patch": 1,
"prefix": "rc",
},
}
`);
});
});
@@ -0,0 +1,255 @@
/*
* 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 '../helpers/tagParts/getCalverTagParts';
import { getRcGheInfo } from '../cards/createRc/getRcGheInfo';
import {
GhCompareCommitsResponse,
GhCreateCommitResponse,
GhCreateReferenceResponse,
GhCreateReleaseResponse,
GhCreateTagObjectResponse,
GhGetBranchResponse,
GhGetCommitResponse,
GhGetReleaseResponse,
GhMergeResponse,
GhUpdateReferenceResponse,
GhUpdateReleaseResponse,
Project,
} from '../types/types';
export const mockSemverProject: Project = {
github: {
org: 'mock_org',
repo: 'mock_repo',
},
name: 'mock_name',
versioningStrategy: 'semver',
};
export const mockCalverProject: Project = {
github: {
org: 'mock_org',
repo: 'mock_repo',
},
name: 'mock_name',
versioningStrategy: 'calver',
};
export const mockDefaultBranch = 'mock_defaultBranch';
export const mockNextGheInfo: ReturnType<typeof getRcGheInfo> = {
rcBranch: 'rc/1.2.3',
rcReleaseTag: 'rc-1.2.3',
releaseName: 'Version 1.2.3',
};
export const mockTagParts = {
prefix: 'rc',
calver: '2020.01.01',
patch: 1,
} as CalverTagParts;
export const mockBumpedTag = 'rc-2020.01.01_1337';
/**
* MOCK RELEASE
*/
const createMockRelease = ({
id = 1,
prerelease = false,
...rest
}: Partial<GhGetReleaseResponse> = {}) =>
({
id: 1,
body: 'mock_body',
html_url: 'mock_release_html_url',
prerelease,
...rest,
} as GhGetReleaseResponse);
export const mockRcRelease = createMockRelease({
prerelease: true,
tag_name: 'rc-2020.01.01_1',
target_commitish: 'rc/1.2.3',
});
export const mockReleaseVersion = createMockRelease({
prerelease: false,
tag_name: 'version-2020.01.01_1',
target_commitish: 'rc/1.2.3',
});
/**
* MOCK BRANCH
*/
const createMockBranch = ({ ...rest }: Partial<GhGetBranchResponse> = {}) =>
({
name: 'rc/1.2.3',
commit: {
sha: 'mock_branch_commit_sha',
commit: { tree: { sha: 'mock_branch_commit_commit_tree_sha' } },
},
_links: { html: 'mock_branch__links_html' },
...rest,
} as GhGetBranchResponse);
export const mockReleaseBranch = createMockBranch();
/**
* MOCK COMMIT
*/
const createMockCommit = ({ node_id = '1' }: Partial<GhGetCommitResponse>) =>
({
node_id,
author: {
html_url: 'mock_recentCommits_author_html_url',
login: 'mock_recentCommit_author_login',
},
commit: {
message: 'mock_latestCommit_message',
},
html_url: 'mock_latestCommit_html_url',
sha: 'mock_latestCommit_sha',
} as GhGetCommitResponse);
export const mockRecentCommits = [
createMockCommit({ node_id: '1' }),
createMockCommit({ node_id: '2' }),
] as GhGetCommitResponse[];
export const mockSelectedPatchCommit = createMockCommit({
node_id: 'mock_selected_patch_commit',
});
/**
* MOCK API CLIENT
*/
export const mockApiClient = {
pluginApiClient: {
getOctokit: jest.fn(),
baseUrl: 'http://mock_base_url.hehe',
},
getRepoPath: jest.fn(() => 'erikengervall/playground'),
getRecentCommits: jest.fn().mockResolvedValue({
recentCommits: mockRecentCommits,
}),
getReleases: jest.fn().mockResolvedValue({
releases: [
createMockRelease({ id: 1, body: 'mock_releases[0]' }),
createMockRelease({ id: 2, body: 'mock_releases[1]' }),
],
}),
getRelease: jest.fn().mockResolvedValue({
latestRelease: createMockRelease({ id: 1, body: 'mock_latest_release' }),
}),
getBranch: jest.fn().mockResolvedValue({
branch: mockReleaseBranch,
}),
getLatestCommit: jest.fn().mockResolvedValue({
latestCommit: createMockCommit({ node_id: 'mock_latest_commit' }),
}),
getOctokit: () => ({
octokit: {
request: jest.fn(),
},
}),
getProject: jest.fn(),
getRepository: jest.fn(),
githubAuthApi: {
getAccessToken: jest.fn(),
},
createRc: {
createRef: jest.fn().mockResolvedValue({
createdRef: {
ref: 'mock_createRef_ref',
} as GhCreateReferenceResponse,
}),
createRelease: jest.fn().mockResolvedValue({
createReleaseResponse: {
name: 'mock_createRelease_name',
html_url: 'mock_createRelease_html_url',
tag_name: 'mock_createRelease_tag_name',
} as GhCreateReleaseResponse,
}),
getComparison: jest.fn().mockResolvedValue({
comparison: {
html_url: 'mock_compareCommits_html_url',
ahead_by: 1,
} as GhCompareCommitsResponse,
}),
},
patch: {
createCherryPickCommit: jest.fn().mockResolvedValue({
cherryPickCommit: {
commit: {
message: 'mock_merge_commit_message',
tree: { sha: 'mock_merge_commit_tree_sha' },
},
html_url: 'mock_merge_html_url',
} as GhMergeResponse,
}),
createReference: jest.fn().mockResolvedValue({
reference: {
ref: 'mock_reference_ref',
} as GhCreateReferenceResponse,
}),
createTagObject: jest.fn().mockResolvedValue({
tagObjectResponse: {
tag: 'mock_tag_object_tag',
sha: 'mock_tag_object_sha',
} as GhCreateTagObjectResponse,
}),
createTempCommit: jest.fn().mockResolvedValue({
tempCommit: {
message: 'mock_commit_message',
sha: 'mock_commit_sha',
} as GhCreateCommitResponse,
}),
forceBranchHeadToTempCommit: jest.fn().mockResolvedValue(undefined),
merge: jest.fn().mockResolvedValue({
merge: {
commit: {
message: 'mock_merge_commit_message',
tree: { sha: 'mock_merge_commit_tree_sha' },
},
html_url: 'mock_merge_html_url',
} as GhMergeResponse,
}),
replaceTempCommit: jest.fn().mockResolvedValue({
updatedReference: {
ref: 'mock_reference_ref',
object: { sha: 'mock_reference_object_sha' },
} as GhUpdateReferenceResponse,
}),
updateRelease: jest.fn().mockResolvedValue({
release: {
name: 'mock_update_release_name',
tag_name: 'mock_update_release_tag_name',
html_url: 'mock_update_release_html_url',
} as GhUpdateReleaseResponse,
}),
},
promoteRc: {
promoteRelease: jest.fn().mockResolvedValue({
release: {
name: 'mock_release_name',
tag_name: 'mock_release_tag_name',
html_url: 'mock_release_html_url',
} as GhGetReleaseResponse,
}),
},
project: mockSemverProject,
} as any;
@@ -0,0 +1,53 @@
/*
* 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 TEST_IDS = {
info: {
info: 'rmaas--info',
infoCardPlus: 'rmaas--info-card-plus',
},
createRc: {
cta: 'rmaas--create-rc--cta',
semverSelect: 'rmaas--create-rc--semver-select',
},
promoteRc: {
mockedPromoteRcBody: 'rmaas-mocked-promote-rc-body',
notRcWarning: 'rmaas--promote-rc--not-rc-warning',
promoteRc: 'rmaas--promote-rc',
cta: 'rmaas--promote-rc-body--cta',
},
patch: {
error: 'rmaas--patch-body--error',
loading: 'rmaas--patch-body--loading',
notPrerelease: 'rmaas--patch-body--not-prerelease--info',
body: 'rmaas--patch-body',
},
components: {
divider: 'rmaas--divider',
reloadButton: 'rmaas--reload-button',
noLatestRelease: 'rmaas--no-latest-release',
circularProgress: 'rmaas--circular-progress',
responseStepListDialogContent: 'rmaas--response-step-list--dialog-content',
responseStepListItem: 'rmaas--response-step-list-item',
responseStepListItemIconSuccess:
'rmaas--response-step-list-item--item-icon--success',
responseStepListItemIconFailure:
'rmaas--response-step-list-item--item-icon--failure',
responseStepListItemIconLink:
'rmaas--response-step-list-item--item-icon--link',
responseStepListItemIconDefault:
'rmaas--response-step-list-item--item-icon--default',
},
};
File diff suppressed because it is too large Load Diff
+5
View File
@@ -11408,6 +11408,11 @@ date-fns@^2.0.0-alpha.27, date-fns@^2.16.1:
resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.18.0.tgz#08e50aee300ad0d2c5e054e3f0d10d8f9cdfe09e"
integrity sha512-NYyAg4wRmGVU4miKq5ivRACOODdZRY3q5WLmOJSq8djyzftYphU7dTHLcEtLqEvfqMKQ0jVv91P4BAwIjsXIcw==
date-fns@^2.19.0:
version "2.19.0"
resolved "https://artifactory.spotify.net/artifactory/api/npm/virtual-npm/date-fns/-/date-fns-2.19.0.tgz#65193348635a28d5d916c43ec7ce6fbd145059e1"
integrity sha1-ZRkzSGNaKNXZFsQ+x85vvRRQWeE=
dateformat@^3.0.0:
version "3.0.3"
resolved "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"